Skip to content

网络协议

游戏为什么常用 UDP?

核心解释

游戏常用 UDP,不是因为 UDP 比 TCP “高级”,而是因为实时游戏更在意低延迟和最新状态。移动、朝向、技能释放、服务器快照这些数据,如果旧包丢了,很多时候直接用下一帧的新包就行,没必要为了补旧包把整个画面卡住。

network-why-games-use-udp

为什么不用 TCP 做实时战斗

TCP 的特点是:

可靠
有序
自动重传
保证数据完整

这听起来很好,但实时游戏里有个问题:队头阻塞。

比如服务器连续发:

位置快照 100
位置快照 101
位置快照 102
位置快照 103
位置快照 104

如果 102 丢了,TCP 会先想办法把 102 补回来。即使 103104 已经到了,应用层也可能要等 102。 但对游戏来说,102 已经是旧位置了,玩家更需要的是最新的 104

所以 TCP 的可靠有序,在实时战斗里反而可能变成卡顿和延迟尖刺。

UDP 为什么适合游戏

UDP 的特点是:

不保证可靠
不保证顺序
不自动重传
包到了就可以处理

这刚好适合实时状态同步。

比如角色位置包丢了一个:

Tick 100:位置 A
Tick 101:位置 B
Tick 102:丢了
Tick 103:位置 D

客户端可以直接用 Tick 103 的位置,再通过插值、预测、平滑校正让画面看起来连续。

也就是说,实时游戏很多数据不是“必须每一条都到”,而是“最新的数据更有价值”。

但 UDP 不等于不可靠

面试里一定要讲这一句:

游戏用 UDP,不代表所有消息都允许丢。 而是游戏自己根据消息类型决定哪些可靠,哪些不可靠。

比如:

角色位置:可以丢,下一帧还有
朝向同步:可以丢,下一帧还有
移动输入:通常要加序号
技能释放:需要可靠
伤害结算:必须可靠
掉落奖励:必须可靠
登录支付:一般不用 UDP

所以很多游戏会在 UDP 上自己做一层可靠协议:

sequence number:消息序号
ack:确认收到
resend:超时重传
deduplicate:去重
order check:顺序检查

这样可以做到:重要消息可靠,普通状态消息低延迟。

项目里怎么用

实时战斗一般可以这样拆:

UDP:
移动同步
状态快照
帧同步输入
技能表现同步
战斗中的低延迟消息

TCP / HTTP:
登录
充值
背包
邮件
商城
公告
账号数据

这样设计的好处是:实时战斗不会被旧包拖慢,重要系统又能保证数据安全和完整。

面试高分回答

TIP

游戏常用 UDP,主要是因为实时游戏对延迟非常敏感。TCP 虽然可靠有序,但丢包后会触发重传和队头阻塞,导致后面的新状态也被旧包卡住。而 UDP 不保证可靠和顺序,包到了就能处理,更适合位置、朝向、服务器快照这类高频状态同步。游戏通常会在 UDP 之上自己实现序号、确认、重传和去重,只让关键消息可靠,普通状态消息允许丢包,用预测、插值和平滑校正保证表现。

一句话记忆

游戏用 UDP,是因为实时战斗宁愿丢一个旧包,也不愿为了等旧包卡住新状态。

UDP 如何实现可靠消息?

核心解释

UDP 本身不可靠。所谓“可靠 UDP”,就是在应用层自己加一套机制:序号、ACK 确认、超时重传、去重、排序。

network-udp-reliable-message

基本流程

发送端做这些事:

1. 发送可靠消息前,分配一个递增 seq
2. 把消息发出去
3. 把消息存进 pending 表
4. 等待接收端 ACK
5. 超过一定时间没收到 ACK,就重发
6. 收到 ACK 后,从 pending 表删除

接收端做这些事:

1. 收到 UDP 包
2. 读取 seq
3. 判断这个 seq 是否处理过
4. 没处理过就交给业务层执行
5. 处理过就丢弃,避免重复执行
6. 无论是否重复,都回 ACK

为什么要去重

因为可靠 UDP 会重传。

比如发送端发送了:

seq = 18:释放技能

接收端其实已经收到了,也执行了技能,但是 ACK 在回来的路上丢了。

发送端以为对方没收到,于是又发一次 seq = 18

如果接收端不去重,就会导致同一个技能执行两次。

所以接收端必须记录最近处理过的 seq,重复包只能回 ACK,不能再次执行业务。

可靠有序和可靠无序

可靠 UDP 不一定都要有序。

可靠无序:

保证消息至少到达
保证重复消息不会重复执行
不保证严格顺序

适合:

战斗事件
音效事件
某些状态通知

可靠有序:

保证到达
保证按 seq 顺序交给业务
中间缺一个包,后面的先缓存

适合:

房间流程
匹配状态
重要战斗指令
结算流程

但是可靠有序会重新带来一点“队头阻塞”,所以游戏里不要所有消息都做可靠有序。

哪些消息需要可靠

通常需要可靠:

释放技能
购买物品
任务提交
掉落拾取
伤害结算
进入房间
准备状态

通常不需要可靠:

位置同步
朝向同步
速度同步
动画参数
服务器快照

因为位置这种高频状态,旧包丢了没有关系,下一包会覆盖它。

面试高分回答

TIP

UDP 实现可靠消息,一般是在应用层做一个可靠通道。发送可靠消息时给包分配递增序号,并把包放入发送缓存;接收端收到后根据序号去重,第一次收到才投递给业务层,然后返回 ACK;发送端收到 ACK 后删除缓存,如果超时未确认就重传。对于需要顺序的消息,还要维护 expectedSeq,把乱序到达的包先缓存,等前面的包补齐后再按顺序投递。游戏里通常不会所有 UDP 消息都可靠,只会对关键消息做 ACK 和重传,高频状态同步允许丢包。

一句话记忆

可靠 UDP 就是:UDP 只负责送包,可靠性由游戏自己用 seq、ACK、重传、去重、排序补上。

包序号有什么用?

核心解释

包序号就是给每个网络包贴一个递增编号,比如 seq = 18seq = 19seq = 20。它本身不是业务数据,而是网络层用来判断这个包“新不新、有没有丢、是不是重复、要不要重传”的依据。

network-packet-sequence-number

为什么需要包序号

UDP 可能出现这几种情况:

包丢了
包乱序到了
包重复到了
旧包比新包晚到

如果没有包序号,接收端只知道“来了一个包”,但不知道这个包是新的还是旧的。

比如服务器发送位置:

seq 18:玩家位置 x = 10
seq 19:玩家位置 x = 11
seq 20:玩家位置 x = 12

客户端先收到 seq 20,又晚一点收到 seq 18

如果没有序号,客户端可能把玩家位置从 x = 12 又改回 x = 10,画面就会倒退。

有了序号,客户端就知道:

我已经处理到 seq 20
seq 18 是旧包
直接丢弃

包序号的主要作用

第一,判断新旧。

lastSeq = 20
收到 seq = 18
说明是旧包,丢弃

第二,发现丢包。

收到 seq = 18
下一个收到 seq = 20
中间少了 seq = 19
说明 19 可能丢了

第三,去重。

可靠 UDP 会重传,所以同一个包可能收到多次。

seq = 21 已经执行过
又收到 seq = 21
说明是重复包
回 ACK,但不重复执行业务

不去重的话,可能出现“技能放两次”“奖励领两次”“扣血扣两次”。

第四,配合 ACK 做可靠消息。

发送端发送 seq = 30
接收端收到后返回 ack = 30
发送端收到 ack = 30
删除 seq = 30 的缓存,不再重传

如果迟迟收不到 ACK,发送端就认为可能丢包,然后重传。

第五,做有序投递。

有些消息必须按顺序执行,比如:

进入房间
选择角色
加载完成
开始战斗

如果先收到 seq = 12,但 seq = 11 还没到,可以先缓存 12,等 11 到了再按顺序交给业务层。

在游戏里怎么用

位置同步一般是“不可靠 + 序号”。

也就是说,位置包可以丢,但旧包不能覆盖新包。

收到更新的 seq:应用
收到更旧的 seq:丢弃

技能释放、拾取掉落、任务提交一般是“可靠 + 序号 + ACK + 重传”。

必须到达
不能重复执行
必要时按顺序处理

所以包序号是可靠 UDP 的基础,没有序号就很难做 ACK、重传、去重和排序。

容易混淆的一点

包序号不一定等于游戏 Tick。

包序号:网络包的编号
游戏 Tick:游戏逻辑帧编号

一个 Tick 里可能发多个包,一个包里也可能包含多个 Tick 的输入。

它们经常一起出现,但含义不同。

面试高分回答

NOTE

包序号主要用于 UDP 通信中的乱序处理、丢包检测、重复包过滤和可靠重传。发送端给每个包分配递增序号,接收端根据序号判断这个包是新包、旧包还是重复包。对于状态同步,旧序号的包可以直接丢弃,避免旧状态覆盖新状态;对于可靠消息,接收端收到后返回 ACK,发送端超时未收到 ACK 就重传,同时接收端用序号去重,防止业务重复执行。如果要求有序,还可以用 expectedSeq 缓存乱序包,按顺序投递给业务层。

一句话记忆

包序号就是 UDP 世界里的“时间线”和“小票据”:知道谁先谁后,谁丢了,谁重复了,谁已经确认了。

ACK 机制是什么?

核心解释

ACK 是 Acknowledgement,意思是“确认应答”。 它的作用是:接收端告诉发送端“这个包我收到了”,发送端收到 ACK 后,就可以把这个包从等待重传列表里删掉。

network-ack-mechanism

为什么需要 ACK

UDP 本身只负责把包发出去,但不保证对方一定收到。

所以发送端发出一个可靠消息后,不能马上认为成功。它需要等接收端回一个 ACK。

比如:

发送端:我发了 seq = 42 的技能释放消息
接收端:我收到了 seq = 42
接收端:返回 ack = 42
发送端:收到 ack = 42,删除缓存,不再重传

完整流程

发送端:

1. 给可靠消息分配 seq
2. 发送这个 UDP 包
3. 把包存到 pending 表
4. 等待 ACK
5. 收到 ACK 后删除 pending 里的包
6. 如果超时没收到 ACK,就重传

接收端:

1. 收到 UDP 包
2. 读取 seq
3. 判断是否已经处理过
4. 没处理过就执行业务
5. 处理过就不重复执行
6. 返回 ACK

为什么 ACK 要配合去重

假设接收端收到了 seq = 42,并且已经执行了技能释放。

但是 ACK 在回来的路上丢了。

发送端没有收到 ACK,就会重传 seq = 42

这时候接收端会再次收到同一个包。

所以接收端必须判断:

seq = 42 已经处理过
这次是重复包
只回 ACK
不再执行技能

否则同一个技能可能释放两次,同一份奖励可能领取两次。

几种 ACK 方式

普通 ACK:

收到 seq = 42
返回 ack = 42

简单直接,但 ACK 包可能比较多。

累计 ACK:

ack = 45
表示 45 以及之前连续的包都收到了

优点是省流量,缺点是对乱序情况表达不够细。

选择性 ACK:

ack = 45
mask = 101101

它可以表示“我确认到 45,同时附近哪些包也收到了”。 游戏里的可靠 UDP,经常会用类似 ack + bitmask 的方式减少 ACK 数量。

游戏里怎么用

不是所有消息都需要 ACK。

需要 ACK 的通常是:

释放技能
拾取道具
购买物品
任务提交
战斗结算
房间准备

不一定需要 ACK 的通常是:

位置同步
朝向同步
速度同步
动画状态
普通快照

因为位置同步这种高频状态,丢了一包等下一包就行。

面试高分回答

IMPORTANT

ACK 机制就是确认应答机制。发送端给可靠消息分配序号并缓存,接收端收到后返回对应 ACK,发送端收到 ACK 后删除缓存;如果超过一定时间没收到 ACK,就认为包或 ACK 可能丢了,于是重传。为了避免重传导致业务重复执行,接收端还要根据包序号做去重。实际游戏网络里通常不会所有 UDP 包都走 ACK,而是只对关键消息做可靠传输,普通状态同步允许丢包。

一句话记忆

ACK 就是网络包的“回执”:收到回执就放心删缓存,没收到回执就准备重传。

心跳和超时如何设计?

核心解释

心跳就是定期发一个很小的包,确认对方还在线。超时就是长时间收不到对方任何有效消息,就认为连接异常,进入弱网提示、重连或断开流程。

network-heartbeat-timeout-design

为什么需要心跳

网络断开不一定会立刻通知程序。

比如玩家切后台、断 WiFi、弱网、电梯里信号消失,客户端可能还以为连接没断。 所以要靠心跳主动检测:

客户端:你还在吗?Ping
服务器:我还在。Pong

只要能持续收到对方消息,就说明连接还活着。

基本设计

客户端保存几个时间:

lastSendHeartbeatTime:上次发送心跳的时间
lastRecvTime:上次收到服务器消息的时间
heartbeatInterval:心跳发送间隔
timeout:超时时间

逻辑大概是:

每隔 heartbeatInterval 秒发送一次 Ping
收到 Pong 或任何服务器业务消息时,刷新 lastRecvTime
当前时间 - lastRecvTime 大于 timeout,就认为连接超时

注意:不一定只有 Pong 才能刷新连接状态。 如果这段时间一直收到服务器的战斗快照、聊天消息、同步包,也说明连接还活着,可以刷新 lastRecvTime

间隔怎么设置

常见做法:

心跳间隔:1 到 5 秒
超时时间:心跳间隔的 3 到 5 倍

比如:

heartbeatInterval = 2 秒
timeout = 8 秒

这样不会因为偶尔丢一两个包就误判掉线,也不会等太久才发现断线。

客户端超时后做什么

一般不要立刻退出游戏,可以分阶段:

1. 短时间没收到包:显示弱网提示
2. 超过超时阈值:进入断线重连
3. 重连成功:请求服务器补状态
4. 重连失败:返回登录界面或提示重新连接

比如动作游戏里,断线重连后不能只靠本地状态继续玩,要向服务器请求权威状态。

服务器超时后做什么

服务器也要记录每个客户端的最后活跃时间:

lastClientActiveTime

如果某个玩家很久没发任何消息:

1. 标记为疑似掉线
2. 暂停或托管角色
3. 保留一段重连窗口
4. 超过窗口后真正踢下线

不要玩家一超时就立刻销毁数据,因为移动网络很容易短暂抖动。

TCP 和 UDP 都需要吗

需要。

TCP 有自己的 keepalive,但默认间隔通常很长,不适合游戏实时断线检测。

UDP 本身没有连接概念,更必须自己做心跳和超时。

所以游戏一般都会做应用层心跳。

面试高分回答

CAUTION

心跳机制一般是在应用层定时发送一个轻量包,比如 Ping,里面带序号和发送时间。对端收到后返回 Pong,发送端收到 Pong 后刷新最后收包时间,并可以用当前时间减发送时间计算 RTT。超时检测不是只看心跳包,而是看“多久没有收到任何有效消息”。如果当前时间减去 lastRecvTime 超过阈值,就认为连接异常。客户端可以先显示弱网,再尝试断线重连;服务器则可以先标记玩家掉线,保留重连窗口,超过窗口后再清理连接。

一句话记忆

心跳负责证明连接还活着,超时负责判断连接可能已经死了。

消息压缩如何做?

核心解释

消息压缩不是一上来就套 GZip。游戏网络里更常见的思路是:先少发,再小发,最后才压缩。也就是先减少消息内容,再用更紧凑的二进制格式表达,只有比较大的包才考虑通用压缩算法。

network-message-compression

第一步:少发

最好的压缩,是不要发不必要的数据。

比如状态同步里,不要每帧把所有玩家、所有怪物、所有字段都发出去。

可以做:

只发变化过的字段
只发玩家附近的对象
只发客户端关心的对象
降低不重要对象的同步频率

这叫差量同步和兴趣管理。

第二步:小发

不要用特别冗长的文本格式传实时消息。

比如 JSON:

{
  "playerId": 1001,
  "positionX": 12.345,
  "positionY": 0.0,
  "positionZ": 9.876
}

可读性很好,但包体偏大。

实时同步更常用二进制协议:

playerId: int
x: short
y: short
z: short
state: byte

这样同样的信息可以用更少字节表示。

第三步:量化

位置、方向、血量百分比,不一定都需要完整 float 精度。

比如地图坐标范围是 0 到 1000,可以把 float 量化成 ushort:

12.345 米 -> 1234

客户端再反量化回来:

1234 -> 12.34 米

这样 float 原本 4 字节,可能可以变成 short 2 字节。 对于位置、角度、速度这种高频同步字段,收益很明显。

第四步:bit packing

多个 bool 不要每个都用一个 byte。

比如:

c
isMoving
isJumping
isAttacking
isDead
isVisible

可以打包到一个 byte 里:

00010111

每一位表示一个状态。这样多个布尔值只需要 1 个字节。

第五步:差量同步

如果上一帧已经发过完整状态,这一帧就只发变化。

比如上一帧:

hp = 100
mp = 50
x = 10
z = 20

这一帧只有位置变了:

x = 11
z = 20.5

那就没必要再发 hp 和 mp。

常见做法是加一个字段掩码:

mask = 0011
表示 x 和 z 发生变化
后面只跟 x 和 z 的数据

第六步:大包再用压缩算法

比如:

配置表
地图数据
战报
回放文件
聊天历史
批量快照

这些比较大的数据可以用:

LZ4
Zstd
GZip
Brotli

游戏实时消息常用 LZ4,因为速度快。 但是小包不一定适合压缩,因为压缩本身有包头和 CPU 代价,有时压完反而没小多少。

项目里要注意

实时战斗消息不要盲目压缩。先看带宽瓶颈还是 CPU 瓶颈。

UDP 包也不要太大,太大可能触发 IP 分片。分片一旦丢一片,整个包都废了。

频繁创建压缩缓冲区也可能产生 GC,所以 Unity 里要复用 byte buffer。

面试高分回答

NOTE

消息压缩我会分层做。第一层是减少发送内容,比如兴趣管理、差量同步、降低同步频率;第二层是使用紧凑的二进制协议,比如字段掩码、ID 映射、bit packing、把 float 量化成 short;第三层才是对较大的消息使用 LZ4、Zstd、GZip 这类通用压缩。实时游戏的小包不一定适合通用压缩,因为 CPU 和包头成本可能超过收益,所以要根据消息大小、发送频率、带宽和延迟要求来决定。

一句话记忆

消息压缩的顺序是:先少发,再小发,大包才真正压缩。

协议版本如何兼容?

核心解释

协议版本兼容就是:客户端和服务器版本不完全一致时,仍然能安全通信。核心原则是连接时先检查版本,消息字段尽量“只新增,不破坏旧字段”,老客户端遇到新字段能跳过,新客户端读旧消息时能用默认值补齐。

network-protocol-version-compatibility

为什么需要兼容

游戏上线后,客户端不会同时更新。

可能出现:

玩家 A 是 1.0 客户端
玩家 B 是 1.1 客户端
服务器已经升级到 1.2

如果协议不兼容,轻则字段解析失败,重则登录失败、战斗异常、客户端崩溃。

所以协议设计不能只考虑“当前版本能跑”,还要考虑“新旧版本混在一起能不能活”。

握手阶段怎么做

客户端连接服务器时,先发版本信息:

clientVersion = 1.3
protocolVersion = 24
resourceVersion = 105
capabilities = 支持哪些功能

服务器根据这些信息判断:

版本太低:拒绝连接,提示强更
版本兼容:允许进入
版本较旧:关闭某些新功能,走降级协议
版本较新:服务器不认识,也要谨慎拒绝或走兼容模式

消息结构怎么设计

最重要的原则是:只增不改。

可以做:

新增字段
新增消息类型
新增可选字段
给新字段设置默认值
未知字段允许跳过

不要做:

删除旧字段
复用字段编号
修改旧字段含义
修改字段类型
修改枚举值含义
依赖字段顺序解析

比如旧协议:

PlayerInfo:
id
name
level

新协议可以加:

PlayerInfo:
id
name
level
title
skinId

老客户端不认识 titleskinId,跳过就行。 新客户端读老数据时没有 titleskinId,就用默认值。

Protobuf 这类协议要注意

如果用 Protobuf,字段编号非常重要。

比如:

1 = playerId
2 = playerName
3 = level

以后不能把 3 改成别的含义,也不要复用删掉的字段编号。

正确做法是:

保留旧字段编号
新增字段用新编号
删除字段时 reserved
旧客户端忽略未知字段
新客户端给缺失字段默认值

这就是为什么 Protobuf 很适合做协议兼容。

能力开关比单纯版本号更灵活

只看版本号有时不够。

比如某个客户端虽然是 1.3,但它可能不支持某个实验功能。 所以可以加 capability:

supportsReplay = true
supportsNewSkillSystem = false
supportsDeltaSnapshot = true

服务器发消息时根据能力决定:

支持新技能协议:发 SkillV2
不支持新技能协议:发 SkillV1

这对灰度发布很有用。

服务器如何支持多版本

服务器通常会维护:

当前协议版本
最低支持协议版本
不同版本的消息转换器
不同功能的开关

例如:

protocolVersion < 20:强制更新
20 到 24:兼容运行
25 以上:新协议

如果新旧协议差异比较大,可以在服务器内部统一成一套业务模型,再根据客户端版本转换成对应协议。

面试高分回答

TIP

协议版本兼容一般从两个层面做。连接层面,客户端登录或握手时带上协议版本、资源版本和能力列表,服务器判断是否支持,太旧的版本强更,兼容版本走降级逻辑。消息层面,协议设计要尽量只增不改,新增字段做成可选字段,老客户端能跳过未知字段,新客户端读旧消息时使用默认值;字段编号、枚举含义和旧字段语义不能随便改。如果是 Protobuf,要避免复用字段编号,删除字段要 reserved。上线时服务器通常会同时支持多个协议版本,并通过灰度、能力开关和兼容性测试保证新旧客户端都能稳定运行。

一句话记忆

协议兼容就是:握手时判断能不能聊,通信时保证新旧字段都能安全读。

粘包和半包如何处理?

核心解释

粘包和半包主要发生在 TCP,因为 TCP 是“字节流”,它不保留应用层消息边界。你发送 3 条消息,接收端不一定刚好读到 3 次;可能一次读到两条,也可能一条消息分两次读到。

network-sticky-half-packet

什么是粘包

发送端连续发送:

A
B
C

接收端一次 Read 可能读到:

A + B

这叫粘包。问题是:你以为收到一条消息,其实里面粘了多条。

什么是半包

发送端发送一条完整消息:

A

接收端第一次可能只读到:

A 的前半段

第二次才读到后半段。这叫半包。问题是:数据不完整时不能强行解析。

根本原因

TCP 只保证字节顺序可靠,不保证你 Send 几次,对方就 Receive 几次。

所以:

Send 一次,不等于 Receive 一次
Receive 一次,也不等于一条完整消息

常见解决方案

最常用的是“长度头 + 消息体”:

[bodyLength][messageBody]

例如:

前 4 字节:消息体长度
后面 N 字节:真正的消息内容

接收端逻辑:

1. 收到数据后先放进缓存区
2. 缓存不足 4 字节,继续等
3. 够 4 字节后读出 bodyLength
4. 如果缓存不足 4 + bodyLength,继续等
5. 如果够一整包,就切出来解析
6. 缓存里可能还有下一包,所以循环继续拆

C# 教学版拆包代码

c
using System; // 引入 Action 委托类型。
using System.Collections.Generic; // 引入 List 用来做接收缓存。
public sealed class TcpPacketDecoder // 定义一个 TCP 拆包器类。
{ // 类开始。
    private const int HeaderSize = 4; // 包头固定 4 字节,用来保存消息体长度。
    private const int MaxBodySize = 1024 * 1024; // 限制单个消息体最大 1MB,避免恶意大包。
    private readonly List<byte> buffer = new List<byte>(); // 保存还没有解析完的 TCP 字节流。
    public void Feed(byte[] bytes, Action<byte[]> onPacket) // 外部每次收到 TCP 数据时调用这个方法。
    { // 方法开始。
        buffer.AddRange(bytes); // 把新收到的字节追加到缓存末尾。
        while (true) // 循环尝试从缓存中拆出完整包。
        { // 循环开始。
            if (buffer.Count < HeaderSize) // 如果连 4 字节包头都不够。
            { // 判断开始。
                return; // 数据不够,先等下一次接收。
            } // 判断结束。
            byte[] headerBytes = buffer.GetRange(0, HeaderSize).ToArray(); // 取出前 4 字节作为长度头。
            int bodyLength = BitConverter.ToInt32(headerBytes, 0); // 把 4 字节转换成消息体长度。
            if (bodyLength < 0 || bodyLength > MaxBodySize) // 如果长度非法或超过上限。
            { // 判断开始。
                throw new Exception("Invalid packet length."); // 说明协议异常,真实项目里通常会断开连接。
            } // 判断结束。
            int fullLength = HeaderSize + bodyLength; // 完整包长度等于包头长度加消息体长度。
            if (buffer.Count < fullLength) // 如果当前缓存还不够一整包。
            { // 判断开始。
                return; // 半包情况,继续等待后续数据。
            } // 判断结束。
            byte[] body = buffer.GetRange(HeaderSize, bodyLength).ToArray(); // 从缓存里切出完整消息体。
            buffer.RemoveRange(0, fullLength); // 从缓存中删除已经解析完的这一整包。
            onPacket(body); // 把完整消息交给业务层处理。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

项目里要注意

长度字段必须做上限检查,否则别人发一个特别大的长度,可能把内存打爆。

粘包半包处理应该在网络层完成,业务层最好只收到完整消息。

生产环境可以用环形缓冲区、ArrayPool<byte>Span<byte> 优化,避免频繁 ToArray() 产生 GC。

面试高分回答

IMPORTANT

粘包和半包不是 TCP 的 bug,而是 TCP 字节流模型的正常现象。TCP 只保证字节可靠有序,不保证消息边界,所以应用层必须自己设计拆包协议。常见方案是长度头加消息体,接收端维护一个缓存区,每次收到数据先追加到缓存,然后循环判断是否够包头、是否够完整包;够一包就切出来交给业务,不够就等待下一次接收。同时要校验包长上限,防止异常包或攻击。

一句话记忆

TCP 只管字节不断不乱,消息边界要应用层自己切。

RPC 是什么?

核心解释

RPC 是 Remote Procedure Call,远程过程调用。它的意思是:你在客户端写起来像调用一个本地函数,但真正执行逻辑的是远端服务器。

network-rpc-explained

怎么理解

普通本地调用是:

result = Add(1, 2)

函数就在本机执行,马上返回结果。

RPC 看起来也像:

reward = ClaimReward(playerId)

但实际发生的是:

1. 客户端把方法名、参数、requestId 打包
2. 通过网络发给服务器
3. 服务器找到对应方法
4. 服务器执行逻辑
5. 服务器把结果打包返回
6. 客户端根据 requestId 找到对应回调

所以 RPC 的表面是“函数调用”,本质是“一次网络请求”。

底层通常有什么字段

一个 RPC 请求包一般会包含:

requestId:请求编号,用来匹配响应
service:调用哪个服务
method:调用哪个方法
args:参数
token:身份验证信息
timestamp:时间戳,可用于校验和超时

响应包一般包含:

requestId:对应哪个请求
errorCode:错误码
result:返回结果

C# 简化示例

c
public sealed class RpcRequest // 定义一个 RPC 请求类。  
{ // 类开始。  
    public int RequestId; // 请求编号,用来匹配服务器返回。  
    public string Method; // 要调用的远端方法名。  
    public string JsonArgs; // 序列化后的参数内容。  
} // 类结束。  

public sealed class RpcResponse // 定义一个 RPC 响应类。  
{ // 类开始。  
    public int RequestId; // 响应对应的请求编号。  
    public int ErrorCode; // 错误码,0 通常表示成功。  
    public string JsonResult; // 序列化后的返回结果。  
} // 类结束。  

public void SendClaimRewardRpc(int rewardId) // 发送一个领取奖励的 RPC 请求。  
{ // 方法开始。  
    RpcRequest request = new RpcRequest(); // 创建 RPC 请求对象。  
    request.RequestId = 1001; // 设置请求编号,真实项目里通常递增生成。  
    request.Method = "ClaimReward"; // 设置要调用的服务器方法名。  
    request.JsonArgs = "{\"rewardId\":" + rewardId + "}"; // 把参数序列化成字符串示例。  
    SendToServer(request); // 把请求发送给服务器。  
} // 方法结束。

RPC 适合什么场景

适合请求和响应关系明确的功能:

登录
注册
匹配
查询背包
领取奖励
购买商品
提交任务
请求排行榜

这些都很像:

我请求服务器做一件事
服务器处理
服务器给我结果

不适合什么场景

不要把每帧移动、每帧位置同步都做成 RPC。

比如:

MoveRpc(position)
MoveRpc(position)
MoveRpc(position)
MoveRpc(position)

这样会产生大量请求响应,延迟和开销都很高。

高频同步更适合:

状态快照
输入流
帧同步
UDP 快照同步

面试高分回答

TIP

RPC 是远程过程调用,它把一次网络请求封装成类似本地函数调用的形式。客户端调用 RPC 方法时,框架会把方法名、参数和请求 ID 序列化成网络包发送给服务器;服务器收到后根据方法名分发到对应处理函数,执行完后把结果和 requestId 返回给客户端,客户端再匹配对应回调或 Task。RPC 使用起来方便,但本质仍然是网络通信,所以必须考虑超时、重试、错误码、鉴权、幂等和协议兼容,不能把远程调用当成真正无成本的本地调用。

一句话记忆

RPC 就是:写起来像调用本地函数,跑起来其实是在请求远端服务器。

网络消息如何分发到业务模块?

核心解释

网络消息分发,就是把服务器发来的二进制数据,经过“拆包、反序列化、读取消息 ID、查找处理函数”,最后交给对应业务模块处理。

network-message-dispatch-business-modules

整体流程

Socket 收到 byte[]
网络层拆包
反序列化成消息对象
读取 msgId
消息分发器查 handler
切到 Unity 主线程
调用对应业务模块

比如:

1001 -> LoginModule.OnLoginResponse
2001 -> BattleModule.OnDamageNotify
3001 -> BagModule.OnItemChanged
4001 -> ChatModule.OnChatMessage

为什么要分层

网络层只负责网络相关的事:

连接
收包
发包
拆包
粘包半包处理
序列化和反序列化

业务模块只负责自己的逻辑:

登录模块处理登录
背包模块处理物品
战斗模块处理伤害
聊天模块处理聊天

中间的 Dispatcher 就像路由器,根据 msgId 把消息送到正确模块。

C# 简化实现

c
using System; // 引入 Action 和 Console。  
using System.Collections.Generic; // 引入 Dictionary 和 Queue。  
public sealed class NetworkDispatcher // 定义网络消息分发器。  
{ // 类开始。  
    private readonly Dictionary<int, Action<object>> handlers = new Dictionary<int, Action<object>>(); // 保存消息 ID 到处理函数的映射。  
    private readonly Queue<NetMessage> mainThreadQueue = new Queue<NetMessage>(); // 保存等待主线程处理的消息队列。  
    public void Register(int msgId, Action<object> handler) // 注册某个消息 ID 对应的业务处理函数。  
    { // 方法开始。  
        handlers[msgId] = handler; // 把消息 ID 和处理函数放进字典。  
    } // 方法结束。  
    public void Unregister(int msgId) // 取消注册某个消息 ID。  
    { // 方法开始。  
        handlers.Remove(msgId); // 从字典里移除对应处理函数。  
    } // 方法结束。  
    public void OnNetworkThreadMessage(int msgId, object body) // 网络线程收到完整消息后调用。  
    { // 方法开始。  
        lock (mainThreadQueue) // 给队列加锁,避免网络线程和主线程同时访问。  
        { // 加锁代码块开始。  
            mainThreadQueue.Enqueue(new NetMessage(msgId, body)); // 把消息放入主线程队列。  
        } // 加锁代码块结束。  
    } // 方法结束。  
    public void UpdateOnMainThread() // Unity 主线程每帧调用,用来处理网络消息。  
    { // 方法开始。  
        while (true) // 循环处理当前已经收到的消息。  
        { // 循环开始。  
            NetMessage message; // 声明一条即将处理的消息。  
            lock (mainThreadQueue) // 给队列加锁,安全取消息。  
            { // 加锁代码块开始。  
                if (mainThreadQueue.Count == 0) // 如果队列里已经没有消息。  
                { // 判断开始。  
                    return; // 直接返回,等待下一帧继续处理。  
                } // 判断结束。  
                message = mainThreadQueue.Dequeue(); // 从队列头部取出一条消息。  
            } // 加锁代码块结束。  
            if (handlers.TryGetValue(message.MsgId, out Action<object> handler)) // 根据消息 ID 查找处理函数。  
            { // 判断开始。  
                handler(message.Body); // 找到了就把消息体交给业务模块处理。  
            } // 判断结束。  
            else // 如果没有找到对应处理函数。  
            { // 分支开始。  
                Console.WriteLine("Unknown message id: " + message.MsgId); // 打印未知消息日志。  
            } // 分支结束。  
        } // 循环结束。  
    } // 方法结束。  
    private readonly struct NetMessage // 定义内部消息结构。  
    { // 结构体开始。  
        public readonly int MsgId; // 保存消息 ID。  
        public readonly object Body; // 保存反序列化后的消息体。  
        public NetMessage(int msgId, object body) // 构造一条网络消息。  
        { // 构造函数开始。  
            MsgId = msgId; // 设置消息 ID。  
            Body = body; // 设置消息体。  
        } // 构造函数结束。  
    } // 结构体结束。  
} // 类结束。

项目里更好的做法

真实项目里不建议所有消息都用 object 硬转,最好用 Protobuf 或代码生成,把 msgId 和具体消息类型绑定起来。

网络线程不要直接操作 Unity 对象。Unity 的 GameObject、Transform、UI 大多数都必须在主线程处理,所以网络线程收到消息后,通常先放进主线程队列,主线程 Update 再分发。

模块销毁时要取消注册,否则可能出现已经关闭的 UI 还收到消息,导致空引用或内存泄漏。

面试高分回答

CAUTION

网络消息分发一般会分成网络层、协议层、分发层和业务层。网络层负责收发字节流和拆包,协议层负责根据消息 ID 反序列化成具体消息对象,分发层维护一个 msgId -> handler 的映射表,把消息路由到登录、战斗、背包、聊天等业务模块。Unity 项目里还要注意线程切换,网络线程只负责收包和入队,真正修改场景对象和 UI 的逻辑放到主线程执行。同时要处理未知消息、异常捕获、模块注销、协议版本兼容和消息统计。

一句话记忆

网络层负责“收和解”,分发器负责“按 ID 找人”,业务模块负责“处理自己的事”。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0