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
2
3
4
5
6
InputCmd {
tick;
buttons;
moveX;
moveY;
}

如果每个 UDP 包只发送当前 tick 的输入,那么某个 UDP 包一旦丢失,服务器就会缺少该 tick 的输入。

例如:

1
2
3
tick 100 输入包丢失
tick 101 输入包到达
tick 102 输入包到达

如果没有冗余,服务器永远拿不到 tick 100 的输入。

所以客户端不只发送当前 tick,而是把最近若干帧输入一起打包发送:

1
2
3
4
5
6
7
8
当前 tick = 105

发送:
105
104
103
102
101

这样即使 tick 103 对应的 UDP 包丢了,后续 tick 104、105 的包里仍然可能携带 tick 103 的输入。


2.2 输入包结构

1
2
3
4
5
6
7
8
9
10
11
struct InputPacket {
uint8_t playerId = 1;
uint8_t count = 0; // 本包携带的 InputCmd 数量
uint16_t reserved = 0;

uint32_t seq = 0; // 输入包序号,用于检测丢包、乱序、重复
Tick newestTick = 0; // 本包中最新的输入 tick
Tick clientAckServerTick = 0; // 客户端已经确认收到的服务器 tick

std::vector<InputCmd> cmds; // 冗余输入列表,每个 cmd.tick 必须有效
};

字段含义:

字段 作用
playerId 客户端玩家槽位
count 当前包里包含多少条输入命令
seq 输入包序号,用于检测丢包、乱序、重复
newestTick 当前输入包里最新的 tick
clientAckServerTick 客户端已经收到并确认的服务器权威 tick
cmds 最近若干帧输入命令,做冗余发送

3. 自定义 UDP 协议格式

输入包的二进制协议可以设计为:

1
2
3
4
5
6
7
8
9
10
11
PacketHeader
playerId
count
reserved
seq
newestTick
clientAckServerTick
cmd[0]
cmd[1]
cmd[2]
...

其中每个 InputCmd 可以编码为:

1
2
3
4
tick       u32
buttons u16
moveX i8
moveY i8

对应编码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
std::vector<uint8_t> EncodeInput(const InputPacket& p) {
std::vector<uint8_t> b;
b.reserve(64);

WriteHeader(b, PacketType::Input);

const uint8_t count =
static_cast<uint8_t>(std::min<size_t>(p.cmds.size(), 255));

WriteU8(b, p.playerId);
WriteU8(b, count);
WriteU16(b, 0);

WriteU32(b, p.seq);
WriteU32(b, p.newestTick);
WriteU32(b, p.clientAckServerTick);

// 每个 cmd:tick(u32) buttons(u16) moveX(i8) moveY(i8)
for (uint8_t i = 0; i < count; ++i) {
const auto& c = p.cmds[i];
WriteU32(b, c.tick);
WriteU16(b, c.buttons);
WriteI8(b, c.moveX);
WriteI8(b, c.moveY);
}

return b;
}

这样做的好处是:

  1. 包体小。
  2. 解析速度快。
  3. 不依赖文本协议。
  4. 每个输入都带 tick,服务器可以按 tick 存储和处理。
  5. 即使 UDP 乱序,服务器也能根据 tickseq 判断包的新旧。

4. 服务端如何确认客户端最新输入

服务器收到客户端输入包后,不应该只看“这个包有没有到”,而应该看:

1
服务器已经处理到了哪个 tick

服务器可以维护:

1
2
serverTickProcessed
serverLastInputTick

含义:

字段 含义
serverTickProcessed 服务器模拟已经推进到的 tick
serverLastInputTick 服务器收到该客户端的最新输入 tick
serverStateHash 服务器在该 tick 的权威状态 hash

服务器再通过 AckPacket 发回客户端:

1
server -> client ACK

客户端收到 ACK 后更新:

1
2
ctx->lastServerTick = ack->serverTickProcessed;
ctx->lastServerHash = ack->serverStateHash;

这表示:

1
2
服务器已经处理到了 serverTickProcessed
客户端可以知道自己本地预测领先服务器多少 tick

例如:

1
2
3
4
客户端本地 tick = 120
服务器 ACK tick = 110

lead = 120 - 110 = 10

说明客户端当前预测领先服务器 10 个 tick。


5. ACK 和 State 分别解决什么问题

这里需要修正你的原笔记:

ACK 不是“确认权威状态”的主要机制。
ACK 主要确认服务器处理进度。
State 才携带服务器权威状态,用于客户端回滚与校正。


5.1 ACK 的作用

ACK 主要解决:

1
服务器处理进度确认

也就是告诉客户端:

1
2
3
我服务器已经处理到哪个 tick 了
我服务器当前状态 hash 是多少
我收到你的输入到哪个 tick 了

ACK 的主要用途:

  1. 判断服务器处理进度。
  2. 判断客户端本地预测领先服务器多少 tick。
  3. 检测包丢失、乱序、延迟。
  4. 帮助客户端或服务器清理旧输入缓存。
  5. 辅助 hash 对账。

ACK 更像是:

1
进度确认包

不是完整状态同步包。


5.2 State 的作用

StatePacket 携带服务器权威状态。

它主要解决:

1
客户端预测错误后的校正问题

服务器发来的 State 包中通常包含:

1
2
3
4
5
server tick
player states
projectile states
maze seed
state hash

客户端收到 State 后:

  1. 构造服务器权威快照。
  2. 将本地世界恢复到服务器状态。
  3. 从该 tick 之后重放本地输入。
  4. 得到新的本地预测世界。
  5. 渲染校正后的结果。

核心过程:

1
2
3
4
5
6
7
客户端当前 tick = 120
服务器 State tick = 110

客户端执行:
restore(server_state_at_110)
replay local input 111 ~ 119
得到新的 predicted state at 120

所以:

1
State = 权威状态同步 + 回滚校正

6. 客户端如何处理服务器权威状态

客户端收到服务器 StatePacket 后,不是直接把画面设置成服务器状态,而是执行:

1
2
3
4
5
6
7
服务器权威状态

恢复 worldPred

重放本地未确认输入

得到当前预测状态

伪代码:

1
2
3
4
5
6
7
8
9
Restore(serverAuthoritativeSnapshot);

for (Tick t = serverTick + 1; t < localTick; ++t) {
InputCmd localCmd = localHist.Get(t);
InputCmd remoteCmd = PredictOrHoldRemoteInput(t);

world.Step(localCmd, remoteCmd, dt);
stateHist.Put(world.Snapshot());
}

这样可以避免玩家操作延迟。

如果直接显示服务器状态,玩家会感觉输入延迟很大。

使用客户端预测后:

1
2
玩家本地输入立即生效
服务器状态回来后再纠正

7. Hold Last Input:远端输入缺失时的预测策略

对于本地玩家,输入每个 tick 都能从键盘采样。

但是对于远端玩家,客户端并不知道对方真实输入,只能根据服务器状态预测。

如果某个 tick 缺少远端输入,可以短时间沿用上一帧远端输入:

1
2
3
4
5
if (hasLast) {
InputCmd c = last;
c.tick = currentTick;
return c;
}

这就是:

1
Hold Last Input

例如对手上一帧向右移动:

1
tick 100: moveX = 1

如果 tick 101、102 暂时没有新的远端输入,就假设:

1
2
tick 101: moveX = 1
tick 102: moveX = 1

这样远端玩家在本地画面中会更平滑,不会频繁停顿。

但是不能无限 Hold,否则对手早已停下,本地还会预测他继续移动。

因此需要设置最大 Hold tick 数:

1
constexpr Tick kHoldTicks = 6;

超过 6 tick 后,返回默认输入:

1
return InputBuffer::DefaultForTick(t);

8. UDP 丢包对抗机制总结

问题 解决方案
UDP 丢包 输入冗余发送,一包携带多帧输入
UDP 乱序 每个包带 seq,每个输入带 tick
UDP 重复 服务器按 seqtick 去重
客户端输入包丢失 后续包重复携带旧输入
客户端不知道服务器处理到哪里 服务器发送 ACK
客户端预测和服务器不一致 服务器发送 State,客户端回滚重放
远端玩家输入缺失 Hold Last Input 短时间预测
状态同步是否一致 使用 stateHash 做对账
旧状态包晚到 丢弃 tick <= lastAuthoritativeTick 的 State

9. 最终整理版核心结论

UDP 本身不保证可靠传输,所以在游戏同步中需要应用层可靠性设计。

本项目采用的机制是:

1
2
3
4
5
6
7
8
9
1. 客户端每 tick 采样本地输入
2. 输入包中冗余携带最近 N 帧输入
3. 服务器根据 tick 接收并处理输入
4. 服务器通过 ACK 告诉客户端处理进度
5. 服务器通过 State 发送权威状态
6. 客户端收到 State 后回滚到服务器状态
7. 客户端重放本地未确认输入
8. 远端玩家缺少输入时短时间 Hold Last Input
9. 使用 stateHash 检查客户端还原状态是否和服务器一致

可以概括为:

1
2
3
4
5
6
输入冗余解决丢包;
seq/tick 解决乱序与重复;
ACK 解决服务器进度确认;
State 解决权威状态校正;
Rollback Replay 解决客户端预测误差;
Hold Last Input 解决远端输入短暂缺失导致的抖动。