约 21 分钟技术学习游戏引擎

Games104学习笔记(22)

GAMES104:网络游戏的架构基础(上)

课程:GAMES104《现代游戏引擎:从入门到实践》
第 18 讲(上):网络游戏的架构基础
本地视频时长:约 1 小时 21 分钟

一、本讲概览

单机游戏只需要在一台机器上维护游戏世界;网络游戏则要让分布在不同设备、不同网络和不同时间基准上的参与者,共同看到一个足够一致的世界。

这使网络游戏成为典型的分布式系统问题。数据包可能延迟、丢失、乱序或重复,客户端可能掉线,设备的数据表示也可能不同。网络层只负责传送字节,而游戏需要的是“玩家释放技能”“角色受到伤害”“加入房间”等具有业务意义的操作。

这一讲感觉没怎么听进去

本讲从通信基础出发,依次讨论:

  • 多人在线游戏面临的核心挑战。
  • OSI 分层、IP、端口与 Socket。
  • TCP 与 UDP 的能力和取舍。
  • 基于 UDP 构建可靠、低延迟传输的方法。
  • ARQ、滑动窗口、FEC、XOR 与 Reed-Solomon。
  • 客户端与服务器之间的时钟同步。
  • RPC、Stub、IDL 与跨平台数据序列化。

二、学习目标

  • 理解网络游戏为何不能简单地把单机逻辑“接上网络”。
  • 掌握延迟、抖动、丢包、乱序、重连和一致性的基本含义。
  • 理解 TCP 与 UDP 的差异,以及游戏为何经常混合使用二者。
  • 掌握 ACK、序列号、超时、滑动窗口和选择重传的基本机制。
  • 理解重传与前向纠错解决丢包问题的不同思路。
  • 掌握 NTP 式往返时延与时钟偏移估计。
  • 理解 RPC 如何把底层字节通信抽象为远程函数调用。
  • 认识跨语言、跨平台通信中的序列化、字节序和版本兼容问题。

三、多人在线游戏的五类挑战

1. 一致性

不同设备各自运行游戏逻辑。若输入、执行顺序或状态不同,多个副本会逐渐产生分歧:

  • 同一角色在两台客户端上的位置不同。
  • 一方判定命中,另一方判定未命中。
  • 双方看到的技能释放顺序不同。
  • 随机数或浮点误差使模拟结果分叉。

网络同步的目标不是让所有机器在物理意义上同时发生变化,而是定义谁拥有权威、什么数据需要同步,以及各端如何在延迟存在时逐步收敛到可接受的一致状态。

2. 可靠性

公网通信无法保证数据准时到达。网络游戏必须面对:

  • 往返延迟较高。
  • 延迟不断波动,即抖动。
  • 数据包丢失、重复或乱序。
  • 玩家临时断线后重新连接。

可靠性不只是“消息最终送达”。对实时游戏来说,过期消息即使最终到达,也可能已经没有价值。

3. 安全性

客户端处在用户控制的设备上,不能被服务器完全信任。风险包括:

  • 修改客户端或使用外挂。
  • 伪造请求和重放数据包。
  • 窃取账号与会话。
  • 读取本不应暴露的信息。
  • 利用协议或服务漏洞获利。

安全边界必须建立在权威端验证之上,而不能只依赖客户端自觉遵守规则。

4. 多样性

现代网络游戏需要支持:

  • PC、主机和移动端跨平台游玩。
  • 快速迭代与新旧版本过渡。
  • 不同类型游戏的通信需求。
  • 匹配、聊天、邮件、排行榜、交易等多个子系统。

不存在适合所有业务的单一协议或同步方案。架构必须允许不同数据采用不同可靠性、频率和安全策略。

5. 复杂性

大规模在线服务还需要满足:

  • 高并发。
  • 高可用。
  • 高吞吐和低响应时间。
  • 服务扩缩容与故障恢复。
  • 分布式服务之间的协调。

因此,游戏网络不仅是客户端与战斗服务器之间的连接,还包括账户、匹配、社交、存储和运营等完整服务体系。

四、分层理解网络通信

1. OSI 模型

OSI 模型把通信问题分成七层:

层级 主要职责
应用层 为用户或业务提供网络功能
表示层 数据格式转换、编码、压缩和加密
会话层 建立、维护和恢复通信会话
传输层 提供端到端传输
网络层 在多个网络节点间路由数据包
数据链路层 在相邻节点间传输帧
物理层 用电信号、光信号或无线信号传送比特

分层的价值在于隔离职责。Gameplay 程序通常关心消息和远程操作,不必直接处理网卡如何发送电信号;传输层也不需要理解“释放技能”代表什么。

2. 封装与解封装

发送端的数据向下经过各层时,每层都会附加自己的控制信息;接收端再逐层解析:

$$ \text{Application Data} \rightarrow \text{Segment} \rightarrow \text{Packet} \rightarrow \text{Frame} \rightarrow \text{Bits} $$

每一层都把上一层的数据视为载荷。这种结构使协议能够独立演化,也带来了额外头部和处理开销。

五、IP、端口与 Socket

1. 通信端点

IP 地址用于定位网络中的主机,端口号用于定位主机中的具体网络服务。二者组合成一个通信端点:

$$ \text{Endpoint} = (\text{IP Address}, \text{Port}) $$

同一台服务器可以同时运行登录服、战斗服、聊天服等多个服务,只要它们监听不同端口。

2. Socket

Socket 是程序使用网络协议栈的接口,可以理解为发送和接收数据的端点对象。应用程序通过 Socket 完成:

  • 创建通信端点。
  • 绑定本地地址。
  • 监听或连接远端。
  • 发送与接收字节。
  • 关闭连接。

Socket 解决的是“如何收发字节”,并不会自动解决消息边界、业务类型、序列化、重连和游戏状态同步。

六、TCP

1. TCP 提供的能力

TCP 是面向连接的字节流协议,主要特点包括:

  • 可靠传输。
  • 按序交付。
  • 流量控制。
  • 拥塞控制。
  • 连接状态管理。

发送方为数据编号,接收方通过确认信息告知已收到的范围;丢失的数据会被重传,最终向应用层提供有序字节流。

2. 确认与重传

TCP 可根据确认号判断接收进度。当某个数据段丢失时,重复 ACK 或超时可以触发重传。

设发送序列为 $1,2,3,4,5,6,\ldots$,若编号 5 的数据丢失,而后续数据已经到达,接收方仍会重复确认前一个连续位置。发送方据此判断中间可能发生丢包,并提前重传。

3. 流量控制

流量控制限制发送速度,避免发送方持续向处理能力较弱的接收方灌入数据。接收窗口反映接收端当前还能容纳多少未处理数据。

4. 拥塞控制

拥塞控制面向整个网络。当丢包或超时被视为拥塞信号时,TCP 会缩小拥塞窗口,再逐步增加发送量。

这种机制有助于避免互联网发生拥塞崩溃,但窗口下降也可能造成吞吐变化、延迟上升和抖动。对强实时游戏来说,稳定及时往往比把每个旧数据包都可靠送达更重要。

5. 队头阻塞

TCP 保证按序交付。若前面的数据丢失,即使后续数据已经到达,应用层也可能必须等待缺失部分重传完成。

这称为队头阻塞。对于“最新状态可以替代旧状态”的实时数据,等待旧包会增加无效延迟。

6. TCP 适合什么

TCP 适合必须完整、按序到达的数据,例如:

  • 登录与鉴权。
  • 交易和结算。
  • 聊天文本。
  • 配置与资源清单。
  • 回合制命令。
  • 对实时性要求较低的业务请求。

TCP 并非“不能用于游戏”,而是其可靠有序语义不一定适合所有实时数据。

七、UDP

1. UDP 的特点

UDP 是无连接的数据报协议:

  • 不自动建立可靠连接。
  • 不保证到达。
  • 不保证顺序。
  • 不自动重传。
  • 不提供 TCP 式流量和拥塞控制。
  • 保留数据报边界。
  • 协议头和状态较轻。

UDP 本身不是“必然更快”,而是把可靠性、顺序和时效策略交给应用决定。它减少了协议层强制等待,为游戏提供了更灵活的控制空间。

2. UDP 适合什么

UDP 适合频率高、时效性强、旧数据可以被新数据取代的内容,例如:

  • 角色位置和朝向。
  • 瞄准方向。
  • 短周期状态快照。
  • 语音或实时媒体。
  • 高频输入采样。

但关键事件仍可能需要可靠送达,因此很多游戏会在 UDP 之上实现选择性可靠机制。

八、为什么游戏需要定制传输协议

游戏服务器同时存在不同语义的数据:

数据类型 更关注的属性
登录、支付、物品变化 可靠、按序、不可重复执行
移动与视角 低延迟、允许丢弃旧值
技能释放与受击 可靠、较低延迟、可去重
世界快照 高频、可被新快照覆盖
聊天与社交 可靠、吞吐优先于帧级延迟

只使用一种固定协议,很难同时满足这些需求。常见设计是:

  • TCP 与 UDP 并用。
  • 在 UDP 上建立可靠与不可靠两类逻辑信道。
  • 为不同消息设置独立的优先级、顺序和重传策略。

协议设计的核心不是“TCP 还是 UDP”这一道单选题,而是明确每类数据允许丢失吗、允许乱序吗、多久后失去价值,以及是否可以由新数据替代。

九、可靠 UDP 的基本构件

1. 序列号

每个数据包携带序列号,接收方可据此判断:

  • 数据包是否重复。
  • 是否发生乱序。
  • 中间是否缺包。
  • 哪些数据已经确认。

序列号通常循环使用,因此比较时还要正确处理回绕。

2. ACK 与 NACK

  • ACK 表示某个包或某段范围已经收到。
  • NACK 表示某个包缺失或不可用。

ACK 让发送方释放已确认缓存;NACK 可以触发快速重传,但若 NACK 自身丢失,系统仍需要超时机制兜底。

3. 超时与 RTO

发送方保存待确认数据。如果在重传超时时间 RTO 内未收到确认,就重新发送。

RTO 太短会因正常抖动产生大量误重传;RTO 太长则会延迟恢复。实际系统通常根据近期 RTT 及其波动动态估计。

4. 去重与乱序缓冲

重传可能让同一消息到达多次,因此接收方必须去重。若业务要求按序执行,还要暂存提前到达的后续消息,等待缺失序列补齐。

对于允许新值覆盖旧值的数据,可以直接丢弃过期包,避免无意义的等待。

十、滑动窗口与 ARQ

1. 停等协议的问题

若每发送一个包都必须等待 ACK 后才能继续,链路吞吐会受 RTT 严重限制。数据包发送很快,但大部分时间都浪费在等待上。

2. 滑动窗口

滑动窗口允许发送方在未收到确认前连续发送多个包:

  • 已确认区域可以从缓存移除。
  • 已发送未确认区域等待 ACK 或重传。
  • 窗口之外的数据暂时不能发送。

若窗口大小为 $W$,忽略包大小差异时,窗口允许的在途包数量最多约为 $W$。吞吐上限与窗口和 RTT 的关系可粗略理解为:

$$ \text{Throughput} \approx \frac{\text{Window Size}}{\text{RTT}} $$

窗口过小无法充分利用链路;窗口过大则会增加缓存、重传压力和突发流量。

3. 自动重传请求

ARQ,即 Automatic Repeat reQuest,通过确认和重传恢复丢失数据。

常见策略包括:

策略 做法 特点
Stop-and-Wait 每次只发送一个包 简单,但吞吐低
Go-Back-N 丢包后从缺失位置起重发一段 实现较简单,可能重复发送大量数据
Selective Repeat 只重传丢失或损坏的包 带宽利用率高,状态管理更复杂

课程强调选择重传更适合游戏的可靠 UDP:接收方分别确认数据包,发送方分别维护超时,只恢复真正丢失的内容。

十一、前向纠错

1. 重传之外的另一条路线

ARQ 发现丢包后还要等待重传,至少会增加一次网络往返。FEC,即 Forward Error Correction,则在发送原始数据时一并加入冗余数据,使接收方在部分包丢失时直接恢复。

FEC 的代价是:

  • 主动增加带宽。
  • 增加编码和解码计算。
  • 需要按数据块组织原始包与校验包。

它的收益是在可恢复范围内不必等待重传,适合 RTT 较大、丢包存在且低延迟重要的场景。

2. XOR-FEC

假设有四个原始包 $A,B,C,D$,生成一个校验包:

$$ E = A \oplus B \oplus C \oplus D $$

若 $C$ 丢失,可由其余数据恢复:

$$ C = A \oplus B \oplus D \oplus E $$

XOR-FEC 简单高效,但在一个编码组内通常只能恢复一个丢失包。若同时丢失多个包,就没有足够信息还原。

3. Reed-Solomon

Reed-Solomon 将 $N$ 个原始数据包编码成 $N+M$ 个包,其中 $M$ 个是冗余包。

可将原始数据写成向量:

$$ D = \begin{bmatrix} D_1 & D_2 & \cdots & D_N \end{bmatrix}^{\mathsf T} $$

通过编码矩阵 $B$ 得到:

$$ G = BD $$

$B$ 的上半部分可视为单位矩阵,用来保留原始数据;下半部分生成冗余数据。只要收到的包对应的 $N$ 行能组成可逆矩阵 $B’$,就可以用:

$$ D = (B’)^{-1}G’ $$

恢复原始数据。概念上,$M$ 个冗余包可用于抵抗一定数量的丢包;冗余越多,恢复能力越强,带宽和计算成本也越高。

4. ARQ 与 FEC 的组合

二者可以组合:

  1. 先尝试用 FEC 直接恢复丢包。
  2. 无法恢复时,再通过 ARQ 选择性重传。

这种混合方式用额外带宽换取较低的平均恢复延迟,同时保留最终可靠性。

十二、定制 UDP 协议的设计目标

课程总结的定制方向包括:

  • 使用 Selective Repeat ARQ 实现选择性可靠。
  • 更快地估计 RTO 和触发重传。
  • 避免不必要的延迟确认。
  • 同时支持可靠与不可靠消息。
  • 用 FEC 在重传前恢复一部分丢包。
  • 根据游戏语义决定顺序与过期策略。

还应注意:即使不采用 TCP 的拥塞控制实现,公网协议仍需要合理响应拥塞,避免无限制发送影响网络公平性和自身稳定性。

十三、为什么需要时钟同步

不同设备的本地时钟并不完全一致,还会以不同速度漂移。网络延迟又使“收到服务器时间后直接设置”产生误差。

游戏需要统一时间概念来处理:

  • 事件时间戳。
  • 输入和状态的先后顺序。
  • 插值与回放。
  • 技能冷却。
  • 超时和重连。
  • 日志与问题追踪。

通常不应频繁强行修改系统时钟,而是维护“本地时钟到服务器时钟”的偏移估计。

十四、NTP 式时钟偏移估计

1. 四个时间戳

一次同步记录四个时间:

符号 含义
$t_0^c$ 客户端发送请求
$t_1^s$ 服务器收到请求
$t_2^s$ 服务器发送响应
$t_3^c$ 客户端收到响应

上标 $c$ 表示客户端时钟,上标 $s$ 表示服务器时钟。

2. 往返时延

客户端观察到的总往返时间是 $t_3^c-t_0^c$,其中还包含服务器处理请求的时间 $t_2^s-t_1^s$。因此网络往返时延估计为:

$$ \delta

(t_3^c-t_0^c)

(t_2^s-t_1^s) $$

3. 时钟偏移

假设请求与响应的单向延迟近似对称,客户端相对服务器的时钟偏移可估计为:

$$ \theta

\frac{ (t_1^s-t_0^c) + (t_2^s-t_3^c) }{2} $$

客户端可用下式估计服务器时间:

$$ t_{\text{server}} \approx t_{\text{client}}+\theta $$

4. 假设与误差

上述估计隐含请求与响应延迟大致对称的假设。现实网络经常不对称,因此单次测量并不可靠。

更稳健的做法是:

  1. 连续采集多组 RTT 与偏移样本。
  2. 按延迟排序。
  3. 丢弃明显偏大的异常样本。
  4. 对剩余结果取均值或加权平滑。
  5. 持续校正时钟漂移,而不是只同步一次。

低 RTT 样本通常更接近真实偏移,因为它包含的排队延迟更少。

十五、从 Socket 到 RPC

1. 直接使用 Socket 的问题

Socket 只提供字节收发,业务程序仍需反复处理:

  • 如何区分同一连接上的不同请求。
  • 如何定义消息长度和边界。
  • 如何将对象编码成字节。
  • 如何匹配请求与响应。
  • 如何处理超时、错误和重连。
  • 如何支持不同语言和平台。

如果每个业务模块都自行处理这些问题,代码会大量重复,并容易形成不兼容协议。

2. RPC 的目标

RPC,即 Remote Procedure Call,希望让远程调用在使用方式上接近本地函数:

$$ \text{response}

\operatorname{RemoteCall}(\text{request}) $$

调用方关心服务名、方法名、参数和返回值;RPC 框架负责把调用转换为网络消息。

远程调用仍然不是本地调用。它可能超时、重复执行、部分失败或连接中断,因此业务必须显式处理失败语义。

十六、RPC Stub 与调用过程

1. Stub

客户端 Stub 对调用方表现得像一个本地函数,实际负责:

  • 构造请求。
  • 序列化参数。
  • 发送网络消息。
  • 等待并解析响应。

服务器 Stub 对服务实现表现得像普通调用方,实际负责:

  • 接收消息。
  • 反序列化参数。
  • 调用真正的服务函数。
  • 序列化结果并返回。

2. 一次 RPC 的路径

典型过程为:

  1. 客户端调用本地代理。
  2. 代理为请求分配标识。
  3. 参数被序列化。
  4. 数据经过压缩、加密或会话封装。
  5. 传输层把消息发给服务器。
  6. 服务器 Stub 解包并反序列化。
  7. 服务实现执行业务逻辑。
  8. 返回值沿反方向传回。
  9. 客户端根据请求标识匹配结果。

真实 RPC 框架还可能处理握手、鉴权、连接池、压缩、加密、重试、会话恢复和负载均衡。

十七、跨平台数据表示

远端程序可能由不同语言、编译器和硬件平台实现。直接发送内存结构会遇到:

  • 基础类型长度不同。
  • 大端序与小端序不同。
  • 浮点表示和精度要求不同。
  • 结构体内存对齐不同。
  • 指针在另一进程中没有意义。
  • 新旧版本字段不一致。

因此,网络协议必须定义稳定的线格式,而不能把本地内存布局当成通信格式。

十八、IDL 与序列化

1. 接口定义语言

IDL,即 Interface Definition Language,用统一描述声明:

  • 服务名称。
  • 可调用方法。
  • 参数与返回值类型。
  • 消息字段及编号。
  • 可选、必需或重复字段。

工具可根据 IDL 为多种语言生成客户端与服务器代码,使双方共享同一接口契约。

2. ProtoBuf 示例

Protocol Buffers 是常见的 IDL 与序列化方案。字段编号属于协议的一部分;版本演进时应保留旧编号,新增字段时保持向后兼容。

良好的消息设计应考虑:

  • 未知字段能否安全忽略。
  • 缺失字段是否有默认语义。
  • 字段删除后编号是否禁止复用。
  • 消息是否具有明确版本。
  • 数值范围和单位是否固定。

3. 序列化不是全部

IDL 和序列化只解决“双方如何理解数据”。可靠性、超时、重试、幂等性、鉴权和业务一致性仍需要在 RPC 与业务层分别设计。

十九、网络调用的失败语义

一次远程调用可能出现:

  • 请求没有到达服务器。
  • 请求到达并执行,但响应丢失。
  • 服务器执行到一半崩溃。
  • 客户端超时后重试,导致操作重复执行。
  • 客户端与服务器使用不兼容版本。

因此,“超时”只表示调用方没有及时拿到结果,并不能证明服务端没有执行。

对扣款、发放物品、创建订单等操作,需要:

  • 请求唯一标识。
  • 服务端去重。
  • 幂等设计。
  • 明确的事务边界。
  • 可查询的最终状态。

实时 Gameplay 消息则可能选择过期即丢弃,不应机械重试所有请求。

二十、协议设计的核心原则

  1. 先根据业务语义分类数据,再选择传输方式。
  2. 区分“必须到达”“必须按序”和“必须及时”,三者不是同一要求。
  3. 高频状态允许新值覆盖旧值,避免等待过期数据。
  4. 使用序列号处理重复、乱序和缺包。
  5. 通过滑动窗口提高链路利用率。
  6. 用选择重传减少无效带宽。
  7. 在高 RTT 场景中用 FEC 降低丢包恢复等待。
  8. 用多次采样估计时钟偏移,不相信单次网络测量。
  9. 不直接传输本地内存结构,使用明确的线格式。
  10. 把远程调用视为可能失败的分布式操作。
  11. 客户端输入必须由权威端验证。
  12. 协议版本、日志和请求标识应从设计初期纳入系统。

二十一、复习要点

  • 网络游戏的核心挑战包括一致性、可靠性、安全性、多样性和系统复杂度。
  • IP 定位主机,端口定位服务,Socket 是程序访问网络协议栈的端点接口。
  • TCP 提供可靠、有序的字节流,但重传、拥塞控制和队头阻塞可能增加实时延迟。
  • UDP 提供轻量数据报,游戏可按消息语义自行选择可靠、顺序和过期策略。
  • 可靠 UDP 的基础包括序列号、ACK、NACK、超时、去重、乱序缓冲和滑动窗口。
  • Selective Repeat 只重传丢失数据,比整段回退重传更节省带宽。
  • FEC 用冗余换取免重传恢复;XOR 简单,Reed-Solomon 能恢复更多丢包。
  • NTP 式算法使用四个时间戳估计 RTT 与时钟偏移,并通过多次采样过滤异常。
  • RPC 用 Stub 把请求、序列化和传输封装为远程函数调用。
  • IDL 定义跨语言接口契约,序列化格式必须独立于本地内存布局。
  • 网络超时不等于服务端未执行,关键操作必须考虑去重与幂等。

二十二、参考资料