最近 4 帧都重复发送了,UDP 输入为什么仍然可能丢?

客户端每 1/60 秒产生一条输入。如果每个 UDP 数据报只放当前 tick,tick 101 对应的数据报丢失后,服务端即使收到了 102、103,也永远看不到 101。

Fighting 的客户端因此在每个 InputPacket 中重复携带最近 4 帧:

1
2
3
seq 20: [100,  99,  98,  97]
seq 21: [101, 100, 99, 98] ← 整包丢失
seq 22: [102, 101, 100, 99] ← 仍可补回 101

这显著提高了短时丢包下输入按时到达的概率,却没有把 UDP 变成 TCP。连续多个包都丢失、补发包在服务端已经推进相应 tick 后才到、数据报被截断,或者会话身份不匹配,输入仍然不会进入权威模拟。最终收敛依赖服务端 State 和客户端回滚,而不是冗余本身。

本文以项目当前 v5 二进制协议为版本快照,围绕一次丢包讲清报文边界、输入冗余、seq/tick、ACK、State、Start/Reset、非阻塞收包和 MTU。项目网络封装使用 IPv4、POSIX socket 与 libevent;Windows 不能直接编译当前 UdpSocket.cpp,需要替换平台 API 或增加兼容层。

1. UDP 没有“粘包”,为什么仍需要应用层协议?

UDP 是数据报协议。一次成功的 sendto 对应一个数据报,接收端不会像 TCP 字节流那样把两个应用消息自然拼成一段、再要求应用寻找消息边界。因此它没有 TCP 语境中的粘包/拆包问题。

但“保留边界”不等于“可靠消息”:

  • 数据报可能丢失、重复、乱序或延迟;
  • 数据报大于接收缓冲时可能被截断;
  • 大于路径 MTU 时可能触发 IP 分片,任一分片丢失都会丢掉整个数据报;
  • UDP 不知道 session、match、tick 和命令值域是否合法;
  • socket 收到字节不代表游戏状态可以应用。

所以应用层仍然需要定义 header、版本、类型、长度、身份、序号、数值边界和恢复策略。协议的任务不是重新实现所有 TCP 语义,而是只实现实时游戏真正需要的那部分。

2. 输入冗余解决了什么,又没有解决什么?

当前客户端每 tick 发送一个包,包中按“最新到更旧”的顺序放最近 4 条本地输入。服务端按每条命令自己的 tick 写入环形缓冲,同 tick 重复到达会覆盖同一槽位。

如果每个包以独立概率 p 丢失,一条输入被连续 4 个携带它的数据报全部丢掉的理想概率约为 p⁴。这个直觉能说明冗余为何有效,却不能拿来预测真实网络:丢包往往成簇,包之间并不独立,而且“最终到达”若晚于权威消费 deadline,仍然没有用。

机制 能处理 不能保证
4 帧输入冗余 短时单包/少量连续丢失 任意长度连续丢包、按时到达
tick 覆盖 重复输入幂等、乱序存放 已推进历史自动重算
seq 包进度和 gap 统计 业务输入唯一性、可靠 ACK
Hold Last 服务端短时缺输入继续推进 丢失松键/攻击边沿仍正确
State + replay 客户端预测最终回到权威基线 原始输入一定被服务端执行

特别要注意:当前服务端不会因为迟到输入而回滚权威世界。冗余输入只有在对应 tick 被消费前到达才可能改变该 tick 的决策。

3. 最小可运行版本:丢掉 seq 1,怎样从 seq 2 补回 tick 101?

下面用标准库实现一个缩小版 v5 Input codec。它保留实际协议的 magic、version、type、身份字段、网络字节序和逐字段边界检查,并模拟中间包丢失。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#include <cassert>
#include <bit>
#include <cstdint>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <unordered_map>
#include <utility>
#include <vector>

using Byte = std::uint8_t;
using Tick = std::uint32_t;

constexpr std::uint32_t magic = 0x4c414230U; // "LAB0"
constexpr std::uint16_t version = 5;
constexpr std::uint16_t inputType = 1;

struct InputCmd {
Tick tick = 0;
std::uint16_t buttons = 0;
std::int8_t moveX = 0;
std::int8_t moveY = 0;
};

struct InputPacket {
std::uint8_t playerId = 1;
std::uint64_t sessionId = 0;
std::uint32_t matchId = 0;
std::uint32_t sequence = 0;
Tick newestTick = 0;
Tick clientAckServerTick = 0;
std::vector<InputCmd> commands;
};

class Writer {
public:
void U8(std::uint8_t value) { bytes_.push_back(value); }
void U16(std::uint16_t value) {
U8(static_cast<Byte>(value >> 8));
U8(static_cast<Byte>(value));
}
void U32(std::uint32_t value) {
U16(static_cast<std::uint16_t>(value >> 16));
U16(static_cast<std::uint16_t>(value));
}
void U64(std::uint64_t value) {
U32(static_cast<std::uint32_t>(value >> 32));
U32(static_cast<std::uint32_t>(value));
}
[[nodiscard]] std::vector<Byte> Take() { return std::move(bytes_); }

private:
std::vector<Byte> bytes_;
};

class Reader {
public:
explicit Reader(const std::vector<Byte>& bytes) : bytes_(bytes) {}

std::uint8_t U8() {
if (position_ >= bytes_.size()) throw std::runtime_error("truncated packet");
return bytes_[position_++];
}
std::uint16_t U16() {
const std::uint16_t high = U8();
const std::uint16_t low = U8();
return static_cast<std::uint16_t>((high << 8) | low);
}
std::uint32_t U32() {
const std::uint32_t high = U16();
const std::uint32_t low = U16();
return (high << 16) | low;
}
std::uint64_t U64() {
const std::uint64_t high = U32();
const std::uint64_t low = U32();
return (high << 32) | low;
}
[[nodiscard]] bool Done() const { return position_ == bytes_.size(); }

private:
const std::vector<Byte>& bytes_;
std::size_t position_ = 0;
};

std::vector<Byte> Encode(const InputPacket& packet) {
if (packet.commands.size() > 8) {
throw std::invalid_argument("too many commands");
}

Writer out;
out.U32(magic);
out.U16(version);
out.U16(inputType);
out.U8(packet.playerId);
out.U8(static_cast<std::uint8_t>(packet.commands.size()));
out.U16(0);
out.U64(packet.sessionId);
out.U32(packet.matchId);
out.U32(packet.sequence);
out.U32(packet.newestTick);
out.U32(packet.clientAckServerTick);
for (const InputCmd& command : packet.commands) {
out.U32(command.tick);
out.U16(command.buttons);
out.U8(std::bit_cast<std::uint8_t>(command.moveX));
out.U8(std::bit_cast<std::uint8_t>(command.moveY));
}
return out.Take();
}

std::optional<InputPacket> Decode(const std::vector<Byte>& bytes) {
try {
Reader in(bytes);
if (in.U32() != magic || in.U16() != version || in.U16() != inputType) {
return std::nullopt;
}

InputPacket packet;
packet.playerId = in.U8();
const std::uint8_t count = in.U8();
if (count > 8) return std::nullopt;
(void)in.U16();
packet.sessionId = in.U64();
packet.matchId = in.U32();
packet.sequence = in.U32();
packet.newestTick = in.U32();
packet.clientAckServerTick = in.U32();

for (std::uint8_t i = 0; i < count; ++i) {
InputCmd command;
command.tick = in.U32();
command.buttons = in.U16();
command.moveX = std::bit_cast<std::int8_t>(in.U8());
command.moveY = std::bit_cast<std::int8_t>(in.U8());
packet.commands.push_back(command);
}
if (!in.Done()) return std::nullopt;
return packet;
} catch (const std::runtime_error&) {
return std::nullopt;
}
}

InputPacket BuildPacket(
Tick newest,
std::uint32_t sequence,
const std::unordered_map<Tick, InputCmd>& history
) {
InputPacket packet;
packet.playerId = 1;
packet.sessionId = 42;
packet.matchId = 7;
packet.sequence = sequence;
packet.newestTick = newest;

for (std::uint32_t age = 0; age < 4 && age <= newest - 100; ++age) {
packet.commands.push_back(history.at(newest - age));
}
return packet;
}

int main() {
const std::unordered_map<Tick, InputCmd> history{
{100, {100, 0, 1, 0}},
{101, {101, 0, -1, 0}},
{102, {102, 0, 1, 0}},
};

const auto seq0 = Encode(BuildPacket(100, 0, history));
const auto seq1 = Encode(BuildPacket(101, 1, history));
const auto seq2 = Encode(BuildPacket(102, 2, history));

std::unordered_map<Tick, InputCmd> serverInputs;
for (const auto* datagram : {&seq0, &seq2}) { // seq1 被网络丢弃
const auto packet = Decode(*datagram);
assert(packet);
for (const InputCmd& command : packet->commands) {
serverInputs.insert_or_assign(command.tick, command);
}
}

auto truncated = seq2;
truncated.pop_back();
const bool truncatedRejected = !Decode(truncated).has_value();

std::cout << "dropped_sequence=1\n"
<< "recovered_tick=101 moveX="
<< static_cast<int>(serverInputs.at(101).moveX) << '\n'
<< std::boolalpha
<< "truncated_packet_rejected=" << truncatedRejected << '\n';

assert(serverInputs.contains(101));
assert(serverInputs.at(101).moveX == -1);
assert(truncatedRejected);
(void)seq1;
}

在 macOS 或 Linux 上使用 Clang 编译:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic \
udp_redundancy.cpp -o udp_redundancy
./udp_redundancy

预期输出:

1
2
3
dropped_sequence=1
recovered_tick=101 moveX=-1
truncated_packet_rejected=true

tick 101 不是由 seq 1 恢复,而是由后来的 seq 2 冗余携带。服务端按 tick 存命令,所以同一命令在 seq 0/1/2 中重复出现不会产生三次移动;真正执行发生在权威服务器消费 tick 101 时。

4. 当前 v5 InputPacket 在网络上怎样排列?

当前输入数据报使用明确字段编码,而不是发送 C++ 结构体内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PacketHeader
magic u32 0x4C414230("LAB0")
version u16 5
type u16 Input = 1

Input body
playerId u8
count u8
reserved u16
sessionId u64
matchId u32
seq u32
newestTick u32
clientAckTick u32
commands[count]

InputCmd
tick u32
buttons u16
moveX i8
moveY i8

固定部分为 36 字节,每条命令 8 字节,所以当前 4 帧冗余包约 68 字节,不含 IP/UDP 链路开销。

多字节整数使用 network byte order(网络字节序,大端):源码通过 htonl/htons 写入,ntohl/ntohs 读取;64 位整数拆成高、低两个 u32。这样协议不依赖发送机器的端序和结构体 padding。

编码器当前根据 p.cmds.size() 写 count,而不是信任调用者手填的 p.count;服务端业务层再要求解码后的 count 与 vector 大小一致且不超过 8。客户端实际发送 4 条,编码器本身最多可写 255 条。这些上限分布在不同层,扩展协议时应统一为一个明确常量,避免“codec 接受、业务拒绝”的不必要流量。

5. 解码成功为什么仍然不能直接写入游戏状态?

DecodeInput 会逐字段检查剩余字节,并要求最后 p == end。因此:

  • 缺少任一字段会失败;
  • header type 不符会失败;
  • magic/version 不符会失败;
  • 声明 count 与实际字节不匹配会失败;
  • 合法消息后追加多余字节也会失败。

但 codec 只回答“这些字节能否解释成 v5 InputPacket”,业务层还要验证:

1
2
3
4
5
6
7
sessionId / matchId / playerId 是否属于当前连接
tick 是否在 [-8, +12] 窗口
count 是否为 1..8
newestTick 是否等于 commands 中最新 tick
moveX/moveY 是否为 -1/0/1
buttons 是否只包含合法位
连接是否超过 240 包/秒

这两层不能合并成一句“协议已经校验”。Codec 防止越界和格式歧义,AuthoritativeServer 防止格式合法但业务无效的数据进入模拟。

当前 DecodeState 会按包内 playerCount/projectileCount 直接 reserve 并逐项读取,字节边界最终仍能拒绝超出数据报的声明;更早根据 len 推导最大可容纳条目,可以减少异常 count 下不必要的分配尝试。

6. seq 和 tick 为什么不能互相替代?

seq 描述数据报发送顺序,tick 描述模拟命令归属

1
seq 22 里可以同时包含 tick 102、101、100、99

当前服务端在写入 InputBuffer 后才更新 seq 统计。advance > 1 被计作 gap,advance <= 0 不推进最新 seq;但重复或旧 seq 中的合法命令仍可能按 tick 覆盖缓冲。这是合理的,因为乱序旧包可能携带一个服务端尚未拥有的 tick。

因此:

  • 不能看到旧 seq 就无条件丢掉整个包;
  • 不能只用 seq 决定某条输入是否已经执行;
  • 不能把 seq gap 直接当成精确网络丢包率,迟到包不会回减累计 gap;
  • tick 覆盖提供存储幂等,但已经推进的 tick 不会再次执行。

序号都是有限宽度整数,长期运行会回绕。项目对 tick/seq 的先后判断使用有符号差值,并假设有效窗口远小于半个序号空间;新增普通 > 比较可能在回绕处失效。

7. ACK 与 State 分别补上哪一块信息?

两者都由服务端发送,却不能互换:

包含的核心信息 客户端用途
ACK 已处理 tick、收到的最大输入 tick、state hash、最新 seq、收包/gap 统计 进度、RTT/loss/lead 观测
State 玩家和弹道权威状态、tick、maze seed、state hash 校验、restore/replay

ACK 没有完整世界,hash 不一致时无法单独恢复。State 能让客户端回到权威基线,却不详细表达输入包接收进度。

当前 clientAckServerTick 虽然已经编码进 InputPacket,但字段注释明确写着“预留给后续回滚/状态”。服务端当前没有使用它做 State 可靠确认、增量基线选择或历史清理。字段存在不等于机制已经实现。

同样,当前 ACK 不会直接驱动 localHist 清理;客户端使用固定容量环形历史覆盖旧数据。把“未来可以根据 ACK 回收”写成当前功能,会误导对内存和恢复窗口的判断。

8. Start 和 Reset 也是 UDP,丢了怎么办?

控制消息不因重要就自动可靠。当前协议采用可重试状态机:

8.1 Start 丢失

未开局客户端每 250 ms 继续发送空 Input 作为 hello。服务端已经 started 时再次收到该连接的 hello,会重发包含 session、match、player、startTick 和完整迷宫的 Start。客户端识别同一 match 的重复 Start,避免反复初始化。

8.2 Reset 丢失

玩家超时后服务端重置比赛并向剩余客户端发 Reset。如果该包丢失,客户端仍会发送旧 match 输入;未 started 服务端会再次返回 Reset,直到客户端回到等待态。

这不是通用可靠消息队列,而是针对两个关键状态转换设计的幂等重试。新增 Ready、RoundEnd、资源加载等控制消息时,也要逐个回答:丢失、重复、乱序和跨 match 到达会发生什么。

9. State 为什么不能无限塞进一个 UDP 数据报?

当前 State 大小可以近似为:

1
40 + 27 × playerCount + 18 × projectileCount 字节

两名玩家且没有弹道时约 94 字节;弹道越多,包线性增长。Start 固定部分约 38 字节,再加完整迷宫网格。

EncodeStart 允许最多 4096 个 maze 单元,理论包大小可超过 4 KiB;State 的 projectileCount 是 u8,理论上也能超过 2 KiB。但当前 UdpSocket::RecvFrom 和 libevent 读取路径都只提供 2048 字节栈缓冲,并没有用 MSG_TRUNC 显式识别超长数据报。当前 15×15 迷宫(225 单元)和游戏弹道上限通常远小于该值,所以正常 Demo 不触发;扩展地图或实体数前必须统一 codec、业务和 socket 的最大数据报约束。

即使接收缓冲能放下,也不建议接近 UDP 理论上限。数据报超过路径 MTU 会发生 IP 分片,任何一片丢失都会让整包失效。公网实时协议常把单包控制在大约 1200 字节附近以减少分片风险,但这不是对所有网络都成立的硬标准,应结合 IPv4/IPv6、隧道和目标网络测量。

当状态超过预算时,可以选择:限制弹道/实体数量、分区/裁剪状态、设计带身份和超时的应用层分片,或提高 State 频率但发送增量。每种方案都会引入新的丢失和基线问题,不能只把 uint8_t count 改大。

10. 非阻塞 UDP 为什么必须一直读到 EAGAIN?

libevent 通知“socket 可读”时,内核接收队列里可能已经有多个数据报。当前 HandleReadable 循环调用 recvfrom,直到返回 EAGAIN/EWOULDBLOCK

1
2
3
4
5
readable callback
↓ recv datagram 1
↓ recv datagram 2
↓ recv datagram 3
↓ EAGAIN,退出

如果每次回调只读一个,高输入速率下队列会持续堆积,增加延迟甚至触发内核丢包。反过来,无限处理大量包也可能饿死 tick 定时器,因此服务端还需要单连接限速、全局预算和事件循环延迟观测。

UDP 的 sendto 要么发送整个数据报,要么失败,不像 TCP stream 需要循环处理部分写;当前封装以 n == len 判断成功。非阻塞发送仍可能因缓冲区满而失败,项目当前只记录失败,没有发送队列或重试。对 Input 这种下一包会冗余携带的数据可以接受,对关键控制消息则依靠状态机重发。

11. 会话字段能否防止伪造和重放?

sessionId + matchId + playerId 能有效隔离大多数旧局和错误玩家包,随机 64 位 session 也比只认 IP:port 更难猜。但它们不是消息认证码(MAC):协议没有证明数据报确实由持有密钥的客户端发送,也没有加密内容。

当前服务端甚至会在 gameplay 完整验证前,根据已知 session 更新客户端回复地址和活跃时间。这便于 NAT 地址变化,但若 session 泄露,可能造成地址劫持或保活滥用。公网部署需要结合威胁模型增加认证标签、握手密钥、防重放窗口和更完整限流;不要把“有 sessionId”写成“已经安全”。

12. 怎样测试协议,而不是只测试 Encode 后立刻 Decode?

round-trip 测试只能证明自己的编码器与解码器彼此同意,两个实现也可能共同犯同一个错误。至少还需要:

  • 固定 golden bytes,验证端序和字段布局不会意外变化;
  • 对每个字节位置做截断测试,确保都安全拒绝;
  • 修改 magic、version、type、count 和尾部多余字节;
  • 测试 i8/i16/i32 负值和整数边界;
  • 重复、乱序、seq 回绕和 tick 回绕;
  • 超出业务窗口、非法按钮、错误身份和超速发包;
  • Start/Reset 丢失重试;
  • 接近 MTU、超过 2048 缓冲和显式截断检测;
  • 真实 UDP loopback 下最终 State hash。

当前 lab_network_integration 已覆盖真实 socket、Start 丢失恢复、非法超前/高范围输入、权威 State hash、玩家超时 Reset 丢失恢复和替补玩家新 match。它没有覆盖持续随机丢包、乱序、网络抖动和多进程公网环境,这些仍需专门网络仿真或代理。

项目依赖准备完成后,可以跳过较长性能套件运行短测试:

1
2
3
4
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure \
-E lab_performance_standard

网络性能结论需要固定包大小、发送频率、网络条件和构建模式,并重复测量 p50/p95/p99。一次 loopback 成功不能证明公网抗丢包能力。

13. 常见误区

13.1 误区:UDP 没有粘包,所以不用长度检查

数据报边界保留,但包内仍有可变数组和 count;接收缓冲还可能截断。每次读取前都必须检查剩余字节,并拒绝尾部多余数据。

13.2 误区:重复 4 帧等于输入可靠送达

它只提供有限时间冗余。连续丢包或晚到仍会错过权威消费,最终由 State 校正客户端,而不是补写服务端历史。

13.3 误区:旧 seq 应直接丢掉整包

旧包可能携带一个尚未收到的有效 tick。应先定义 seq 用途;当前项目用它估计包 gap,输入幂等由 tick 缓冲承担。

13.4 误区:ACK 表示客户端可以删除所有旧输入

当前 ACK 表达服务端进度,但客户端尚未据此清理历史,clientAckServerTick 也未形成可靠状态确认协议。删除恢复窗口前必须证明不会再收到需要这些输入的 State。

13.5 误区:增大 socket buffer 就不会丢包

更大内核缓冲只能吸收部分突发,不能解决链路丢包、事件循环处理不足、应用数据报截断或 IP 分片。

14. 什么时候适合用 UDP 加定制同步?

它适合时效高于逐条可靠性的实时状态:新输入很快会覆盖旧输入,应用能够预测、容忍短时缺失,并通过权威快照恢复。动作游戏输入、实时位置和语音媒体常符合这个特点。

如果每条消息都必须最终送达且严格有序,例如支付、库存提交或聊天历史,直接使用成熟可靠传输通常更合适。也可以考虑 QUIC 等在 UDP 之上提供安全与多路可靠性的协议,但其流/数据报语义、拥塞控制和库版本需要结合实际需求评估,不能因为底层是 UDP 就自动获得本项目的 tick 同步模型。

15. 总结:冗余争取的是 deadline,不是永不丢失

最近 4 帧重复发送的真正价值,是让输入在服务端消费 deadline 前拥有多次抵达机会。完整链路仍需要多个互补机制:

  1. magic/version/type、网络字节序和严格长度检查定义可解析的数据报。
  2. session/match/player 隔离对局,业务层继续校验 tick、值域和速率。
  3. tick 让冗余命令幂等归位,seq 主要观测数据报进度与 gap。
  4. ACK 报告进度和网络统计,State 提供客户端权威 restore/replay 基线。
  5. Start/Reset 用幂等状态机重试,包大小和非阻塞读取守住传输边界。

最直接的实践建议是为每类数据报写一张“丢失、重复、乱序、截断、超时”处理表。如果某个格子只能回答“应该不会发生”,协议还没有完成。

16. 当前源码阅读入口

以下路径相对于 Fighting 项目根目录:

  • include/lab/net/Packets.h:v5 包结构与身份字段;
  • include/lab/net/NetCodec.hsrc/net/NetCode.cpp:逐字段二进制编解码;
  • include/lab/net/UdpSocket.hsrc/net/UdpSocket.cpp:IPv4 非阻塞 socket 与 libevent;
  • apps/client_main.cpp:4 帧冗余、ACK/State 接收与控制消息重试;
  • src/server/AuthoritativeServer.cpp:业务校验、按 tick 缓存、seq 统计和发包;
  • tests/core_tests.cpp:codec round-trip 与字段边界;
  • tests/network_integration_tests.cpp:真实 UDP loopback 生命周期测试。