Skip to content

Mercury 可靠 UDP

BigWorld 的网络核心不是“epoll + socket”这么薄的一层,而是 Mercury 在 UDP 之上实现的游戏协议运行时。它把消息聚合、可靠/不可靠语义、ACK、重发、乱序窗口、分片、piggyback、indexed channel 和实体迁移版本号放在同一个 Channel 模型里。

先给结论

Mercury 可靠 UDP 解决的不是普通 RPC 的可靠投递问题,而是 MMO 高频状态同步中的选择性可靠问题:

  • 移动、AOI、属性同步等消息需要低延迟,很多数据过期后不值得重传。
  • 登录、创建实体、跨进程迁移、关键生命周期消息必须可靠。
  • 同一个 UDP 包里可以混合可靠消息和不可靠消息。
  • 外部客户端链路带宽更紧,丢包时只重发可靠数据,不可靠数据会被剥离或丢弃。
  • 内部服务器链路默认低延迟、高带宽、低丢包,可以采用更激进的可靠策略。

源码入口:

  • UDPChannel::Traits 明确区分 INTERNALEXTERNAL,见 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:客户端到服务器,高延迟、低带宽、高丢包。
  • 外部通道只重发可靠数据,不可靠数据从丢包中剥离并丢弃。

这不是简单的网络类型标签,而是重发策略、带宽策略和包内容裁剪策略的输入。

Mercury Channel 语义
 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

cpp
// 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_ 移除

详细讲解:

  1. RELIABLE_DRIVER:第一个可靠消息,让整个 Bundle 具备可靠投递基础。如果没有 driver,Bundle 中的 passenger 消息不会重发。

  2. RELIABLE_PASSENGER:搭车消息,只有 Bundle 中有 driver 时才可靠发送。这允许批量发送多个可靠消息,减少 ACK 开销。

  3. RELIABLE_CRITICAL:关键消息,等价于 driver,并标记 Bundle 为 critical。critical Bundle 会占用 unackedCriticalSeq_,用于判断是否需要等待关键消息确认。

  4. 混合 Bundle:同一个 Bundle 中可以混合可靠和不可靠消息。例如,移动更新(不可靠)和属性变更(可靠)可以在同一个包中发送。

为什么不用 TCP:

  • TCP 是连接级可靠,无法区分”这条消息可靠,那条消息不可靠”
  • TCP 的 head-of-line blocking 会让旧可靠数据阻塞后续新状态
  • MMO 状态同步需要按消息语义选择可靠性,不是按连接统一重传

发送窗口

概述: Mercury 维护自己的滑动窗口和未确认包集合。这不是”发出去后等回调”的简单模型,而是类似 TCP 的窗口机制,但针对游戏协议优化。

源码入口: udp_channel.hpp:383

cpp
// 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 --> [*]
}

详细讲解:

  1. 序号管理smallOutSeqAt_largeOutSeqAt_ 区分普通包和 overflow 包。overflow 包用于窗口满时的特殊处理。

  2. 未确认包集合unackedPackets_ 保存所有已发送但未收到 ACK 的包。每个包记录发送时间、可靠消息列表。

  3. 窗口大小windowSize_ 动态调整,根据网络状况和 ACK 速度变化。maxWindowSize_ 是上限。

  4. 重发机制:如果包在 resendTimeout_ 内未收到 ACK,触发重发。重发超时基于 RTT 估计。

  5. RTT 估计roundTripTime_ 通过 ACK 时间差估算,用于调整重发超时和窗口大小。

发送流程

源码入口: udp_channel.cpp:1513

cpp
// 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

cpp
// 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

详细讲解:

  1. 窗口范围inSeqAt_ 是期望的下一个包序号,oldestUnackedSeq_ 是最早未 ACK 的包。窗口大小限制了可以缓存的乱序包数量。

  2. 重复检测seq < inSeqAt_ 表示包已经收到过,直接丢弃。这防止了重发包被重复处理。

  3. 乱序缓存bufferedReceives_ 缓存乱序包,当 inSeqAt_ 推进时,检查是否有连续的缓存包可以串起来。

  4. ACK 生成:收到包后,将序号加入 acksToSend_ 队列,下次发送时携带 ACK。

  5. 地址校验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_CHANNELChannelIDChannelVersion
  • 如果空间足够,写 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 的边界。
  • INTERNALEXTERNAL 重发策略差异。
  • indexed channel 的 version 过期包丢弃。
  • RELIABLE_CRITICAL 未确认时的恢复行为。
  • 发送窗口溢出和 maxWindowSize() 边界。

源码已经有网络单元测试目录,例如 lib/network/unit_test/test_receive_window.cpptest_reliable.cpptest_fragment.cpptest_channel_version.cpp。后续测试章节会专门分析覆盖范围与缺口。

本章边界

本章只解释 Channel 可靠层。下一章继续分析 Mercury 的通信抽象:InterfaceElement 如何定义消息头,Bundle 如何组织 RPC,请求/回复如何与可靠层衔接。

按源码证据链、源码取舍和验证边界组织,而不是按目录机械罗列。