UDP 协议如何对抗丢包
下面是整理后的笔记版本,我顺便修正了几个表述问题:UDP 没有 TCP 的字节流粘包问题,但 UDP 仍然可能丢包、乱序、重复、延迟;所以需要在应用层做冗余、ACK、状态同步和回滚。这也对应你代码里的 InputPacket 冗余输入发送、AckPacket 服务器处理进度确认、StatePacket 权威状态回滚逻辑。
UDP 协议如何对抗丢包
1. UDP 的特点
UDP 是无连接、无可靠性保证的传输协议。
相比 TCP:
- UDP 保留报文边界,不存在 TCP 那种字节流“粘包/拆包”问题。
- UDP 不保证可靠送达,可能发生丢包。
- UDP 不保证顺序,可能发生乱序。
- UDP 不保证只到达一次,可能出现重复包。
- UDP 延迟低,适合游戏输入同步、实时音视频、状态广播等场景。
因此,在实时游戏同步中,如果使用 UDP,需要在应用层自行设计可靠性机制。
2. 输入包冗余发送:对抗丢包
2.1 为什么要多帧输入打包发送
客户端每个 tick 都会产生一个本地输入,例如:
1 | InputCmd { |
如果每个 UDP 包只发送当前 tick 的输入,那么某个 UDP 包一旦丢失,服务器就会缺少该 tick 的输入。
例如:
1 | tick 100 输入包丢失 |
如果没有冗余,服务器永远拿不到 tick 100 的输入。
所以客户端不只发送当前 tick,而是把最近若干帧输入一起打包发送:
1 | 当前 tick = 105 |
这样即使 tick 103 对应的 UDP 包丢了,后续 tick 104、105 的包里仍然可能携带 tick 103 的输入。
2.2 输入包结构
1 | struct InputPacket { |
字段含义:
| 字段 | 作用 |
|---|---|
playerId |
客户端玩家槽位 |
count |
当前包里包含多少条输入命令 |
seq |
输入包序号,用于检测丢包、乱序、重复 |
newestTick |
当前输入包里最新的 tick |
clientAckServerTick |
客户端已经收到并确认的服务器权威 tick |
cmds |
最近若干帧输入命令,做冗余发送 |
3. 自定义 UDP 协议格式
输入包的二进制协议可以设计为:
1 | PacketHeader |
其中每个 InputCmd 可以编码为:
1 | tick u32 |
对应编码逻辑:
1 | std::vector<uint8_t> EncodeInput(const InputPacket& p) { |
这样做的好处是:
- 包体小。
- 解析速度快。
- 不依赖文本协议。
- 每个输入都带
tick,服务器可以按 tick 存储和处理。 - 即使 UDP 乱序,服务器也能根据
tick和seq判断包的新旧。
4. 服务端如何确认客户端最新输入
服务器收到客户端输入包后,不应该只看“这个包有没有到”,而应该看:
1 | 服务器已经处理到了哪个 tick |
服务器可以维护:
1 | serverTickProcessed |
含义:
| 字段 | 含义 |
|---|---|
serverTickProcessed |
服务器模拟已经推进到的 tick |
serverLastInputTick |
服务器收到该客户端的最新输入 tick |
serverStateHash |
服务器在该 tick 的权威状态 hash |
服务器再通过 AckPacket 发回客户端:
1 | server -> client ACK |
客户端收到 ACK 后更新:
1 | ctx->lastServerTick = ack->serverTickProcessed; |
这表示:
1 | 服务器已经处理到了 serverTickProcessed |
例如:
1 | 客户端本地 tick = 120 |
说明客户端当前预测领先服务器 10 个 tick。
5. ACK 和 State 分别解决什么问题
这里需要修正你的原笔记:
ACK 不是“确认权威状态”的主要机制。
ACK 主要确认服务器处理进度。
State 才携带服务器权威状态,用于客户端回滚与校正。
5.1 ACK 的作用
ACK 主要解决:
1 | 服务器处理进度确认 |
也就是告诉客户端:
1 | 我服务器已经处理到哪个 tick 了 |
ACK 的主要用途:
- 判断服务器处理进度。
- 判断客户端本地预测领先服务器多少 tick。
- 检测包丢失、乱序、延迟。
- 帮助客户端或服务器清理旧输入缓存。
- 辅助 hash 对账。
ACK 更像是:
1 | 进度确认包 |
不是完整状态同步包。
5.2 State 的作用
StatePacket 携带服务器权威状态。
它主要解决:
1 | 客户端预测错误后的校正问题 |
服务器发来的 State 包中通常包含:
1 | server tick |
客户端收到 State 后:
- 构造服务器权威快照。
- 将本地世界恢复到服务器状态。
- 从该 tick 之后重放本地输入。
- 得到新的本地预测世界。
- 渲染校正后的结果。
核心过程:
1 | 客户端当前 tick = 120 |
所以:
1 | State = 权威状态同步 + 回滚校正 |
6. 客户端如何处理服务器权威状态
客户端收到服务器 StatePacket 后,不是直接把画面设置成服务器状态,而是执行:
1 | 服务器权威状态 |
伪代码:
1 | Restore(serverAuthoritativeSnapshot); |
这样可以避免玩家操作延迟。
如果直接显示服务器状态,玩家会感觉输入延迟很大。
使用客户端预测后:
1 | 玩家本地输入立即生效 |
7. Hold Last Input:远端输入缺失时的预测策略
对于本地玩家,输入每个 tick 都能从键盘采样。
但是对于远端玩家,客户端并不知道对方真实输入,只能根据服务器状态预测。
如果某个 tick 缺少远端输入,可以短时间沿用上一帧远端输入:
1 | if (hasLast) { |
这就是:
1 | Hold Last Input |
例如对手上一帧向右移动:
1 | tick 100: moveX = 1 |
如果 tick 101、102 暂时没有新的远端输入,就假设:
1 | tick 101: moveX = 1 |
这样远端玩家在本地画面中会更平滑,不会频繁停顿。
但是不能无限 Hold,否则对手早已停下,本地还会预测他继续移动。
因此需要设置最大 Hold tick 数:
1 | constexpr Tick kHoldTicks = 6; |
超过 6 tick 后,返回默认输入:
1 | return InputBuffer::DefaultForTick(t); |
8. UDP 丢包对抗机制总结
| 问题 | 解决方案 |
|---|---|
| UDP 丢包 | 输入冗余发送,一包携带多帧输入 |
| UDP 乱序 | 每个包带 seq,每个输入带 tick |
| UDP 重复 | 服务器按 seq 或 tick 去重 |
| 客户端输入包丢失 | 后续包重复携带旧输入 |
| 客户端不知道服务器处理到哪里 | 服务器发送 ACK |
| 客户端预测和服务器不一致 | 服务器发送 State,客户端回滚重放 |
| 远端玩家输入缺失 | Hold Last Input 短时间预测 |
| 状态同步是否一致 | 使用 stateHash 做对账 |
| 旧状态包晚到 | 丢弃 tick <= lastAuthoritativeTick 的 State |
9. 最终整理版核心结论
UDP 本身不保证可靠传输,所以在游戏同步中需要应用层可靠性设计。
本项目采用的机制是:
1 | 1. 客户端每 tick 采样本地输入 |
可以概括为:
1 | 输入冗余解决丢包; |