Appearance
网络协议
游戏为什么常用 UDP?
核心解释
游戏常用 UDP,不是因为 UDP 比 TCP “高级”,而是因为实时游戏更在意低延迟和最新状态。移动、朝向、技能释放、服务器快照这些数据,如果旧包丢了,很多时候直接用下一帧的新包就行,没必要为了补旧包把整个画面卡住。
为什么不用 TCP 做实时战斗
TCP 的特点是:
可靠
有序
自动重传
保证数据完整这听起来很好,但实时游戏里有个问题:队头阻塞。
比如服务器连续发:
位置快照 100
位置快照 101
位置快照 102
位置快照 103
位置快照 104如果 102 丢了,TCP 会先想办法把 102 补回来。即使 103、104 已经到了,应用层也可能要等 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 确认、超时重传、去重、排序。
基本流程
发送端做这些事:
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 = 18、seq = 19、seq = 20。它本身不是业务数据,而是网络层用来判断这个包“新不新、有没有丢、是不是重复、要不要重传”的依据。
为什么需要包序号
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 后,就可以把这个包从等待重传列表里删掉。
为什么需要 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 就是网络包的“回执”:收到回执就放心删缓存,没收到回执就准备重传。
心跳和超时如何设计?
核心解释
心跳就是定期发一个很小的包,确认对方还在线。超时就是长时间收不到对方任何有效消息,就认为连接异常,进入弱网提示、重连或断开流程。
为什么需要心跳
网络断开不一定会立刻通知程序。
比如玩家切后台、断 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。游戏网络里更常见的思路是:先少发,再小发,最后才压缩。也就是先减少消息内容,再用更紧凑的二进制格式表达,只有比较大的包才考虑通用压缩算法。
第一步:少发
最好的压缩,是不要发不必要的数据。
比如状态同步里,不要每帧把所有玩家、所有怪物、所有字段都发出去。
可以做:
只发变化过的字段
只发玩家附近的对象
只发客户端关心的对象
降低不重要对象的同步频率这叫差量同步和兴趣管理。
第二步:小发
不要用特别冗长的文本格式传实时消息。
比如 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 和包头成本可能超过收益,所以要根据消息大小、发送频率、带宽和延迟要求来决定。
一句话记忆
消息压缩的顺序是:先少发,再小发,大包才真正压缩。
协议版本如何兼容?
核心解释
协议版本兼容就是:客户端和服务器版本不完全一致时,仍然能安全通信。核心原则是连接时先检查版本,消息字段尽量“只新增,不破坏旧字段”,老客户端遇到新字段能跳过,新客户端读旧消息时能用默认值补齐。
为什么需要兼容
游戏上线后,客户端不会同时更新。
可能出现:
玩家 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老客户端不认识 title 和 skinId,跳过就行。 新客户端读老数据时没有 title 和 skinId,就用默认值。
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 次;可能一次读到两条,也可能一条消息分两次读到。
什么是粘包
发送端连续发送:
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,远程过程调用。它的意思是:你在客户端写起来像调用一个本地函数,但真正执行逻辑的是远端服务器。
怎么理解
普通本地调用是:
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、查找处理函数”,最后交给对应业务模块处理。
整体流程
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 找人”,业务模块负责“处理自己的事”。