18.网络游戏的架构基础

18.网络游戏的架构基础


18.1 前言

网络游戏可以理解为把”单机体验”扩展为”多人共享体验”的工程体系。相较于单机游戏只需要维护单一视角下的世界状态,网络游戏需要在分布式网络环境中,让不同终端上的玩家共同参与同一套规则下的交互,并在可接受的体验成本内维持世界的可信度与一致性。

在线世界的核心矛盾

多人在线世界的难点并不在于“把画面传过去”,而在于“让多个客户端各自看到的世界足够接近”。当某个事件在一个客户端已经发生,而另一个客户端由于延迟或丢包仍停留在旧状态时,玩家会直观感受到世界的不可信,进而破坏互动的公平性与沉浸感。

因此网络游戏架构的第一目标是建立一套稳定的信息交换与状态收敛机制,使不同终端对同一世界的理解在规则上可解释、在体验上可接受。

多人在线游戏面临的挑战

一致性

多人交互首先需要解决一致性问题。不同客户端对同一战斗、同一位置、同一物体状态的理解必须在规则意义上收敛,否则就会出现”同一时刻不同世界”的差异化行为。网络同步机制的核心任务是控制这种差异,并在必要时进行校正与回滚。

可靠性

在线环境不仅存在延迟,还存在抖动、拥塞、丢包、断开与重连等不可靠因素。架构需要明确”在不可靠传输上构建可靠体验”的策略,例如连接管理、重传与冗余、断线重连后的状态恢复,以及在网络不稳定时的降级与容错。

安全

网络游戏天然包含经济与对抗要素,作弊、信息泄露、账号盗用与非法获利会直接破坏公平性与商业可持续性。架构层面需要明确客户端与服务器的信任边界,限制敏感信息暴露,提供反作弊与审计能力,并为账号体系与交易系统提供安全防护。

多样性

在线游戏往往需要面对多平台与多终端生态,并且包含大量子系统,例如聊天、匹配、交易、排行榜等。与此同时,在线运营要求快速迭代与热更新,架构必须支持版本演进与兼容策略,避免系统升级对在线体验造成不可控冲击。

复杂性

当参与人数从两人扩展到数百、数千甚至更大规模时,连接数量、状态传播与服务器负载会迅速放大。为了支撑高并发、高可用与高性能,服务端架构通常需要引入分布式与服务化设计,并配合监控、限流、容灾与快速恢复等工程能力。

本章内容与后续章节

本章首先覆盖网络游戏架构的基础构件,包括网络协议、时钟与调用机制、网络拓扑,以及三种主流同步模式的基本概念。后续章节会进一步进入更贴近体验的主题,例如角色移动同步、命中判定与延迟补偿,并拓展到 MMO 服务器架构、AOI、反作弊与未来方向。


18.2 网络协议

在介绍网络游戏之前,先把网络协议(Network Protocols)这套基础设施梳理清楚:它解决的是两台机器如何在不可靠的物理世界里,交换可解释的数据

TCP/IP协议

互联网并不是某一根网线,而是一套能把“异构网络”连成整体的规则集合。TCP/IP 的意义在于:它把“跨电话线、卫星、无线、不同网络”的通信,抽象为一套统一的寻址、分包与传输机制。

图中这段早期互联实验,本质是在验证一个事实:只要协议一致,底层链路可以被替换,而上层应用可以保持稳定。

两台 PC 之间最原始的通信,只能“传信号”。但要让信号变成信息,至少需要在多个层面达成一致:

  • 比特的物理表示:0/1 用什么电平/电压/调制方式表达。
  • 边界:接收方如何知道“这一段比特是一条消息”,以及消息何时结束。
  • 编码与语义:一个数字有多少位,按什么字节序解释,如何描述结构化数据。

这些规则合起来,才形成“协议”。

当应用协议越来越多(HTTP/SSH/FTP…),底层介质也越来越多(同轴/光纤/Wi‑Fi…)时,如果每一种“应用 × 介质”都单独适配,工程复杂度会指数增长。

这也是“协议分层”最直接的工程动机:把变化隔离在某一层内。

分层架构

通过引入中间层(Intermediate layers),应用侧只需要面对一套稳定接口;介质侧的变化(光纤换 Wi‑Fi、换 5G…)被封装在更底层。

这类分层思想在工程上非常通用:上层关注业务语义,下层负责把语义可靠地“落地到比特流”。

OSI模型

OSI 模型(OSI model)把通信过程拆成 7 层,目的是让职责边界清晰可控:越往下越贴近硬件,越往上越贴近应用语义。

在游戏开发里,通常不需要逐层实现,但理解这套分层很有价值:很多“延迟/丢包/抖动”问题,最终都能映射回某一层的约束与策略。

Socket

实际工程很少逐层实现 OSI 的每一层。更常见的做法是从 套接字(Socket)开始:

  • Socket 是通信端点的抽象。
  • 端点由 IP 地址(IP Address)与端口号(Port)定位。
  • 上层进程把数据写入 Socket,下层协议栈负责把数据发到对端进程。

在网络编程语境里,Socket 可以理解为“进程对外的通信口”。应用层只需要面向 Socket 读写数据,传输层及以下的复杂性由协议栈承担。

在建立 Socket 时,还需要明确一些关键选择:

  • IPv4(IPv4) vs IPv6(IPv6)
  • TCP(Transmission Control Protocol) vs UDP(User Datagram Protocol)

Socket 的定位方式也很直观:IP 地址(IP Address)与端口号(Port)共同确定一个通信端点。

其中 IPv6 在部分地区已经是主流部署形态,面向全球的在线服务通常需要具备 IPv6 能力。

建立 Socket 时常见的关键参数包括:地址族(IPv4/IPv6)、类型(TCP/UDP)与协议号等。

TCP

TCP 的核心目标是:在不可靠链路上提供可靠、有序的字节流。

它典型具备以下特性:

  • 面向连接:通信前需要建立连接。
  • 可靠有序:通过序列号、确认应答等机制保证数据按序到达。
  • 流量控制 / 拥塞控制:根据网络状况调节发送速率,避免把网络“挤爆”。

TCP 的底层关键机制之一是重传(Retransmission):接收端收到数据后发送 确认应答(ACK);发送端在未收到 ACK 时,会触发超时重传或快速重传。

拥塞控制会让发送窗口(CWND)在“探测带宽”与“拥塞退让”之间震荡:网络稳定时窗口增大,出现丢包/拥塞时窗口回退。

这对互联网是必要的自我保护机制,但对“强实时交互”会带来明显副作用:吞吐波动与额外延迟

UDP

UDP 的设计目标更单纯:提供一个尽量薄的端到端数据报(Datagram)通道。

它的典型特性是:

  • 无连接:不需要建立长连接即可发送数据。
  • 不保证可靠与顺序:丢包、乱序需要上层自行处理(或接受)。
  • 头部更小:UDP 头部固定 8 字节,相对 TCP 更轻量。

网络协议选择

网络游戏并不是“二选一”,而是“按系统选型”:

  • 对可靠一致性更敏感:登录、支付、背包、邮件、匹配等偏“事务”的系统,往往更偏向可靠传输(例如基于 TCP,或基于可靠 UDP 的通道)。
  • 对时效与响应更敏感:移动、瞄准、开火、视角等高频交互,更偏向低延迟通道(例如 UDP)。

大型在线游戏通常是“多通道并存”:不同消息类别走不同通道,并且在必要时混用可靠与不可靠语义。

TCP 的问题不在“对”,而在“代价”:它通过可靠机制换来确定性,但这份确定性对强实时场景未必划算。

UDP 的问题也不在“快”,而在“缺省不可靠”:不补齐可靠性,系统就难以建立一致的世界状态。

可靠 UDP

真实游戏网络的诉求往往同时存在:

  • 可靠性:关键消息不能丢(例如技能释放、道具结算)。
  • 时效性:某些消息宁可丢,也不能“等到过期才到”(例如高频移动输入)。
  • 灵活性:同一条连接上希望同时承载“可靠消息”和“不可靠消息”。

这会推动一种常见工程路径:在 UDP 之上自定义一层“传输语义”,构造可靠 UDP(Reliable UDP)。

要在 UDP 上构建可靠传输,最基础的三个构件是:

  • 确认应答(ACK)/ 否定确认(NACK)
  • 序列号(SEQ)
  • 超时(Timeout / RTO)

自动重传请求(ARQ)

ARQ(Automatic Repeat reQuest)是一类基于 ACK/NACK 的错误控制机制:丢了就补,坏了就重传。

滑动窗口协议(Sliding Window)

滑动窗口的核心是“批量发送 + 批量确认”:发送端按窗口大小连续发送;收到累计确认后窗口前移,从而尽量吃满带宽。

停止等待(Stop-and-Wait)

窗口大小为 1:发 1 个包就等待 ACK。实现简单,但对高延迟网络的带宽利用率很低。

回退 N 帧(Go-Back-N)

当出现丢包且超时未确认时,重传“窗口内的一段包”。实现简单、状态少,代价是可能重传一些其实已经到达的包。

选择性重传(Selective Repeat)

只重传丢失/损坏的包。带宽利用率更高,但需要维护更复杂的接收缓存、以及更精细的确认信息(例如 NACK)。

FEC:用冗余换时延(Forward Error Correction)

仅靠 ARQ 的问题在于:一旦网络波动,重传链路会把延迟拉长;对强实时体验而言,这种“等 ACK 再补发”的节奏并不友好。

FEC(Forward Error Correction)的思路是“多发一点冗余,让接收端在一定丢包率下直接恢复数据”,用带宽换时延

前向纠错(FEC)的基本形态

发送端在原始数据包之外附带冗余包;接收端即使少收到部分包,也能在本地重建缺失内容,从而减少重传频率。

FEC 的代价也很明确:丢包率很低时,冗余包可能变成“额外开销”;丢包率较高时,FEC 的收益更明显。

XOR‑FEC:用异或做最小成本的纠错

XOR‑FEC 的思想是:对一组数据包做异或,生成一个冗余包;当且仅当“该组内只丢了 1 个包”时,可以用异或恢复它。

XOR‑FEC 的边界条件很清晰:一组里如果同时丢了 2 个及以上的包,就无法恢复,只能回退到 ARQ 重传。

里德‑所罗门码(Reed‑Solomon)

Reed‑Solomon codes 的工程直觉是:把原始数据视为向量,通过一个可逆构造得到冗余编码;只要“收到的包数量 ≥ 原始数据包数量”,就可以通过求逆恢复原始数据。

丢包后,相当于“删掉了若干行观测”,只要剩下的编码矩阵仍可逆,就能解出原始数据。

恢复过程可以视为:对删行后的矩阵求逆,再乘以已接收的数据向量,从而重建缺失数据。

小结:可靠语义、纠错与分通道

网络游戏的底层传输往往不是“选 TCP 或 UDP”,而是“在 UDP 上定制一层可配置的传输语义”:

  • 可靠性:用 ARQ(ACK/SEQ/超时/窗口)兜底。
  • 时效性:把消息按“可靠/不可靠、可丢/不可丢、顺序敏感/不敏感”分通道。
  • 抗丢包:用 FEC 在可控冗余下减少重传与抖动。

当这些策略组合起来,传输层才能同时满足“可用性、实时性与可控一致性”三者的平衡。


18.3 时钟同步

在开始设计对战游戏的同步与判定之前,有一件事必须先落地:让客户端与服务器对同一条时间轴“达成共识”。否则同一句话都说不清——玩家 A 说“13 秒 05 我开火”,玩家 B 说“13 秒 06 我闪避”,这次到底算命中还是躲开,结论会因为各自的时钟偏差而变得不可解释。

往返时间(RTT)

对时离不开对网络延迟的量化。最常用的指标是 往返时间(RTT):客户端发出请求,到收到服务器响应所经历的时间。

图里也顺带点出了 RTT 和 Ping 的关系:Ping 更偏底层(常见是基于 ICMP 的探测),而 RTT 往往由应用层自己测出来(例如游戏客户端发包、服务器回包)。工程上两者都在描述“延迟”,但对游戏更直接好用的是 RTT。

网络时间协议(NTP)

网络时间协议(NTP)的目标是让网络中的机器时钟尽量同步。它不仅服务于网络游戏,GPS、金融交易、分布式系统等场景都依赖“可用的对时能力”。

NTP 的核心直觉是:虽然信息传输不能超过光速,绝对同步做不到,但我们可以用 RTT 去估计“路上的时间”,从而逼近两台机器之间的时钟偏差

时间服务器层级

真实世界的对时通常是分层的:最上层是参考时钟(例如原子钟、GPS 等),下层时间服务器逐级向外提供校时服务。层级太深时误差会逐渐积累,因此工程上会倾向选择“层数更小”的时间源。

对网络游戏而言,通常不需要完整的多层体系:很多项目只需要做到“客户端与服务器对齐”,也就是让客户端跟随服务器时间形成稳定的相对参考系。

NTP算法

NTP 的基础形态非常简单:一次请求-响应里,我们记录四个时间戳(客户端两次、服务器两次):

  • (t_0^c):客户端发出请求的本地时间
  • (t_1^s):服务器收到请求的时间
  • (t_2^s):服务器发出响应的时间
  • (t_3^c):客户端收到响应的本地时间

在“上行/下行延迟近似对称”的假设下,可以得到一个常用的时钟偏移(offset)估计:

\[
\text{offset} = \frac{(t_1^s - t_0^c) + (t_2^s - t_3^c)}{2}
\]

它表达的意思很直观:把两条路径(去程、回程)的时间差平均一下,用来估计客户端时钟相对服务器的偏移量。

如果配合一个具体的数字例子,上面的公式会更容易落地:当你发现“服务器给出的时间似乎发生在未来”,那不是时空穿越,通常就是双方时钟不一致,需要把客户端时间向前或向后调整。

把图里的数字按“时间戳”代回去,其实就是一步步把“路上的耗时”和“时钟偏差”拆开:

  • 四个时间戳
    • (t_0^c=17{:}01{:}00):客户端发出请求
    • (t_1^s=17{:}01{:}32):服务器收到请求
    • (t_2^s=17{:}01{:}33):服务器发出响应
    • (t_3^c=17{:}01{:}05):客户端收到响应
  • 服务器处理时间:服务器从收到到发出,用了 (t_2^s-t_1^s=1s)。
  • 往返链路耗时(扣掉服务器处理):

\[
\text{RTT}=(t_3^c-t_0^c)-(t_2^s-t_1^s)=(05-00)-(33-32)=4s
\]

这 4 秒更接近“网络在路上的真实往返时间”,而不是把服务器处理也算进去的总耗时。

  • 时钟偏移:再用 offset 公式估计客户端相对服务器的偏移:

\[
\text{offset}=\frac{(t_1^s-t_0^c)+(t_2^s-t_3^c)}{2}
=\frac{(32-00)+(33-05)}{2}=30s
\]

它的含义是:客户端时钟比服务器慢了约 30 秒

  • 怎么用这个 offset 校正:在 (t_3^c) 这个时刻,客户端本地显示是 17:01:05;加上 offset 后,校正到服务器时间轴上就是 17:01:35:
    • (t_3^c+\text{offset}=17{:}01{:}05+30s=17{:}01{:}35)

这个例子也顺带揭示了 NTP 的隐含假设:把 RTT 平分到去程/回程,默认上下行延迟在统计上近似对称;一旦网络抖动或上下行不对称,单次计算就可能不稳定,所以后面才需要“多次采样 + 过滤”。

对时采样与滤波

基础 NTP 有一个危险前提:默认网络稳定且上下行延迟对称。但在现实网络里,上下行带宽与排队情况经常不对称,抖动也无法避免。

因此工程上更常见的做法是:多做几次采样,得到一组 RTT/offset,再用统计规则剔除“明显不可信”的样本,最后用剩余样本的平均结果来更新本地时钟。

一种简单有效的策略是:反复进行 5 次或更多次对时,按 RTT 从小到大排序,剔除 RTT 明显偏大的样本(例如超过中位数约 1.5 倍),然后对剩余样本取算术平均。

需要明确的是:在不可靠通信里,时钟无法被严格校准到完全一致。我们能做的是“逼近一个足够稳定的相对时间”,让后续的判定、回滚、延迟补偿有一个共同参考。

Socket编程

有了对时基础,下一步就是“怎么通信”。用 套接字(Socket)当然能把客户端和服务器连起来,但完全靠 Socket 写游戏业务,会很快遇到两个现实问题:

  • 消息类型爆炸:请求/响应种类多,payload 复杂,开发者不得不手写打包、拆包与路由。
  • 跨平台差异:不同语言、不同操作系统、大小端、对齐方式等差异,会让解析与兼容变得难调且易错。

基于消息的通信

更自然的编程模型是“基于消息”的通信:客户端发出一个语义明确的请求,服务器处理后返回结果。问题在于:如果仍旧由业务程序员手工把这些语义编码成字节流,复杂度并不会消失。

早期做法往往是“手动编码”:定义操作码与参数,发送前打包,接收后解包。逻辑越复杂,维护成本越高。

另外还有一批更隐蔽但高频的坑:不同语言的类型大小、字节序(大小端)、数据对齐与变长数组等。一旦协议定义或解析出错,问题往往表现为“偶现的乱码”和“难复现的崩溃”。


18.4 RPC

远程过程调用(RPC)

现代网络游戏更倾向使用 远程过程调用(RPC):让业务侧像调用本地函数一样发起请求,底层负责参数序列化、传输、路由、反序列化与派发。

RPC 的目标不是“更快”,而是把复杂性从业务逻辑里抽走:程序员专注于玩法与规则,网络层用标准化流程保证可维护与不易出错。

RPC 的调用形态通常是“请求-响应”:客户端发起调用,服务器端触发对应处理,再把结果回给客户端。

它带来的直接收益是:写起来更像“集中式代码”,开发者不必反复处理网络协议细节与序列化细节。

接口定义语言(IDL)

RPC 的关键支撑是 接口定义语言(IDL):用一份规格描述“可调用的方法、参数、类型”,再由工具生成客户端与服务器端的代码骨架。

常见的例子包括 Protobuf 等:它们本质上都是在解决“跨语言、跨平台的稳定数据描述与编码”。

RPC存根

在 RPC 体系里,客户端通常通过“客户端存根”发起调用;服务器端通过“服务端存根”接收并派发。存根把网络细节屏蔽在内部,使远程调用在语义上更透明。

存根代码一般来自一个“存根编译器”:它读取 IDL,生成双方的存根与序列化/反序列化代码。接口缺失、参数不匹配等问题,可以在生成阶段或运行时尽早暴露出来。

真实RPC调用链路

最后别忘了:真实线上环境里,RPC 并不是“把参数发过去”这么简单。一次 RPC 往往还要经历压缩与加密;连接还要处理握手、会话标识、断线重连与恢复;服务端也会有队列、线程池与分发策略。

把这些流程收拢到 RPC 框架里,业务侧才能保持接口清晰、逻辑稳定。


18.5 网络拓扑

对战游戏要做的事情,本质是“把多个终端上的局部视角,组织成同一个可交互的世界”。而网络拓扑决定了:谁和谁通信、谁做权威裁决、以及出现丢包与高延迟时该由谁承担代价。

P2P(Peer-to-Peer)

P2P(Peer-to-Peer)的特点是:每个客户端都要与其他客户端通信,并把自己的状态广播给其他节点。为了维持分布式状态的一致性,节点之间需要频繁同步。

它的优势通常是“结构简单、天然分布式”,但代价也很现实:参与者越多,通信与同步压力越大;同时因为缺少权威裁决点,作弊与一致性校验会更难做。

主机服务器

当需要把“裁决”集中起来,又不想引入官方服务器时,常见做法是从玩家里选一台机器担任 主机(Host Server):其他玩家都连到主机,由主机承担部分“服务器职责”。

这类结构在局域网/小规模联机里很常见,但它有一个天然风险:主机退出或网络不稳,整局游戏的可用性会直接受影响。另外,主机往往还需要处理一些不由玩家直接控制的逻辑(例如 AI)。

P2P游戏

从应用场景看,P2P 更适合“玩家数量不多、偏私域”的联机:例如局域网或朋友之间的小房间。通常你不会期待它在公平性、反作弊与规模上做到极致,但它能以较低的运维成本提供足够好的联机体验。

专用服务器

当游戏目标变成“更大规模、更强公平、更稳定运营”时,主流选择会转向 专用服务器(Dedicated Server):由专门的服务器维护一个权威世界状态,再把必要的结果分发给各个客户端。

专用服务器的核心价值是权威性:胜负结算、命中判定、状态校验等关键规则能在同一个裁决点收敛,从而把“每个人各说各话”的分歧压到最小。

P2P vs 专用服务器

两种拓扑没有绝对好坏,更像是在“成本、规模、公平性、稳定性”之间做取舍:

  • P2P:维护成本低、结构直接,但玩家数量受限、对玩家网络质量依赖更强,且作弊更难控制。
  • 专用服务器:更易维护与反作弊、能承载更大规模世界,也能把游戏响应从“受个别玩家网络拖累”中解耦出来;代价是服务器成本更高、服务端工程更复杂,并且存在单点故障风险(需要配套容灾)。

延迟优化

当玩家跨国家/跨大区,或网络路径复杂导致 往返延迟(RTT)偏高时,仅仅“把服务器放在某个机房”往往不够。更常见的工程做法是:在各地布置接入点(类似边缘网关),让玩家先就近接入;接入点到核心服务器之间再走更稳定的专线链路,从而降低延迟波动并提升可预测性。


18.6 游戏同步

前面把协议、对时、RPC、拓扑这些“底座”铺好以后,终于可以进入网络游戏最核心的问题:怎么让不同终端“像在同一个世界里玩”

在单机游戏里,输入、逻辑、渲染都在同一台机器上闭环:玩家输入被转换成游戏命令,交给规则与战斗逻辑计算,最后渲染成画面。

一旦进入网络环境,事情会变得复杂:每个终端都有自己的输入与渲染,但“游戏逻辑与状态”到底放在哪里、由谁来裁决、以及如何在网络延迟下保持一致性,都需要重新设计。

从玩家体验看,网络游戏要同时满足两件事:

  • 响应性:按下按键要立刻有反馈。
  • 一致性:你看到的结果,要能和其他玩家、以及服务器的裁决对得上。

延迟与丢包会让“同一时刻”变得不再同步:你以为自己已经躲开了,对方那边可能还没收到你的动作;你这边看到对方已经倒地,对方屏幕上也许还在开枪。

因此,游戏同步更像是在做一套规则:在可接受的延迟成本下,让各端对世界状态“足够一致”。业界常见的主流路线大致有三类:快照同步、帧同步与状态同步。

快照同步

快照同步(Snapshot Synchronization)是一种比较早期、也非常直观的同步方式。它的核心思路是:服务器负责模拟整个世界,然后周期性把“这一刻的完整世界状态”下发给客户端;客户端收到后按快照更新表现。

在工程上,快照同步通常会配合“权威服务器”:客户端不直接决定世界状态,只提供输入;最终的状态以服务器为准。这也是它“很干净”的地方——一致性收敛到服务器,不需要在各端做复杂的状态合并。

把图里的流程展开,就是一个闭环:

  • 客户端:只上报输入(例如移动、开火)。
  • 服务器:推进游戏逻辑,得到权威世界状态。
  • 服务器:生成一份状态快照(位置、速度、朝向、血量等)。
  • 服务器:把快照下发给各个客户端。
  • 客户端:依据快照更新画面(渲染与表现由客户端负责)。

这种结构的关键瓶颈在于“服务器要发多少、发多频繁”。快照频率越高,越接近实时;但服务器计算与带宽压力也会同步上升。

一个常见折中是降低快照频率(例如从 60pps 降到 10pps)。但频率下降会带来两个副作用:

  • 延迟更明显:状态更新稀疏,玩家更容易感到“跟手性变差”。
  • 卡顿感:画面需要在两帧快照之间“补齐连续性”。

为了解决“低频快照导致的跳变”,客户端通常会做快照插值(Interpolation):不把最新快照立刻渲染出来,而是保留一个插值缓冲区,让画面落后真实时间一点点(例如落后 1~2 个快照间隔),在两个已收到的快照之间做平滑插值,视觉上就会更连贯。

快照同步的另一个压力来自数据量:如果每次都发送完整状态,很多字段在相邻快照里其实没有变化。于是就会引入差值压缩(Delta Compression)。

差值压缩的做法可以理解为:客户端与服务器都保存“上一份快照”,下一次只发送“变化的部分”。客户端收到后把差值应用到本地快照上,重建出最新状态。图里用 Quake 相关流程示意了:基于已确认(ACK)的快照版本做差,能显著减少带宽消耗。

因此,快照同步常见会组合三件事一起用:降低发送频率、客户端插值、以及差值压缩。它们的目标都是把“带宽与服务器压力”压下去,同时尽量保住可玩的连续性。

即便如此,快照同步的结构性代价仍然存在:客户端大量算力只用于渲染,服务器却要同时承担世界模拟与大规模下发;而随着游戏复杂度增长,快照体积会不断膨胀,带宽与分发压力进一步放大。

帧同步

帧同步通常对应 帧同步(Lockstep):各端不直接同步“状态”,而是同步“输入指令”。在理想前提下,只要初始条件一致,并且每一步都以相同顺序执行同一组输入,各端就能得到相同的游戏状态。

帧同步 Lockstep

“Lockstep”字面意思是步调一致,它强调的是:所有节点按同一个节拍推进。

锁步的核心约束可以概括为:任何成员在未确认其他所有成员已完成之前,不得推进仿真时钟。把它类比成回合制会更直观:你出招要等别人确认,才能进入下一轮;这样事件顺序是可靠的、唯一的。

锁步要成立,需要两个前提:

  • 相同输入:每一帧(或每一轮)各端收到的输入序列一致。
  • 相同执行过程:同一组输入在各端以同一顺序执行,逻辑具有确定性。

满足这两点,才能推导出“相同状态”。

锁步并不是新概念。早期一些多人在线游戏(例如《毁灭战士》,1994)就采用了接近锁步的思路(当时往往配合 P2P 架构),只同步输入指令而不是全量状态。

初始化

锁步最怕“起步就不一致”。一旦初始状态有细微差异,后续只同步输入时,各端会像滚雪球一样偏离,最终进入完全不同的结果。

因此在加载阶段,除了静态数据(地图、角色配置、数值表等)要一致,时钟与步调也需要对齐。

确定性锁步

最朴素的锁步实现方式是:客户端把输入发给服务器,服务器收齐并排序后再广播回所有客户端;客户端在收到“本帧所有人的输入”后,才执行本帧逻辑。

这个版本的代价也很直接:游戏进度取决于最慢的玩家。只要有一个玩家输入迟到,整局就必须等;如果有人离线,所有人都会被迫暂停。

这也是很多早期局域网对战的共同体验:某个玩家卡顿、掉线,全场“等待玩家中”。

桶同步

为了避免无限等待,工程上常会引入“桶同步”:把时间切成固定大小的桶(例如 100ms),服务器在每个桶内收集已到达的输入并广播;无需等待所有玩家的指令全部到齐

这样做的含义是:超过阈值的迟到输入,要么延后到下一桶,要么被当作“本桶无操作”。它牺牲了“严格还原每一次输入”的一致性,换取更稳定的交互性。

这里的本质是在做权衡:一致性越强,越容易被网络拖慢;交互性越强,就越需要容忍“迟到输入的丢弃或延后”。

确定性难题

锁步真正难的不是“转发输入”,而是让逻辑具备足够强的确定性:相同输入序列必须在所有机器上得到相同状态。困难点包括浮点数、随机数、容器遍历顺序、数学库、物理模拟、以及逻辑执行顺序等。

浮点数是最常见的坑之一:计算机用二进制表示小数,本身就存在表示误差;即使遵循 IEEE 754,不同平台的硬件、操作系统、编译器、数学库与第三方组件,也可能产生细微差异。

因此工程上常见的做法是避免把“关键结算”完全交给不受控的浮点路径:例如关键逻辑使用定点数、查表(三角函数等)、以及对运算精度进行约束。

定点数的直觉是把数拆成“符号位、整数部分、小数部分”,在固定精度下实现加减乘除,从而把跨平台差异收敛到可控范围内。

随机数同样需要被“驯服”。游戏里的暴击、掉落、NPC 刷新点等看起来是随机的,但在锁步里必须做到“每个人掷到同一颗骰子”。

常见做法是使用伪随机(Pseudo Random):对齐随机种子,并严格约束随机函数的调用次数与调用顺序,确保不同平台生成的序列一致。

把这些要点收束起来,就是一套“确定性解决方案”:关键逻辑尽量用定点数;容器与算法保持确定;数学工具(向量、四元数等)走统一实现;物理模拟尽量简化或做确定性改造;逻辑执行顺序固定。

调试与校验

锁步一旦失去确定性,表现往往是“前几百帧都正常,突然开始分叉”。因此需要能定位“从哪一帧开始不一致”。一个常见工程手段是定期上报校验和(checksum):服务器比对各客户端的校验和,一旦发现不一致,触发日志回传并定位问题帧。

延迟与缓冲

在公网环境里,网络抖动不可避免。如果客户端每帧都“等新帧到达再执行”,画面会出现明显卡顿。常见策略是在客户端加缓冲:提前缓存一段未来指令,平滑短时间抖动;代价是缓冲越大,额外延迟越高,缓冲越小,对抖动越敏感。

进一步的工程化做法是把逻辑帧渲染帧分离:逻辑帧通常较低(例如 10~30),渲染帧更高;通过本地插值/平滑让画面连续,即便网络抖动导致渲染卡顿,也尽量不影响逻辑推进。

这种分离还能带来两个额外收益:渲染节奏更稳定、减少撕裂与卡顿;同时服务器可通过运行逻辑帧参与部分校验,降低作弊空间。并且服务器可以保存关键帧快照,辅助断线重连。

断线重连与追赶

断线重连的难点不是“重新连上”,而是“怎么追上当前进度”。常见做法是以时间桶为粒度维护进度:离线后重新连接,客户端需要把丢失的指令补齐并追赶到当前桶。

为了避免从开局追到现在,客户端会周期性保存本地状态快照并落盘。重连后从最近快照恢复,再从服务器补齐快照之后的指令,大幅缩短追赶时间。

追赶阶段一般会临时关闭渲染、加速执行逻辑帧(例如每次追 10 帧),把算力集中用在“追上进度”。

服务器侧也可以保存关键帧快照:重连时先下发快照,再下发其后的指令,让客户端更快恢复到正确状态。

对于短暂离线,一种常见策略是“不断线、无崩溃”:客户端继续维持本地状态与关键帧记录;重连成功后从服务器补齐指令并加速追赶,尽量把中断感压到最小。

观战与回放

观战在底层上和“重连追赶”很接近:观战端拿到某个时间点的快照,再接收之后的指令并追赶到当前进度。为了防作弊,观战通常会引入延迟(例如延后几分钟),并只转发观战所需的数据。

回放更直接:把玩家输入指令按顺序记录下来(体积小),配合少量关键帧快照,就能支持快进/回退等操作。

作弊与对抗

锁步的反作弊通常会结合校验和与服务器校验:多人对战时可以投票/比对校验和定位异常;但在小人数(例如 2 人)对战里,“投票”并不可靠,需要服务器参与校验或引入更强的权威裁决。

另外一个更棘手的问题是信息泄露:锁步要求各端本地执行逻辑,原则上客户端“拥有完整世界状态”。如果游戏存在战争迷雾、视野遮挡等机制,就需要额外的可见性裁剪与安全设计,否则外挂可以通过第三方插件直接读取隐藏信息。

小结

帧同步的优势很鲜明:带宽低(只同步指令)、开发形态更接近单机、动作/命中等判定更精确、并且天然易于记录与回放。

它的问题也同样明确:确定性难实现;外挂更容易暴露全局状态;断线重连与追赶需要更复杂的工程化方案。

状态同步

状态同步(State Synchronization)是目前大型网游与大多数对战游戏更常见的路线:各端不需要做到严格的确定性(Deterministic),因为最终结算由服务器裁决;客户端更像是在“尽快响应 + 逼近服务器真相”之间做工程折中。

同步对象

从同步对象角度看,状态同步通常会同时处理三类信息:

  • 状态数据:例如位置、生命值、法力值、姿态、Buff 等。
  • 事件:例如开火、爆炸、命中、死亡等“瞬时通知”。
  • 控制数据:来自玩家输入的高频数据,但通常不会直接按键级同步,而是抽象为“移动/攻击/跳跃”等可复现的意图与参数。

一个关键差异在于:服务器不会给所有客户端下发同一份更新包,而是会针对每个客户端生成“与我相关的那部分世界”。当世界足够复杂时,通常需要配合 兴趣管理(AOI,Area of Interest)做裁剪,从而降低带宽与服务器开销。

权威服务器

状态同步最核心的假设是“服务器有最终解释权”。服务器收集客户端上报的输入/意图,推进权威世界的游戏逻辑,然后把结果按需下发给各个客户端;客户端据此更新本地世界的表现与模拟。

授权与复制

为了把“谁说了算”讲清楚,状态同步里经常会用到两个词:

  • 授权客户端(Authorized Client):玩家本地的客户端,对“我操控的角色”的输入有发言权(例如我按下开火、开始移动)。
  • 复制客户端(Replicated Client):在其他玩家视角里,你的角色只是一个“复制品”,它的行为由服务器下发的状态驱动。

服务器本身是 权威服务器(Authorized Server):判定命中、伤害结算等最终以服务器为准。

同步流程示例

用“开火”这个例子把链路走一遍(图里 Player1 为授权客户端,Player2 侧看到的是复制客户端):

  1. Player1 本地触发开火意图(Fire)。
  2. Player1 把开火相关数据发给服务器(例如开火时刻、枪口朝向、武器参数、以及必要的随机种子/序列号等)。

  1. 服务器推进游戏逻辑,做权威判定:是否命中、命中谁、造成多少伤害。
  2. 服务器把“Player1 开火”这类状态/事件分发给各客户端,Player2 收到后驱动复制客户端播放开火表现。

  1. 对于弹道、投射物轨迹等细节,各端可以做本地模拟以获得连续画面;但“是否命中/最终伤害”依然以服务器结果为准。

  1. 当服务器确认投射物命中或销毁时,会再下发后果(例如删除投射物副本、播放爆炸、结算伤害),各端据此统一收敛。

这个流程的好处是:各端不需要完全一致;即使本地模拟在细节上有偏差,最后也会被服务器的权威结果拉回“同一条世界线”。

愚蠢客户端问题

状态同步的直观问题是:如果客户端必须等服务器确认后才动,那么玩家会感到“按键不跟手”。这就是常说的 愚蠢客户端(Dumb Client)问题。

客户端预测

解决思路是:本地先走一步。以移动为例,玩家按下方向键后,授权客户端立即根据自己的输入与运动模型预测下一段位移,同时把输入发给服务器。

在竞技射击里,这种“先走一步”的幅度通常会和 RTT 相关:客户端往往会领先服务器半个 RTT(往返时延的一半),再加上一个用于平滑的缓冲帧;这样按键能立刻响应,但仍能在可控范围内等待服务器回包。

服务器和解

预测不是永远正确:服务器可能在你看不到的地方更新了世界(例如有人放墙、地形变化、碰撞阻挡),导致你的本地预测与服务器结果冲突。

因此客户端需要保存一段时间内的历史状态(环形缓冲区),当服务器回包到达时,将回包对应的时刻与本地历史进行对齐和校验;如果发现不一致,必须以服务器为准进行修正,这个过程通常称为 服务器和解(Server Reconciliation)。

如果预测正确,客户端就继续平滑推进;如果预测错误,则从服务器确认的位置重新对齐,并把“确认之后的输入”重新回放一遍,把状态追到当前时刻。

这也是“被拉回去”的来源:你在本地看到自己跨过去了,但服务器判定没过去,于是和解时把你修正回去。为了减少突兀感,修正通常会配合插值/平滑,而不是瞬移。

丢包与补偿

丢包会让服务器暂时收不到客户端输入。服务器一般也会维护一个小的输入缓冲:如果在窗口内输入没到,为了避免角色“原地抽搐”,常见做法是短时间复用上一帧输入(例如继续沿上一次方向移动),同时等待客户端把缺失输入补齐;如果缓冲耗尽,则往往按掉线处理。

状态同步 vs 锁步同步

状态同步与锁步同步的取舍,本质是“权威位置”不同:状态同步把世界的权威集中在服务器,因此更容易跨平台、更容易重连,但通常需要更高的网络流量与更复杂的工程(预测、和解、插值、裁剪);锁步同步则以确定性换带宽,但响应与调试难度更突出。

对照表可以把差异看得更清楚:



转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏