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 的组合
二者可以组合:
- 先尝试用 FEC 直接恢复丢包。
- 无法恢复时,再通过 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. 假设与误差
上述估计隐含请求与响应延迟大致对称的假设。现实网络经常不对称,因此单次测量并不可靠。
更稳健的做法是:
- 连续采集多组 RTT 与偏移样本。
- 按延迟排序。
- 丢弃明显偏大的异常样本。
- 对剩余结果取均值或加权平滑。
- 持续校正时钟漂移,而不是只同步一次。
低 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 的路径
典型过程为:
- 客户端调用本地代理。
- 代理为请求分配标识。
- 参数被序列化。
- 数据经过压缩、加密或会话封装。
- 传输层把消息发给服务器。
- 服务器 Stub 解包并反序列化。
- 服务实现执行业务逻辑。
- 返回值沿反方向传回。
- 客户端根据请求标识匹配结果。
真实 RPC 框架还可能处理握手、鉴权、连接池、压缩、加密、重试、会话恢复和负载均衡。
十七、跨平台数据表示
远端程序可能由不同语言、编译器和硬件平台实现。直接发送内存结构会遇到:
- 基础类型长度不同。
- 大端序与小端序不同。
- 浮点表示和精度要求不同。
- 结构体内存对齐不同。
- 指针在另一进程中没有意义。
- 新旧版本字段不一致。
因此,网络协议必须定义稳定的线格式,而不能把本地内存布局当成通信格式。
十八、IDL 与序列化
1. 接口定义语言
IDL,即 Interface Definition Language,用统一描述声明:
- 服务名称。
- 可调用方法。
- 参数与返回值类型。
- 消息字段及编号。
- 可选、必需或重复字段。
工具可根据 IDL 为多种语言生成客户端与服务器代码,使双方共享同一接口契约。
2. ProtoBuf 示例
Protocol Buffers 是常见的 IDL 与序列化方案。字段编号属于协议的一部分;版本演进时应保留旧编号,新增字段时保持向后兼容。
良好的消息设计应考虑:
- 未知字段能否安全忽略。
- 缺失字段是否有默认语义。
- 字段删除后编号是否禁止复用。
- 消息是否具有明确版本。
- 数值范围和单位是否固定。
3. 序列化不是全部
IDL 和序列化只解决“双方如何理解数据”。可靠性、超时、重试、幂等性、鉴权和业务一致性仍需要在 RPC 与业务层分别设计。
十九、网络调用的失败语义
一次远程调用可能出现:
- 请求没有到达服务器。
- 请求到达并执行,但响应丢失。
- 服务器执行到一半崩溃。
- 客户端超时后重试,导致操作重复执行。
- 客户端与服务器使用不兼容版本。
因此,“超时”只表示调用方没有及时拿到结果,并不能证明服务端没有执行。
对扣款、发放物品、创建订单等操作,需要:
- 请求唯一标识。
- 服务端去重。
- 幂等设计。
- 明确的事务边界。
- 可查询的最终状态。
实时 Gameplay 消息则可能选择过期即丢弃,不应机械重试所有请求。
二十、协议设计的核心原则
- 先根据业务语义分类数据,再选择传输方式。
- 区分“必须到达”“必须按序”和“必须及时”,三者不是同一要求。
- 高频状态允许新值覆盖旧值,避免等待过期数据。
- 使用序列号处理重复、乱序和缺包。
- 通过滑动窗口提高链路利用率。
- 用选择重传减少无效带宽。
- 在高 RTT 场景中用 FEC 降低丢包恢复等待。
- 用多次采样估计时钟偏移,不相信单次网络测量。
- 不直接传输本地内存结构,使用明确的线格式。
- 把远程调用视为可能失败的分布式操作。
- 客户端输入必须由权威端验证。
- 协议版本、日志和请求标识应从设计初期纳入系统。
二十一、复习要点
- 网络游戏的核心挑战包括一致性、可靠性、安全性、多样性和系统复杂度。
- IP 定位主机,端口定位服务,Socket 是程序访问网络协议栈的端点接口。
- TCP 提供可靠、有序的字节流,但重传、拥塞控制和队头阻塞可能增加实时延迟。
- UDP 提供轻量数据报,游戏可按消息语义自行选择可靠、顺序和过期策略。
- 可靠 UDP 的基础包括序列号、ACK、NACK、超时、去重、乱序缓冲和滑动窗口。
- Selective Repeat 只重传丢失数据,比整段回退重传更节省带宽。
- FEC 用冗余换取免重传恢复;XOR 简单,Reed-Solomon 能恢复更多丢包。
- NTP 式算法使用四个时间戳估计 RTT 与时钟偏移,并通过多次采样过滤异常。
- RPC 用 Stub 把请求、序列化和传输封装为远程函数调用。
- IDL 定义跨语言接口契约,序列化格式必须独立于本地内存布局。
- 网络超时不等于服务端未执行,关键操作必须考虑去重与幂等。