Mercury 可靠 UDP
BigWorld 的网络核心不是“epoll + socket”这么薄的一层,而是 Mercury 在 UDP 之上实现的游戏协议运行时。它把消息聚合、可靠/不可靠语义、ACK、重发、乱序窗口、分片、piggyback、indexed channel 和实体迁移版本号放在同一个 Channel 模型里。
先给结论
Mercury 可靠 UDP 解决的不是普通 RPC 的可靠投递问题,而是 MMO 高频状态同步中的选择性可靠问题:
- 移动、AOI、属性同步等消息需要低延迟,很多数据过期后不值得重传。
- 登录、创建实体、跨进程迁移、关键生命周期消息必须可靠。
- 同一个 UDP 包里可以混合可靠消息和不可靠消息。
- 外部客户端链路带宽更紧,丢包时只重发可靠数据,不可靠数据会被剥离或丢弃。
- 内部服务器链路默认低延迟、高带宽、低丢包,可以采用更激进的可靠策略。
源码入口:
UDPChannel::Traits明确区分INTERNAL与EXTERNAL,见 udp_channel.hpp。- 可靠机制入口包括
addResendTimer()、handleCumulativeAck()、handleAck()、checkResendTimers()、resend(),见 udp_channel.hpp。 - 发送窗口状态在
smallOutSeqAt_、largeOutSeqAt_、oldestUnackedSeq_、unackedPackets_,见 udp_channel.hpp。 - 接收窗口状态在
inSeqAt_、bufferedReceives_、pFragments_、highestAck_,见 udp_channel.hpp。
为什么不用纯 TCP
TCP 提供字节流可靠有序传输,但游戏状态同步不总是需要“所有历史状态按顺序到达”。
BigWorld 需要的能力更接近:
可靠消息
实体创建、销毁、迁移、请求/回复、关键生命周期事件必须到达。
不可靠消息
位置、朝向、部分 AOI 更新过期后没有重传价值,新状态覆盖旧状态。
混合 Bundle
同一个发送批次内同时携带可靠和不可靠消息,降低包头和 syscall 开销。
实体迁移
Channel 不只是网络连接,还要承载 Base/Cell 实体 offload 的版本语义。
如果全部使用 TCP,会获得简单可靠流,但会付出这些代价:
- head-of-line blocking 会让旧可靠数据阻塞后续新状态。
- 无法天然表达“这条消息可靠,那条消息不可靠”。
- 多条逻辑流要么多 TCP 连接,要么在应用层重新多路复用。
- MMO 状态同步常常需要按消息语义而不是按连接统一重传。
这也是当时很多游戏服务器选择 UDP + 自研可靠层的根本原因。
Channel 分类
UDPChannel::Traits 的注释已经给出核心设计判断:
INTERNAL:服务器到服务器,低延迟、高带宽、低丢包。EXTERNAL:客户端到服务器,高延迟、低带宽、高丢包。- 外部通道只重发可靠数据,不可靠数据从丢包中剥离并丢弃。
这不是简单的网络类型标签,而是重发策略、带宽策略和包内容裁剪策略的输入。
flowchart TD A[UDP Socket] --> B[NetworkInterface] B --> C[UDPChannel] C --> D{Traits} D -- INTERNAL --> E[Server-Server Channel] D -- EXTERNAL --> F[Client-Server Channel] E --> G[可靠与不可靠消息都更偏向保留吞吐] F --> H[重发可靠消息 剥离不可靠负载] C --> I[Bundle 消息聚合] I --> J[ReliableOrder] I --> K[Piggyback] 可靠等级
概述: 可靠性不是简单的 bool 值。BigWorld 定义了四种可靠类型,允许在同一个 Bundle 中混合可靠和不可靠消息。这是 MMO 状态同步的关键:位置更新可以丢弃,但实体创建必须可靠。
源码入口: bundle.hpp:27
// bundle.hpp:27 - 可靠类型定义
enum ReliableType
{
RELIABLE_NO, // 不可靠消息
RELIABLE_DRIVER, // 驱动可靠包
RELIABLE_PASSENGER, // 搭车可靠消息
RELIABLE_CRITICAL // 关键可靠消息
};| 类型 | 含义 | 设计目的 |
|---|---|---|
RELIABLE_NO | 不可靠消息 | 允许丢弃,适合过期即无价值的数据 |
RELIABLE_DRIVER | 驱动可靠包 | 让当前 UDPBundle 具备可靠投递基础 |
RELIABLE_PASSENGER | 搭车可靠消息 | 只有同 Bundle 有 driver 时才可靠发送 |
RELIABLE_CRITICAL | 关键可靠消息 | 等价 driver,并把 Bundle 标记为 critical |
流程图:
sequenceDiagram participant Sender as 发送方 participant Bundle as UDPBundle participant Channel as UDPChannel participant Receiver as 接收方Sender->>Bundle: 添加消息 (RELIABLE_DRIVER) Bundle->>Bundle: 标记为可靠 Sender->>Bundle: 添加消息 (RELIABLE_PASSENGER) Bundle->>Bundle: 搭车可靠 Sender->>Bundle: 添加消息 (RELIABLE_NO) Bundle->>Bundle: 不可靠 Bundle->>Channel: 发送 Bundle Channel->>Channel: 记录到 unackedPackets_ Channel->>Receiver: UDP 包 Receiver->>Receiver: 解析消息 Receiver->>Channel: 发送 ACK Channel->>Channel: 从 unackedPackets_ 移除
详细讲解:
RELIABLE_DRIVER:第一个可靠消息,让整个 Bundle 具备可靠投递基础。如果没有 driver,Bundle 中的 passenger 消息不会重发。
RELIABLE_PASSENGER:搭车消息,只有 Bundle 中有 driver 时才可靠发送。这允许批量发送多个可靠消息,减少 ACK 开销。
RELIABLE_CRITICAL:关键消息,等价于 driver,并标记 Bundle 为 critical。critical Bundle 会占用
unackedCriticalSeq_,用于判断是否需要等待关键消息确认。混合 Bundle:同一个 Bundle 中可以混合可靠和不可靠消息。例如,移动更新(不可靠)和属性变更(可靠)可以在同一个包中发送。
为什么不用 TCP:
- TCP 是连接级可靠,无法区分”这条消息可靠,那条消息不可靠”
- TCP 的 head-of-line blocking 会让旧可靠数据阻塞后续新状态
- MMO 状态同步需要按消息语义选择可靠性,不是按连接统一重传
发送窗口
概述: Mercury 维护自己的滑动窗口和未确认包集合。这不是”发出去后等回调”的简单模型,而是类似 TCP 的窗口机制,但针对游戏协议优化。
源码入口: udp_channel.hpp:383
// udp_channel.hpp:383 - 发送窗口状态
class UDPChannel
{
private:
// 发送窗口状态
SeqNum smallOutSeqAt_; // 下一个要发送的序号(不含 overflow)
SeqNum largeOutSeqAt_; // 下一个要发送的序号(含 overflow)
SeqNum oldestUnackedSeq_; // 最早未 ACK 的包
UnackedPackets unackedPackets_; // 未确认包集合
int windowSize_; // 当前窗口大小
int maxWindowSize_; // 最大窗口大小
// 重发相关
int roundTripTime_; // RTT 估计
int resendTimeout_; // 重发超时
int lastSendTime_; // 上次发送时间
};流程图:
stateDiagram-v2 [*] --> Idle Idle --> Sending: 有数据要发 Sending --> WindowFull: 窗口满 WindowFull --> Sending: 收到 ACK Sending --> Resending: 超时未 ACK Resending --> Sending: 收到 ACK Sending --> Idle: 无数据state Sending { [*] --> CheckWindow CheckWindow --> SendPacket: 窗口有空间 CheckWindow --> WaitACK: 窗口满 SendPacket --> RecordUnacked RecordUnacked --> CheckWindow } state Resending { [*] --> FindOldest FindOldest --> ResendPacket ResendPacket --> UpdateTimeout UpdateTimeout --> [*] }
详细讲解:
序号管理:
smallOutSeqAt_和largeOutSeqAt_区分普通包和 overflow 包。overflow 包用于窗口满时的特殊处理。未确认包集合:
unackedPackets_保存所有已发送但未收到 ACK 的包。每个包记录发送时间、可靠消息列表。窗口大小:
windowSize_动态调整,根据网络状况和 ACK 速度变化。maxWindowSize_是上限。重发机制:如果包在
resendTimeout_内未收到 ACK,触发重发。重发超时基于 RTT 估计。RTT 估计:
roundTripTime_通过 ACK 时间差估算,用于调整重发超时和窗口大小。
发送流程
源码入口: udp_channel.cpp:1513
// udp_channel.cpp:1513 - 发送 Bundle
void UDPChannel::send( Bundle * pBundle )
{
UDPBundle * pUDPBundle = static_cast<UDPBundle *>(pBundle);
// 1. 检查窗口是否有空间
if (this->windowSize() >= this->maxWindowSize())
{
// 窗口满,等待 ACK
return;
}
// 2. 分配序号
SeqNum seq = largeOutSeqAt_++;
// 3. 记录到未确认包集合
unackedPackets_.add( seq, pUDPBundle );
// 4. 写入 packet header
this->writeFlags( pUDPBundle );
this->writeFooter( pUDPBundle );
// 5. 发送
pUDPBundle->send();
}关键细节:
- 窗口满时,新消息会排队等待,不会丢弃
- 序号单调递增,用于接收方检测乱序和重复
- 未确认包集合支持快速查找和重发
- packet header 包含 ACK 信息,用于确认之前发送的包
接收窗口
概述: 接收窗口负责处理乱序包、检测重复包、维护接收顺序。这是可靠 UDP 的核心:允许短暂乱序,但最终按序交付。
源码入口: udp_channel.cpp:1208
// udp_channel.cpp:1208 - 添加到接收窗口
UDPChannel::AddToReceiveWindowResult UDPChannel::addToReceiveWindow(
SeqNum seq, Packet * pPacket, bool isReliable )
{
// 1. 校验 sequence number 是否在合法范围
if (seq >= inSeqAt_ + windowSize_ || seq < oldestUnackedSeq_)
{
return ADD_TO_RECEIVE_WINDOW_RESULT_OUT_OF_WINDOW;
}
// 2. 校验源地址
if (!this->checkSourceAddress( pPacket ))
{
return ADD_TO_RECEIVE_WINDOW_RESULT_CORRUPT;
}
// 3. 对非 piggyback 包加入 ACK 队列
if (!pPacket->isPiggyback())
{
acksToSend_.push_back( seq );
}
// 4. 检查是否为预期的下一个包
if (seq == inSeqAt_)
{
// 推进接收窗口
inSeqAt_++;
// 检查是否有缓存的乱序包可以串起来
while (bufferedReceives_.contains( inSeqAt_ ))
{
pPacket = bufferedReceives_.get( inSeqAt_ );
this->processPacket( pPacket );
inSeqAt_++;
}
return ADD_TO_RECEIVE_WINDOW_RESULT_NEXT;
}
// 5. 检查是否为重复包
if (seq < inSeqAt_)
{
return ADD_TO_RECEIVE_WINDOW_RESULT_DUPLICATE;
}
// 6. 乱序包,缓存到接收窗口
bufferedReceives_.add( seq, pPacket );
return ADD_TO_RECEIVE_WINDOW_RESULT_BUFFERED;
}流程图:
sequenceDiagram participant Receiver as 接收方 participant Window as 接收窗口 participant Buffer as 缓冲区 participant Handler as 消息处理Receiver->>Window: addToReceiveWindow(seq) alt seq 非法 Window->>Receiver: OUT_OF_WINDOW else seq == inSeqAt_ Window->>Window: inSeqAt_++ loop 有缓存的连续包 Window->>Buffer: get(inSeqAt_) Buffer->>Handler: processPacket() Window->>Window: inSeqAt_++ end Window->>Receiver: NEXT else seq < inSeqAt_ Window->>Receiver: DUPLICATE else seq 在窗口内 Window->>Buffer: add(seq, packet) Window->>Receiver: BUFFERED end
详细讲解:
窗口范围:
inSeqAt_是期望的下一个包序号,oldestUnackedSeq_是最早未 ACK 的包。窗口大小限制了可以缓存的乱序包数量。重复检测:
seq < inSeqAt_表示包已经收到过,直接丢弃。这防止了重发包被重复处理。乱序缓存:
bufferedReceives_缓存乱序包,当inSeqAt_推进时,检查是否有连续的缓存包可以串起来。ACK 生成:收到包后,将序号加入
acksToSend_队列,下次发送时携带 ACK。地址校验:
checkSourceAddress()防止伪造包。如果允许shouldAutoSwitchToSrcAddr_,可以根据 channel version 切换地址。
接收窗口状态机
stateDiagram-v2 [*] --> ValidateSeq ValidateSeq --> Corrupt: seq 非法 ValidateSeq --> CheckAddress: seq 合法 CheckAddress --> Corrupt: 源地址错误 CheckAddress --> AckQueued: 地址合法 AckQueued --> Next: seq == inSeqAt_ AckQueued --> Duplicate: seq < inSeqAt_ AckQueued --> Buffered: seq 在窗口内但乱序 AckQueued --> OutOfWindow: seq 超过窗口 Next --> AdvanceWindow AdvanceWindow --> AttachBuffered AttachBuffered --> [*] Duplicate --> [*] Buffered --> [*] OutOfWindow --> [*] Corrupt --> [*]
这个状态机是可靠 UDP 的核心之一。它保证可靠消息能按 Channel 序号恢复顺序,同时允许短暂乱序进入缓冲。
ACK 与 cumulative ACK
writeFlags() 和 writeFooter() 负责把 Channel 元数据写入 packet,源码见:
关键策略:
- 所有 channel 包写
FLAG_ON_CHANNEL。 - indexed channel 额外写
FLAG_INDEXED_CHANNEL、ChannelID、ChannelVersion。 - 如果空间足够,写 cumulative ACK,表达某个序号之前全部收到。
- 如果还有空间,写有限数量的离散 ACK。
- 第一个内部可靠包会写
FLAG_CREATE_CHANNEL。
这是一种很典型的“累计确认 + 选择性确认”混合策略:
- cumulative ACK 压缩连续确认范围。
- 离散 ACK 处理乱序洞。
- ACK piggyback 到出站包,减少独立 ACK 包。
- ACK 数量受 packet 空间限制,避免 ACK 把负载挤爆。
重发路径
UDPChannel::resend() 的策略不是机械重发原包,见 udp_channel.cpp。
对于外部通道,如果未确认包不是 fragment,并且当前发送窗口不会溢出,会尝试把可靠内容 piggyback 到下一个 outgoing bundle:
flowchart TD A[unacked packet timeout or ack hole] --> B{External channel?} B -- no --> E[sendUnacked 原包重发] B -- yes --> C{非 fragment 且窗口可容纳?} C -- yes --> D[piggyback 到新 Bundle] C -- no --> E D --> F[handleAck 原序号] E --> G[sendPacket isResend=true] 这体现了外部客户端链路的带宽优先策略:能搭车就不单独重发,能只重发可靠内容就不重复发送已经过期的不可靠数据。
Indexed Channel
UDPChannel 支持 indexed channel,源码注释见 udp_channel.hpp。
indexed channel 的作用是:在同一对地址之间复用多个逻辑 channel。典型场景是 Base 和 Cell 实体之间需要多个独立流,仅靠地址无法区分。
它包含两个关键字段:
ChannelID id_:逻辑 channel 标识。ChannelVersion version_:记录 indexed channel 被 offload 的次数,用于识别过期包和恢复场景下的最新实体信息。
这点对理解实体迁移非常重要。BigWorld 的网络连接不是纯传输概念,它嵌入了实体迁移和恢复语义。
Regular 与 Irregular
isLocalRegular_ 和 isRemoteRegular_ 会影响 ACK 与重发策略,源码见 udp_channel.hpp。
含义:
- local irregular 时,本地会定期检查重发,并更倾向立即发送 ACK。
- remote regular 时,可以基于 ACK 洞推断丢包,而不是只靠静默超时。
这说明 Mercury 的可靠层利用了游戏服务器通信模式:有些 Channel 会持续发包,有些 Channel 是间歇发包。不同流量形态应使用不同 ACK/重发策略。
与 Bundle 的关系
UDPChannel::newBundle() 创建 UDPBundle,见 udp_channel.cpp。
UDPBundle 负责:
startMessage()、startRequest()、startReply()。- 记录
ReliableOrder。 preparePackets()将消息序列切成 UDP packet。piggyback()把重发内容挂到新包。ack_处理 off-channel ack。
源码见 udp_bundle.hpp。
所以 Channel 管连接状态和可靠窗口,Bundle 管消息聚合与 packet 编排。两者合起来才是 Mercury 的游戏网络协议。
源码取舍
Mercury 可靠 UDP 与引擎状态模型深度绑定:
- TCP 无法表达同一逻辑流内可靠/不可靠混合语义。
- 服务器内部多进程 MMO 通信需要可控协议头、可观测统计和引擎级调试能力。
- Python 脚本层和 EntityDef 类型系统需要与网络协议紧密结合。
- Channel version、critical reliable、piggyback、fragment 和 ACK 都参与实体迁移、恢复和内部 RPC 的正确性。
源码代价同样明确:
- 协议复杂度高,正确性依赖大量边界测试。
- 与通用生态脱节,外部工具难以直接解析。
- 拥塞、路径 MTU、NAT、加密和攻击面需要结合外部入口、PacketFilter 和部署拓扑一起分析。
- I/O 后端只能影响收发方式,不能替代 Channel 的可靠顺序和实体语义。
源码验证重点
可靠 UDP 的测试不能只测“能收发”。至少要覆盖:
- 连续 ACK、离散 ACK、ACK 丢失、ACK 延迟。
- 乱序包、重复包、窗口外包、损坏包。
- fragment 包重发与 piggyback 的边界。
INTERNAL与EXTERNAL重发策略差异。- indexed channel 的 version 过期包丢弃。
RELIABLE_CRITICAL未确认时的恢复行为。- 发送窗口溢出和
maxWindowSize()边界。
源码已经有网络单元测试目录,例如 lib/network/unit_test/test_receive_window.cpp、test_reliable.cpp、test_fragment.cpp、test_channel_version.cpp。后续测试章节会专门分析覆盖范围与缺口。
本章边界
本章只解释 Channel 可靠层。下一章继续分析 Mercury 的通信抽象:InterfaceElement 如何定义消息头,Bundle 如何组织 RPC,请求/回复如何与可靠层衔接。
