Skip to content

04 通信协议设计 ​

本章目标:帮助读者建立游戏协议设计的完整方法论——先拆消息语义,再做传输选型,最后才谈格式与优化。读完本章,你应该能为自己的项目定义一套可持续演化的协议契约,而不是一堆临时约定的 JSON 字段。

参考书籍:本章基于《网络游戏核心技术与实战》(中嶋谦互)的协议设计框架,结合《百万在线》中的高并发协议实践。

一个常见误区:协议设计不是"定义几个 JSON 接口",而是客户端多版本、服务端多模块、接入层、工具链、灰度包、后台系统之间的长期契约。很多线上事故不是网络断了,而是不同参与方对同一条消息产生了不同理解。


1. 协议设计为什么重要 ​

协议设计是游戏服务器开发中最容易被低估的环节。很多团队在项目初期随便定义了一些 JSON 接口,等到游戏上线后才发现:消息体积太大导致带宽不够、协议不兼容导致老版本客户端无法更新、没有版本管理导致接口混乱。

协议一旦上线就很难修改——因为你要同时兼容新老版本的客户端。所以协议设计需要"向前看",预留扩展空间,考虑未来的业务需求。

一个反面案例:某手游在 v1.0 版本使用了简单的 JSON 协议,消息 ID 就是字符串(如 "login"、"move")。等到 v3.0 需要支持跨服玩法时,发现字符串消息 ID 无法高效路由,不得不做一次痛苦的协议迁移。如果一开始就用数字编码的模块化消息 ID,这个问题根本不会出现。

1.1 为什么协议问题总在后期变重 ​

协议在项目初期通常看起来很简单:定一个消息号,放几个字段,本地联调能通。但游戏项目的协议几乎一定会持续演化:

  • 新字段增加、旧字段废弃、枚举扩展
  • 压缩、加密或包头策略变化
  • 热更新包和渠道包共存
  • 客户端与服务端版本并不完全同步(渠道审核窗口、玩家延迟更新)

如果前期没有契约意识,系统就只能依赖一个脆弱的假设:所有参与方永远一起升级,而且永远不会误解字段。这个假设在真实项目里从不成立。

1.2 一份协议契约应该包含什么 ​

一条成熟的协议契约,不只是"字段长什么样",至少应包含:

  • 消息编号/类型的业务语义(不只是数字,还有它"意味着什么")
  • 请求和响应结构定义,字段含义、默认值、可选规则
  • 枚举的扩展规则(收到未知枚举值怎么处理)
  • 错误码语义与客户端处理策略
  • 兼容窗口和版本协商方式
  • 异常包与非法值的处理约定

1.3 协议设计的三个原则 ​

  1. 可扩展:新功能不需要修改已有协议,只需要添加新消息
  2. 高效:消息体积尽可能小,编解码速度尽可能快
  3. 安全:防篡改、防重放、防刷

这三个原则的优先级不是固定的——休闲回合制游戏可以把"高效"放得很低(JSON 完全够用),而强实时竞技游戏的每一条原则都是硬约束。判断依据来自下一节的语义分析。


2. 先拆语义,再谈协议 ​

"到底用 TCP 还是 UDP"是网络讨论里最常见的开场白,但它通常是错误的第一问。更有用的顺序是:先看消息语义,再看传输需求,最后才看具体协议。

2.1 连接、会话、消息要分开 ​

很多网络设计讨论之所以混乱,是因为"连接断了""会话没了""消息没到"常被当成同一类问题。它们的生命周期其实完全不同:

层含义生命周期
连接TCP/WebSocket/UDP 上的物理或逻辑通道可能随时断开
会话玩家绑定的业务上下文(登录态、房间态)可以跨连接存活
消息在连接和会话中流动的最小业务单位单条失败不必拖垮会话

重连、踢线、路由、会话恢复这些问题,本质上都是在处理三者之间的映射关系。把会话设计成"连接断开会话还在",断线重连就从"重新登录重建一切"简化成"换一条连接接着玩"。

2.2 按语义给消息分类 ​

一个游戏项目里的流量至少可以分成五类,它们在优先级、可靠性、可丢弃性上完全不同:

消息类别典型例子核心诉求可丢弃?
强控制登录、鉴权、进房、结算、资产变更可靠、幂等、明确回执不可
强实时位移输入、技能、朝向、开火时效性,旧消息价值快速衰减可丢旧
弱实时准备状态、频道状态、低频位置更新尽量及时,偶尔延迟可接受可降频
外围通知邮件、红点、公告、好友上线不丢即可,延迟不敏感可延迟
大包传输热更资源、配置、回放、战报稳定吞吐,不与实时链路抢带宽可断点续传

游戏网络设计的第一原则:不要试图用一种处理方式覆盖所有消息。"聊天拖慢战斗""下载链路影响广播"这类事故,根源都是不同语义的流量混在同一条处理路径里。

2.3 可靠性、顺序、兼容性是三件事 ​

"可靠送达""顺序正确""跨版本兼容"经常被一起提,但它们不是一回事:可靠不代表有序,有序不代表兼容。混为一谈是很多协议治理问题的起点。

  • 可靠性关注消息最终有没有送达和处理——支付结果、资产发放、结算确认
  • 顺序性关注消息以什么相对次序被消费——连续输入帧、同一对象的状态变化
  • 兼容性关注不同版本参与方能否一致理解同一条消息——灰度发布、渠道包延迟更新

三者会互相影响,但需要分开治理:可靠性靠确认与重传(或选 TCP),顺序靠序列号与排序窗口,兼容性靠字段演化纪律和版本协商。设计每条消息时问一句"它对这三者各自的要求是什么",比笼统地说"这个消息很重要"有用得多。


3. 协议类型与消息模式 ​

3.1 八种基本类型 ​

网络游戏中的协议可以按传输方向、可靠性和使用场景分为八种基本类型:

类型方向可靠性典型场景传输层
请求-响应C→S→C可靠登录、背包操作、商店购买TCP/WebSocket
服务端推送S→C可靠系统公告、邮件、任务完成TCP/WebSocket
广播通知S→C不可靠聊天消息、世界公告UDP
实时同步C↔S不可靠位置同步、战斗状态UDP
心跳包C↔S不可靠连接保活、延迟探测UDP
确认包C↔S可靠操作确认、数据校验TCP
批量同步S→C可靠登录初始数据、全量同步TCP
跨服消息S↔S可靠跨服匹配、跨服聊天gRPC/TCP

不同业务场景对"可靠性"和"延迟"的需求完全不同:登录操作需要可靠、延迟可以稍高;位置同步需要低延迟、偶尔丢包可以接受;系统公告需要可靠(不能丢)但延迟不敏感。如果所有业务都走 TCP 请求-响应,位置同步会被 TCP 重传的延迟抖动拖累;如果登录走 UDP,你分不清失败是网络问题还是服务端拒绝。

3.2 请求-响应与推送广播的边界 ​

请求-响应最适合需要明确因果和明确回执的场景:登录、进房、购买、抽卡、领奖励、拉配置。它的价值在于因果关系清晰、错误处理清晰、客户端状态机容易组织。

但它不适合承载持续状态同步。一旦把"状态变化"也强行做成"发请求等回包",链路会变得冗长、回包臃肿,服务端还要为大量无意义确认付成本。只要玩家之间共享同一房间/场景/活动状态,推送和广播就不可避免——而广播的本质不是"发给所有人",而是"谁应该知道什么变化"。这个问题回答不好,带宽和客户端消费成本都会迅速失控(详见第 10 节)。

3.3 选型决策树 ​

text
需要可靠传输?
  ├── 是 → TCP/WebSocket
  │        ├── 客户端发起? → 请求-响应
  │        └── 服务器发起? → 服务端推送 / 批量同步
  └── 否 → UDP
           ├── 高频小包? → 实时同步
           └── 低频通知? → 广播通知

需要跨服通信?
  └── 是 → gRPC / 内部 TCP

4. 传输层选择:TCP、UDP 与它们的封装 ​

4.1 TCP 解决什么,又带来什么 ​

TCP 的价值不只是"可靠",而是它把一堆基础语义交给了成熟的协议栈:有连接、有序、可靠、流量控制、拥塞控制。这显著降低了接入层自己维护传输语义的成本——所以控制类消息、会话类消息、外围推送、一般房间交互都很适合跑在 TCP 或 WebSocket 之上。

TCP 的明确代价:

  • 队头阻塞:前面丢一个包,后面所有数据都要等重传——会把混合语义流量之间的相互影响放大
  • 旧消息可能拖慢新消息:位置同步里,一条过时的旧位置包重传到达,价值已经是负的
  • 所有消息被强制同一种可靠语义,想"这条可丢、那条必达"做不到

这不意味着 TCP 不好,而是说明:不要把所有链路都塞进同一种 TCP 语义里。

4.2 UDP 解决什么,又让你多承担什么 ​

UDP 的核心价值是控制权。你可以自己决定:

  • 哪些消息要确认重传,哪些直接丢弃
  • 旧消息过期就不重发(时间戳比对即可)
  • 不受队头阻塞影响——一条消息丢了不影响别的消息到达

相应的代价是:可靠传输、顺序、拥塞控制这些 TCP 白送的东西,你都需要自己(或通过 KCP 这类库)重建。常见组合是"大部分流量走 TCP/WebSocket,只有局内强实时消息走 UDP + 自定义可靠性层"。

4.3 WebSocket、KCP、QUIC 的位置 ​

方案本质适合注意点
WebSocketTCP 上的全双工协议H5/小游戏、需要过 HTTP 基础设施继承 TCP 队头阻塞;部分代理/网关超时短,需心跳保活
KCPUDP 上的可靠 ARQ强实时且能接受非标协议以 10%-20% 带宽换更低重传延迟;需自行处理穿透和加密
QUICUDP 上的现代可靠传输新项目、需要多路复用无队头阻塞国内移动网络对 UDP 高端口的 QoS 差异明显,需实测;客户端集成成本高

一个务实提醒:国内移动网络对不同 UDP 端口段的 QoS 策略差异很大,实验室里 UDP 优势明显,商用环境可能被运营商限速。传输选型需要在全国多地真机实测,而不是只看 benchmark。

4.4 游戏常见组合 ​

  • 卡牌/回合制/SLG:全 TCP(或 HTTP + WebSocket 推送),无 UDP
  • MMO:TCP/WebSocket 主链路 + 可选 UDP 位置通道
  • FPS/MOBA 竞技:TCP 控制面(登录/匹配/结算)+ UDP/KCP 数据面(输入与状态同步)
  • 格斗/赛车 1v1:P2P(UDP 直连 + TURN 回退),见第 11 章中继服务器

5. API 规范设计 ​

5.1 消息命名规范 ​

好的命名规范让协议自解释——看到消息 ID 就知道它是哪个模块的什么操作。常用命名格式:模块_动作_方向。

模块说明消息 ID 前缀
AUTH认证相关0x01
PLAYER玩家相关0x02
BATTLE战斗相关0x03
CHAT聊天相关0x04
SHOP商店相关0x05
GUILD公会相关0x06
MAIL邮件相关0x07
ACTIVITY活动相关0x08

方向标记:REQ(请求)、RSP(响应)、NTF(通知)、SYNC(同步)。

示例:AUTH_LOGIN_REQ = 认证模块的登录请求,消息 ID = EncodeMsgID(0x01, 0x0001, 0x01)。

5.2 消息 ID 编码 ​

推荐编码:模块(8bit) + 动作(16bit) + 类型(8bit),共 4 字节 uint32:

  • 8bit 模块 = 最多 256 个模块
  • 16bit 动作 = 每个模块最多 65536 个动作
  • 8bit 类型 = REQ/RSP/NTF/SYNC 四种消息类型
go
// 消息 ID 编解码:模块(8) + 动作(16) + 类型(8)
func EncodeMsgID(module uint8, action uint16, kind uint8) uint32 {
    return uint32(module)<<24 | uint32(action)<<8 | uint32(kind)
}

func DecodeMsgID(id uint32) (module uint8, action uint16, kind uint8) {
    return uint8(id >> 24), uint16(id >> 8), uint8(id)
}

// 路由时只需一次位运算和一次查表,不需要解析字符串
var handlers = map[uint32]Handler{
    EncodeMsgID(0x01, 0x0001, 0x01): handleLogin,
    EncodeMsgID(0x03, 0x0007, 0x01): handleCastSkill,
}

数字编码的真正价值在路由效率和跨服转发:网关拿到 uint32 就能查表转发,而字符串 ID 需要先解析再比对,且没法做前缀通配(比如"转发所有 BATTLE 模块消息给战斗服"用 (id>>24)==0x03 一个判断就完成)。

5.3 错误码设计 ​

错误码应包含足够信息,让客户端知道"出了什么问题"和"该怎么告诉玩家"。推荐格式:模块(8bit) + 错误类型(8bit) + 错误号(16bit)。

错误类型值客户端策略
参数错误0x01提示"请检查输入",一般不需要上报
逻辑错误0x02展示具体原因文案(金币不足、等级不够),文案 ID 由服务端下发
系统错误0x03提示"请稍后重试",自动重试需带退避并计入限流

一个经常被忽略的设计点:错误文案应由客户端按错误码本地化,或由服务端下发文案 ID,而不是服务端直接下发中文句子——否则多语言、文案改版都要动服务端。


6. 包格式设计 ​

6.1 通用包头 ​

包头是协议的"信封",包含路由、校验、控制等元信息:

字段大小说明
Magic2 bytes魔数,用于快速识别有效包(如 0x474D = "GM")
Version1 byte协议版本号
Flags1 byte标志位(压缩/加密/分片/优先级)
MsgID4 bytes消息 ID
SeqNum4 bytes序列号(防重放和消息去重)
BodyLength4 bytes消息体长度
Checksum4 bytes校验和

包头固定 20 字节。Flags 是值得花心思设计的字段——压缩、加密、分片这些"包体处理方式"都靠它标记,让接收方不需要额外消息就能知道如何解码。

6.2 为什么需要魔数:TCP 粘包与拆包 ​

TCP 是流式协议,没有"包"的概念。一次 read 可能读到半个包、一个包、或三个半包(粘包/拆包)。所以需要消息边界机制,最常见的是长度字段分帧:包头声明 BodyLength,接收方按长度切分字节流。

go
// 拆包器:从字节流中切出完整消息(处理粘包/半包)
func (d *Decoder) Next() ([]byte, error) {
    for {
        if d.buf.Len() < headerSize {
            return nil, io.EOF // 数据不够,等下一次读
        }
        header := d.buf.Bytes()[:headerSize]
        if binary.LittleEndian.Uint16(header) != magicNumber {
            // 魔数不对:字节流错位(脏数据或攻击),丢弃一个字节重新同步
            d.buf.ReadByte()
            continue
        }
        bodyLen := binary.LittleEndian.Uint32(header[12:16])
        if bodyLen > maxBodySize {          // 上限校验:防止恶意超长包
            return nil, ErrPacketTooLarge
        }
        if d.buf.Len() < headerSize+int(bodyLen) {
            return nil, io.EOF              // 半包,继续等
        }
        packet := make([]byte, headerSize+int(bodyLen))
        d.buf.Read(packet)
        return packet, nil
    }
}

这段代码里有三个生产级的防御:魔数错位时逐字节重同步而不是断连、BodyLength 有上限校验(否则一个伪造的 4 字节长度就能让服务端预分配 4GB 内存)、半包时正确等待而不是报错。

6.3 大消息的分片与重组 ​

当消息体超过 MTU(以太网通常 1500 字节,移动网络有效值更低)时需要应用层分片。关键是每个分片都携带分片信息(分片 ID、总分片数、当前索引),接收方收齐后重组。

重组器最常踩的坑是超时清理:某个分片因丢包一直不到,重组缓冲会一直占着内存。需要给每个未完成的分片组设超时(如 5 秒),超时丢弃。分片本身就是 Flags 里的一个标志位——收到带分片标志的包,走重组流程而不是直接处理。


7. 序列化格式的选择 ​

7.1 候选格式对比 ​

格式体积编解码速度可读性schema适合
JSON大(基准)慢好无管理后台、调试、日志
Protobuf小 60-80%快 5-10 倍差.proto 文件游戏核心协议
FlatBuffers小极快(零拷贝)差.fbs 文件低延迟热路径
自定义二进制最小极快最差文档约定位置同步等高频小包

7.2 Protobuf 的版本兼容纪律 ​

Protobuf 成为游戏协议首选的原因是体积、速度、强类型和代码生成的组合优势。但让它能支撑"长期契约"的,是字段演化的纪律——这些规则比性能更重要:

  1. 永不复用字段编号:废弃字段用 reserved 锁死编号和名字,而不是删除后让新字段顶上。复用编号会让新旧版本把完全不同的数据解读成同一个字段
  2. 新字段必须是 optional:老版本收到含未知字段的消息会跳过它们(proto3 默认行为),这是前向兼容的根基
  3. 枚举只增不改值:客户端收到未知枚举值时按"未知类型"处理,不要崩溃或默认到第一个分支
  4. 不要用 required(proto1/2 的教训):required 字段一旦存在就无法降级为可选
  5. 嵌套消息优于并行字段:给未来留出"整块替换"的空间
protobuf
message PlayerSync {
  reserved 3, 7;              // 已废弃:3=旧战力字段, 7=旧皮肤模型
  reserved "legacy_power";
  uint64 player_id = 1;
  Position pos = 2;           // 嵌套消息,未来可整体替换
  optional int32 skin_id = 4; // 后加的字段,必须 optional
}

7.3 何时用 JSON ​

场景推荐格式原因
游戏核心逻辑Protobuf体积小、速度快
登录/支付接口JSON可读性好、调试方便、第三方对接常用
管理后台 APIJSON浏览器原生支持
日志/监控JSONELK 生态支持
实时同步数据Protobuf/FlatBuffers性能要求高

JSON 不是"落后"——支付回调、运营后台这些低频但需要人读的接口,JSON 的可调试性价值大于 Protobuf 的性能价值。按链路选格式,而不是全项目一刀切。

7.4 手动二进制编码技巧 ​

对性能要求极高的场景(位置同步每秒几十包),可以绕过通用序列化,手工编码:

go
// 位置同步包:24 字节固定布局
// | Seq uint32 | X int32 | Y int32 | Z int32 | Flags uint8 | Pad 3B |
func encodePosition(seq uint32, p Position, flags uint8) []byte {
    buf := make([]byte, 20)
    binary.LittleEndian.PutUint32(buf[0:4], seq)
    binary.LittleEndian.PutUint32(buf[4:8], quantize(p.X)) // 浮点→定点
    binary.LittleEndian.PutUint32(buf[8:12], quantize(p.Y))
    binary.LittleEndian.PutUint32(buf[12:16], quantize(p.Z))
    buf[16] = flags
    return buf
}

// 坐标压缩:浮点转定点整数,精度 0.01
// -10000.00 ~ 10000.00 映射到 int32,比 float32 省 0 字节
// 但避免了浮点跨平台误差,还方便 delta 压缩
func quantize(v float32) uint32 {
    return uint32(int32(v*100) + 1000000) // 偏移保证非负
}

三个关键技巧:

  • Varint 编码:小数值用更少字节(<128 占 1 字节,<16384 占 2 字节)——适合 ID、等级这类小数字
  • 定点坐标:浮点转整数(精度 0.01),顺带消除跨平台浮点误差
  • 位域编码:一个字节存 8 个布尔(站立/移动/攻击/无敌……),替代 8 个 bool 字段

代价是可读性归零、需要手工维护字节布局文档。所以它只用在profile 证明确实需要的热路径上,不要全项目推广。


8. 压缩与消息合并 ​

8.1 压缩阈值与算法选择 ​

不是所有消息都需要压缩——压缩有 CPU 开销,小消息可能得不偿失:

text
消息类型判断:
  ├── 小消息(< 64 bytes)→ 不压缩
  ├── 中等消息(64-256 bytes)→ 根据频率决定
  ├── 大消息(> 256 bytes)→ 压缩
  └── 批量数据 → 需要压缩

压缩算法选择:
  ├── LZ4 → 速度快,压缩率低(适合实时消息)
  ├── zlib → 平衡(适合一般消息)
  ├── zstd → 压缩率高,速度适中(适合大文件)
  └── Snappy → 极快,压缩率最低(适合日志)

压缩开关放在包头 Flags 里,让每条消息独立决定——这样调试时可以关压缩抓包,线上只压大包。

8.2 Delta 压缩(增量同步) ​

Delta 压缩是游戏同步中最重要的优化之一:只发送变化的数据,而非全量数据。

一个玩家的位置同步包含坐标(x,y,z)、朝向、速度、状态。如果只有坐标变了,就只发坐标。常用实现是脏标记位掩码:

go
const (
    FieldX uint16 = 1 << iota
    FieldY
    FieldZ
    FieldRotation
    FieldVelocity
    FieldState
)

func encodeDelta(prev, cur *PlayerState) []byte {
    mask := uint16(0)
    if prev.X != cur.X { mask |= FieldX }
    // ... 逐字段比较
    buf := writeUint16(mask)        // 先写 2 字节位掩码
    if mask&FieldX != 0 { buf = append(buf, quantize(cur.X)...) }
    // ... 只写变化的字段
    return buf
}

接收方按掩码逐位解包,没有的字段沿用本地旧值。这条优化能把同步流量减少 50-80%。配套纪律:差异超过全量的一半时直接发全量,否则掩码加差异的开销反而更大。

8.3 消息合并 ​

多个小消息合并为一个大消息再发送,可以摊薄每包约 40 字节的 IP+TCP 头部开销:发 10 个 10 字节小消息比发 1 个 100 字节消息多花 360 字节头部开销。

合并的粒度由帧驱动:每个逻辑帧(如 15Hz)结束时,把这一帧内要发给同一玩家的所有消息合成一个包。这也自然地呼应了第 5 章的房间单写者模型——一帧内的状态变化在房间线程里聚合,广播一次成型。注意配合 TCP_NODELAY 使用(见第 5 章):合并解决的是"包太多",Nagle 关闭解决的是"小包被攒着不发",两者一起才构成完整的发送策略。


9. 安全设计 ​

9.1 加密层次 ​

text
┌─────────────────────────────────────┐
│           应用层加密                  │
│   (消息签名、防篡改、字段级加密)       │
├─────────────────────────────────────┤
│           传输层加密                  │
│   (TLS / DTLS)                      │
├─────────────────────────────────────┤
│           网络层加密                  │
│   (IPSec / VPN,机房间链路)           │
└─────────────────────────────────────┘

常见策略:一般游戏用 TLS 传输加密 + 关键操作消息签名;高安全需求(涉及资产)再加应用层 AES。提醒两点:UDP 链路要用 DTLS 或自行实现的加密,TLS 不覆盖它;应用层加密的密钥不能硬编码在客户端里(会被逆向提取),需要会话级密钥协商。

9.2 防重放攻击 ​

重放攻击指攻击者截获合法消息后重复发送——比如截获一个"购买"消息重放 100 次。防御是序列号 + 滑动窗口:

go
type ReplayGuard struct {
    latest uint32   // 已见过的最大序列号
    seen   []uint64 // 位图,记录窗口内的历史序列号
}

const windowBits = 512

func (g *ReplayGuard) Check(seq uint32) bool {
    if seq > g.latest {                       // 新序列号:前移窗口
        shift := seq - g.latest
        if shift >= windowBits {
            g.seen = make([]uint64, windowBits/64) // 窗口整体滑过
        } else {
            for i := uint32(0); i < shift; i++ {   // 滑动并清理
                g.shiftOne()
            }
        }
        g.latest = seq
        g.mark(seq)
        return true
    }
    if seq+windowBits <= g.latest { return false } // 窗口外:拒绝
    return !g.test(seq)                            // 窗口内未见:放行
}

每个连接独立维护一个 guard(登录成功后重置)。序列号同时也承担消息去重——重连恢复会话时用它识别"哪些消息已经处理过"。

9.3 速率限制 ​

令牌桶是防刷的基础设施:每个客户端一个桶,匀速补充令牌,发消息消耗令牌,没令牌就拒绝(或丢弃):

go
type TokenBucket struct {
    tokens float64
    max    float64
    rate   float64 // 每秒补充量
    last   time.Time
}

func (b *TokenBucket) Allow(n int) bool {
    now := time.Now()
    b.tokens = min(b.max, b.tokens+now.Sub(b.last).Seconds()*b.rate)
    b.last = now
    if b.tokens < float64(n) {
        return false            // 触发限流:计入指标,连续超限可踢线
    }
    b.tokens -= float64(n)
    return true
}

分级限流更实用:全局阈值(如 50 msg/s)防脚本刷包,特定操作单独阈值(抽卡 1 次/s、移动 30 包/s)。限流触发要留证据(日志+指标),它是第 18 章反作弊体系的第一道信号源。

9.4 客户端上报只是声明 ​

协议设计里有一条贯穿始终的安全原则:客户端发来的所有数据都只是"声明",服务端负责裁决。移动包声明"我走到了这里"——服务端校验速度是否可能;伤害包声明"我打中了"——服务端按战斗规则重算。协议字段设计时就该想清楚:哪些字段是客户端可以任意伪造的(都要校验),哪些是纯展示性的(无害)。这条原则的完整展开见第 18 章安全与风控。


10. 广播、弱网与真实网络环境 ​

10.1 房间广播的两个放大器 ​

房间制场景和点对点交互不同,天然有两个放大器:

  • 一人异常,影响整房:一个弱网玩家可能拖慢全房同步节奏
  • 同一变化,发给很多人:状态变更的发送成本 = 变更数 × 接收人数

一个玩家弱网时,不只是他自己卡,还可能引发:房间广播积压、同步节奏被拖慢、重发补包成本上升、其他玩家看到该玩家状态异常。所以广播设计和弱网治理需要一起做,而不是各管各的。

10.2 广播优化首先是语义优化 ​

最容易犯的错误是一有状态变化就全量发给所有人。人数不多时还能跑,人数、对象、弱网连接一多立刻暴露。更稳妥的做法:

  • 区分关键与非关键广播:战斗事件必须送达;闲聊表情可丢
  • 允许最新值覆盖旧值:位置包丢了不用补,下一包自然更新
  • 合并、降频、裁剪:远处的玩家 5Hz 同步,视野内 15Hz;视野外的只发存在性
  • 按接收者定制粒度:自己收到全量状态,别人只收到影响自己的部分

核心问题是"什么值得发、发到什么粒度",而不只是"怎么把包压小"。压缩(第 8 节)是最后一层优化,语义裁剪才是第一层。

10.3 国内移动网络的日常现实 ​

设计时直接承认这些现实,而不是把它们当特殊事故:

  • Wi-Fi 与蜂窝切换非常频繁(出门、进电梯)
  • 电梯、地铁、地下车库造成短时断流和大抖动
  • 不同运营商、不同地区链路质量差异明显
  • 切后台、锁屏、来电会打断网络行为
  • 设备性能影响收发节奏和重连速度

落到协议设计的具体要求:重连必须轻量(会话保持 + 增量同步,而不是重走完整登录);心跳间隔兼顾运营商 NAT 超时(通常 30-60s,过短费电费流量,过长被 NAT 掐断);弱网时客户端主动降级(自动降频同步、缓存输入)而不是等待超时。

10.4 P2P 与 NAT 穿越 ​

特性C/S 架构P2P 架构
权威方服务端(单一权威)任一客户端/无权威
延迟双跳(C→S→C)单跳(C↔C)
带宽成本高(服务端转发)低(客户端直连)
反作弊容易(服务端权威)困难(锁定输入同步/回滚裁决)
适用场景MMO、MOBA、竞技 FPS格斗、赛车、休闲对战

大多数现代对战游戏用混合架构:匹配、房间管理、NAT 穿越协调走服务器,对局数据走 P2P 直连,直连失败回退服务器转发。格斗游戏常用的 GGPO 回滚网络栈就是"P2P + 输入延迟 + 确定性回滚"的组合。

NAT 类型决定了两个客户端能否直接打通:

Full ConeRestricted ConePort RestrictedSymmetric
Full Cone✓✓✓✗
Restricted Cone✓✓✗✗
Port Restricted✓✗✗✗
Symmetric✗✗✗✗

常用技术:STUN(发现自己的公网映射)、TURN(中继转发兜底)、ICE(把前面两者编排成完整的连通性检查流程)。工程上的关键指标是直连成功率——手机蜂窝网络的 Symmetric NAT 比例不低,10%-30% 的会话最终要走 TURN 中继,中继服务器的部署成本要提前规划。


11. 协议治理:让契约可执行 ​

11.1 单一事实源 ​

协议定义需要唯一权威来源——通常是 .proto 文件仓库(或等价的 IDL 仓库),由它生成客户端和服务端的编解码代码、消息 ID 常量、错误码表和文档。禁止任何一侧手写消息结构——手写版本的漂移就是"双方对同一条消息理解不一致"的开始。

11.2 协议变更流程 ​

一个可执行的最低配置:

  1. 变更走 MR/PR 评审:协议变更的影响面(哪些模块、哪些版本)由 second reviewer 确认
  2. 编号只分配不回收:废弃消息标记 deprecated 保留定义,编号永不复用
  3. 兼容窗口明确化:新协议上线后旧协议保留多久(一个渠道审核周期 + 一个大版本,通常 4-8 周)
  4. 双跑验证:关键消息新旧格式并行处理一段时间,比对结果一致再切流

11.3 版本协商 ​

客户端登录时上报自己的协议版本,服务端决定:直接服务、降级服务(屏蔽新功能消息)、还是要求强制更新。包头里的 Version 字段就是为此预留的。渠道包审核周期长的项目(iOS 审核一周),协议兼容窗口要按最慢的参与方设计,而不是按自己的发版节奏。


12. 协议设计 Checklist ​

text
□ 1. 消息按语义分类(强控制/强实时/弱实时/外围/大包),分链路处理
□ 2. 消息命名规范统一(模块_动作_方向),ID 数字编码
□ 3. 消息 ID 按模块分段,预留扩展空间,编号永不复用
□ 4. 错误码含模块+类型+编号,客户端处理策略明确
□ 5. 包头包含魔数、版本、Flags、长度、序列号
□ 6. 拆包器处理粘包/半包/错位/超长包
□ 7. 大消息支持分片重组,重组缓冲有超时清理
□ 8. Protobuf 字段演化纪律(reserved/optional/枚举只增)
□ 9. 压缩按消息阈值开关,算法按场景选择
□ 10. 高频同步用 Delta + 位掩码,差异过大回退全量
□ 11. 一帧内消息合并发送,配合 TCP_NODELAY
□ 12. 防重放:序列号 + 滑动窗口,按连接隔离
□ 13. 速率限制:全局 + 分级令牌桶,触发留证据
□ 14. 客户端上报只是声明,服务端裁决
□ 15. 弱网场景:轻量重连、降级同步、心跳匹配 NAT 超时
□ 16. 协议单一事实源 + 代码生成,变更走评审
□ 17. 兼容窗口按最慢参与方设计,关键消息双跑验证

13. 代码示例:一帧消息的完整旅程 ​

前面各节是按设计维度展开的零件——消息 ID、包头、拆包器、防重放、限流。这一节把它们装成一条完整的收包链路,看一帧消息从字节流到业务处理依次经过哪些防线。示例是讲解用的最小实现(省略了超时、指标上报、连接管理等工程细节),但主干与生产实现同构。

包头与整帧编解码 ​

go
// 包头固定 20 字节,与 6.1 的字段表一一对应
const (
    magicNumber = 0x474D // "GM":字节流错位时逐字节重同步的锚点
    headerSize  = 20
)

type Header struct {
    Version uint8  // 协议版本,版本协商用(见 11.3)
    Flags   uint8  // 压缩/加密/分片标记位,接收方按位决定如何解包
    MsgID   uint32 // 模块(8)+动作(16)+类型(8),路由的唯一依据
    SeqNum  uint32 // 防重放与去重,按连接单调递增
}

// 编码:定长包头在前,包体紧随其后
func EncodeFrame(h *Header, body []byte) []byte {
    buf := make([]byte, headerSize+len(body))
    binary.LittleEndian.PutUint16(buf[0:2], magicNumber)
    buf[2] = h.Version
    buf[3] = h.Flags
    binary.LittleEndian.PutUint32(buf[4:8], h.MsgID)
    binary.LittleEndian.PutUint32(buf[8:12], h.SeqNum)
    binary.LittleEndian.PutUint32(buf[12:16], uint32(len(body)))  // 长度上限校验的依据
    binary.LittleEndian.PutUint32(buf[16:20], crc32.ChecksumIEEE(body))
    copy(buf[headerSize:], body)
    return buf
}

字段顺序一旦上线就是契约——这就是 6.1 说包头是"信封"的含义。校验和只覆盖包体:头部出错等价于字节流整体错位,由魔数负责发现和重同步,两道机制各管一层。

接收管线:防线按成本排序 ​

go
// 一帧消息的旅程:拆包产物 → 防重放 → 限流 → 路由 → 按需解压 → 业务
// 防线排序原则:便宜的在前,昂贵的在后,让伪造包在早期就被拦下
func (s *Server) OnPacket(conn *Conn, packet []byte) {
    h, body, err := decodeFrame(packet)      // 1. 魔数/长度/校验(6.2 拆包器的产物)
    if err != nil {
        conn.CloseWithError(err)             // 结构性错误:踢线,不做字节级猜测
        return
    }
    if !conn.replay.Check(h.SeqNum) {        // 2. 防重放(9.2),每个连接独立 guard
        metrics.Inc("replay_dropped")        // 静默丢弃:不回错误包,不向攻击者暴露探测信号
        return
    }
    if !conn.limiter.Allow(1) {              // 3. 令牌桶限流(9.3),全局 + 分操作阈值
        metrics.Inc("rate_limited")          // 触发留证据:限流是风控体系的第一道信号源
        return
    }
    handler := s.route[h.MsgID]              // 4. 一次位运算 + 一次查表(5.2 的路由红利)
    if handler == nil {
        s.replyError(conn, h.MsgID, ErrUnknownMsg)
        return
    }
    if h.Flags&flagCompressed != 0 {         // 5. 按需解压:只有通过前三道防线的包才值得花 CPU
        if body, err = decompress(body); err != nil {
            return
        }
    }
    handler(conn.Session, body)              // 6. 进入业务逻辑,此时消息已可信、有界
}

顺序即设计。防重放是 O(1) 位图操作,令牌桶是 O(1) 浮点运算,都远便宜于解压和业务处理;把解压放在防线之后,伪造流量就无法消耗解压 CPU。6.2 的三个生产级防御(重同步、长度上限、半包等待)都发生在第 1 步之前——拆包器挡住结构攻击,这条管线挡住语义攻击。

分片重组器:超时清理是灵魂 ​

go
type fragmentGroup struct {
    total    int               // 预期分片数,来自首个分片的包头
    received map[int][]byte    // 已收到的分片,按索引存放
    deadline time.Time         // 单组生存期:超时整体丢弃
}

func (r *Reassembler) Accept(f Fragment) []byte {
    g := r.groups[f.Key()]
    if g == nil {
        g = &fragmentGroup{received: make(map[int][]byte), deadline: time.Now().Add(5 * time.Second)}
        r.groups[f.Key()] = g                // 首个分片到达时建组
    }
    g.received[f.Index] = f.Payload
    if len(g.received) < g.total {
        return nil                           // 未收齐,继续等
    }
    delete(r.groups, f.Key())                // 收齐:重组并释放
    return join(g.received)
}

// 后台定时清理:丢包或攻击都会制造永远等不齐的组
func (r *Reassembler) Sweep(now time.Time) {
    for key, g := range r.groups {
        if now.After(g.deadline) {
            delete(r.groups, key)            // 超时丢组:内存防线的最后一环
        }
    }
}

重组器最容易被省略的恰恰是 Sweep——没有它,一个丢在路上的分片就让一组缓冲泄漏到进程重启;被恶意伪造分片 ID 时,它同时是内存防线。这与 6.3 的结论一致:分片功能的一半复杂度在重组的超时治理上。

这三段合起来,正是本章 Checklist 第 5~13 条的可执行形态。真正上线时,拆包、防重放、限流都在接入层完成,业务 Handler 拿到的已经是干净、有界、可信的消息——协议设计的目标,就是让业务代码可以放心地"什么都不防"。


14. 小结 ​

关键问题答案
协议设计的第一个动作是什么?拆消息语义(五类),不是选 TCP 还是 UDP
可靠、顺序、兼容为什么分开谈?三者治理手段完全不同:确认重传 / 序列号窗口 / 字段演化纪律
消息 ID 怎么设计?模块(8)+动作(16)+类型(8) 的 uint32,数字编码支撑高效路由
TCP 粘包怎么解决?长度字段分帧 + 魔数校验 + 长度上限,拆包器四件事缺一不可
Protobuf 兼容的根基?永不复用字段编号(reserved)、新字段 optional、枚举只增
最有效的带宽优化?不是压缩,是语义裁剪:Delta 同步 + 按距离降频 + 丢旧保新
防重放怎么做?序列号 + 滑动窗口位图,按连接隔离
广播的本质问题?"谁应该知道什么变化",先裁剪语义再谈压缩
协议怎么长期维护?单一事实源 + 代码生成 + 变更评审 + 按最慢参与方设计兼容窗口

15. 实战案例:一次战斗中「角色倒着走」的同步回滚排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化。它把 10.1 的广播放大器、10.2 的语义裁剪与 6.3/13 的分片重组超时用到深水区:逐条位置包回答「倒退的每一帧是谁发的」——这是网络同步的消息级排查,也是小结「丢旧保新」四个字的完整展开。

背景与现象:MOBA 新赛季「极速模式」上线 2 天,高端局玩家陆续反馈战斗中角色「倒着走」——前脚已经迈出去,人却被拽回半步,随后再恢复。客诉抽样 5000 局,触发回拉 50 局,回拉对局占比 1%;上版本同口径只有 0.1%,版本后变成 10 倍。回拉不是掉线也不是瞬移,画面全程流畅——三端谁都说自己没问题。

第一步:现象与口径——先把「回拉」定义成一个可对账的事件 ​

回拉判定口径统一为:客户端预测位被服务端权威状态拉回、且偏差超过 2 个身位的视觉纠偏。三口径抽同一批对局:

  • 客户端埋点:50 局触发回拉——现象的唯一记录者
  • 服务端权威位日志:0 处标记异常——序列号连续、无缺口,服务端视角一切正常
  • 观测大盘:RTT 中位 45ms 全绿,但回拉对局的 RTT P95 却有 210ms,比中位高出 4.7 倍——平均口径把事故局藏进了分位(与第 13 讲「监控全绿」同一课)

口径分歧本身就是线索:消息都送达了、顺序也没错,但状态回退了——这排除了可靠性问题(2.3 的三件事),把排查压到「新旧状态怎么用」这一层:不是丢包,是消费纪律。

第二步:分层归因——从链路到广播的消息账本 ​

text
链路层   RTT 中位 45ms、重传率 1.2%——链路健康,排除网络劣化
协议层   回拉帧前后序列号连续、无重传缺口——排除丢包与乱序
消息层   回拉前断流 1.2s 无位置包,恢复后一帧突发 12 条(10Hz × 1.2s)全是断流前旧值
         客户端逐条消费旧值,把角色一步步拖回去——丢旧保新(10.2)未实现
广播层   96 人房间:95 个接收者 × 每秒 10 次 = 950 条/秒/发送者,全房间 9.12 万条/秒
         断流玩家的接收队列被持续灌满,1.2s 断流(10.3 电梯/地铁场景)后积压是必然
特效链路 4200 字节大招特效包按 1400 字节 MTU 分 3 片,2% 丢包下整包收齐率 0.98³ ≈ 94%
         6% 的帧因分片不齐被跳过,下一帧到达时触发第二次纠偏

账本收敛:96 人房间单发送者 950 条/秒、全房间 9.12 万条/秒;断流 1.2 秒突发 12 条旧位置包(10Hz × 1.2 秒);特效包 4200 字节按 1400 字节 MTU 分 3 片,2% 丢包下整包收齐率 94%、缺帧率 6%。

  • 按链路:位置走 UDP/KCP 数据面、事件走 TCP 控制面(4.4 的竞技类组合)——倒退只发生在位置链路
  • 按场景:46 局发生在弱网时段,稳定 Wi-Fi 只复现出 4 局——与 10.3 的断流地图重合

第三步:根因——三件事同时成立,且没有一条契约拦住它们 ​

  1. 直接原因:位置包是「最新值覆盖旧值」语义(10.2),客户端却逐条消费——断流积压的 12 条旧值被当状态一步步应用,角色被拖回;叠加特效分片缺帧 6% 的第二次纠偏,回拉被放大成可见的「倒着走」
  2. 机制原因:广播放大器(10.1)全开——96 人房间按 950 条/秒/发送者全量推送,没有 10.2 的按距离降频(5Hz/15Hz)、没有关键/非关键分级——断流玩家的积压是设计的必然,不是意外
  3. 契约原因:§12 协议设计 Checklist 的 17 条里有分片超时(第 7 条)与防重放(第 12 条),却唯独没有「广播条数预算」和「位置包新旧覆盖纪律」——10.2 的语义裁剪停在建议,不进契约评审就没有门禁。这与第 19 讲「预算表只有内存水位、没有渲染预算」是同一类缺位:安全与格式有账,预算与消费纪律没有账

第四步:处置与回填 ​

处置按「收益 / 成本」排序:

  1. 客户端位置消费加序列号新旧判断,断流恢复只取最新值:回拉 50 局 → 5 局
  2. 特效包按 10.2 分级:关键技能可靠通道走 TCP,非关键特效压到 900 字节单包不分片(< 1400 MTU)
  3. 位置广播按距离分档:视野内 10 人 × 15Hz + 视野外 85 人 × 1Hz = 235 条/秒/发送者,较 950 下降 75%
  4. 积压深度、分片重组丢弃率与回拉率进监控分位(第 13 讲口径)

复核:抽样 5000 局回拉 5 局(0.1%,回到上版本基线,回落 90%);回拉对局 RTT P95 210 → 70ms,回到与中位同量级;位置链路不再依赖分片重组。

回填清单(呼应第 14 讲复盘回填纪律):

回填项归属完成标准
§12 Checklist 增补「广播条数预算」「位置包新旧覆盖纪律」两条,进协议评审协议治理评审通过并跑一轮变更
位置同步验收门禁:回拉率 ≤ 0.1%、按距离分档生效(呼应第 20 讲性能验收门槛)QA/客户端进版本验收清单
监控分位:广播积压条数、分片重组丢弃率、回拉率按 P95 上报(呼应第 13 讲口径)服务端/数据看板上线
协议契约补「广播条数/字节预算」,与安全条目同权管理(呼应第 19 讲预算教训)服务端/协议治理契约评审通过

案例的三个教训 ​

  1. 「送达」和「顺序」都对,状态照样会错。2.3 把可靠、顺序、兼容拆成三件事,这个案例补上第四问:新旧谁说了算——丢旧保新是协议语义,不是客户端实现细节
  2. 平均口径又一次藏了事故。RTT 中位 45ms 配 P95 210ms,第 13 讲「监控全绿」的网络版:分位不进看板,事故就只活在客诉里
  3. 广播成本 = 变更数 × 接收人数(10.1),预算要按房间人数定。950 条/秒不是性能问题,是契约缺了预算条目——与第 19 讲「预算表没有渲染预算」同构

游戏知识体系