最近 4 帧都重复发送了,UDP 输入为什么仍然可能丢?
客户端每 1/60 秒产生一条输入。如果每个 UDP 数据报只放当前 tick,tick 101 对应的数据报丢失后,服务端即使收到了 102、103,也永远看不到 101。
Fighting 的客户端因此在每个 InputPacket 中重复携带最近 4 帧:
1 | seq 20: [100, 99, 98, 97] |
这显著提高了短时丢包下输入按时到达的概率,却没有把 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 |
|
在 macOS 或 Linux 上使用 Clang 编译:
1 | clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic \ |
预期输出:
1 | dropped_sequence=1 |
tick 101 不是由 seq 1 恢复,而是由后来的 seq 2 冗余携带。服务端按 tick 存命令,所以同一命令在 seq 0/1/2 中重复出现不会产生三次移动;真正执行发生在权威服务器消费 tick 101 时。
4. 当前 v5 InputPacket 在网络上怎样排列?
当前输入数据报使用明确字段编码,而不是发送 C++ 结构体内存:
1 | PacketHeader |
固定部分为 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 | sessionId / matchId / playerId 是否属于当前连接 |
这两层不能合并成一句“协议已经校验”。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 | readable callback |
如果每次回调只读一个,高输入速率下队列会持续堆积,增加延迟甚至触发内核丢包。反过来,无限处理大量包也可能饿死 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 | cmake -S . -B build |
网络性能结论需要固定包大小、发送频率、网络条件和构建模式,并重复测量 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 前拥有多次抵达机会。完整链路仍需要多个互补机制:
- magic/version/type、网络字节序和严格长度检查定义可解析的数据报。
- session/match/player 隔离对局,业务层继续校验 tick、值域和速率。
- tick 让冗余命令幂等归位,seq 主要观测数据报进度与 gap。
- ACK 报告进度和网络统计,State 提供客户端权威 restore/replay 基线。
- Start/Reset 用幂等状态机重试,包大小和非阻塞读取守住传输边界。
最直接的实践建议是为每类数据报写一张“丢失、重复、乱序、截断、超时”处理表。如果某个格子只能回答“应该不会发生”,协议还没有完成。
16. 当前源码阅读入口
以下路径相对于 Fighting 项目根目录:
include/lab/net/Packets.h:v5 包结构与身份字段;include/lab/net/NetCodec.h、src/net/NetCode.cpp:逐字段二进制编解码;include/lab/net/UdpSocket.h、src/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 生命周期测试。