设计一个 10 万并发玩家的实时多人对战后端(.io 风格 / 轻量 FPS):玩家在同一房间里移动、射击,每个人都要"几乎瞬间"看到别人的动作。这类系统和 请求-响应 Web 后端是两种物种——SLO 不是"p99 < 200ms",而是 端到端 p95 < 100ms 且抖动可控,因为人眼对 50ms 以上的输入延迟与位置跳变极敏感。
graph TD
C["客户端
本地预测 + 渲染插值"]
MM["Matchmaker
HTTPS · 分配房间/区域"]
GW["Edge Gateway
UDP/WebRTC 终结 · 就近接入"]
GS["权威游戏服务器
房间进程 · tick loop"]
R[("Redis
会话/房间状态")]
DB[("持久化 DB
战绩/排行")]
C -->|① 找对局 HTTPS| MM
MM -->|② 返回 server+token| C
C <-->|③ 实时双向 UDP| GW
GW <-->|④ 转发| GS
GS -.房间状态.-> R
GS -.异步落库.-> DB
classDef client fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef edge fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef core fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class C client
class MM,GW edge
class GS core
class R,DB store
控制面(撮合、登录)走 HTTPS;数据面(实时状态)走专用低延迟通道,两条路径解耦
组件职责:Matchmaker 是无状态 Web 服务,按地理与技能把玩家分到最近区域的房间,返回服务器地址 + token。Edge Gateway 在用户 30ms 内终结 UDP/WebRTC 连接,做 NAT 穿透与抗 DDoS。游戏服务器是有状态房间进程,跑固定频率 tick loop:收集本 tick 全部输入 → 推进物理 → 广播快照。状态主存内存,Redis 只做故障恢复与协调,DB 异步记录结果。关键是把"实时数据面"和"持久控制面"彻底分开。
核心 trade-off:TCP 的可靠有序,恰恰是实时系统的毒药。
原理:TCP 保证字节流有序到达——一旦一个包丢了,后面已到达的包必须在内核缓冲区排队等重传,应用层拿不到。这叫队头阻塞(Head-of-Line blocking)。游戏里第 100 号位置包丢了,101、102 号已到却被卡住,等重传回来它们早已过时——你为"没人想要的旧数据"付出一次 RTT 的卡顿。实时系统宁愿丢掉过时数据继续往前,也不要有序。所以游戏走 UDP(自实现选择性可靠),浏览器走 WebRTC DataChannel(UDP 基底,可配 unreliable/unordered)或 WebTransport(QUIC)——QUIC 在用户态实现多 stream,单 stream 丢包不阻塞其他 stream,绕开内核 TCP 的队头阻塞。
| 通道 | 可靠/有序 | 队头阻塞 | 适用 |
|---|---|---|---|
| TCP / WebSocket | 可靠+有序 | 有(致命) | 聊天、协作编辑、控制面 |
| UDP(裸) | 都不保证 | 无 | 原生游戏,自实现可靠层 |
| WebRTC DataChannel | 可配 | 可关 | 浏览器实时游戏/P2P |
| WebTransport (QUIC) | 多 stream 独立 | 跨 stream 无 | 现代浏览器实时数据 |
实战做法是双通道:位置/朝向走不可靠 UDP(丢了无所谓,下一 tick 覆盖);"开火/拾取/死亡"等关键事件走可靠通道(自定义 ACK 重传或 WebRTC reliable 模式)。
核心 trade-off:用"可能要回滚"的乐观本地仿真,换"零感知延迟"的手感。
原理:哪怕 RTT 只有 50ms,若按"按下 → 发服务器 → 等回包 → 才移动",玩家会觉得操作粘滞。解法三件套:① 客户端预测——本地立刻按输入移动不等服务器,同时把带序号的输入发出。② 服务器和解——服务器权威,回包带"已处理到第 N 号输入 + 权威位置";客户端以该位置为基准重放第 N 号之后所有未确认的本地输入,得修正后位置。预测对则画面不动,被撞墙等打断则平滑纠正。③ 实体插值——其他玩家状态按 tick 离散到达,客户端故意延迟约 100ms 渲染,在两个已知快照间插值,换来丝滑而非瞬移。代价是你看到的"别人"永远是 100ms 前的——这就是 lag compensation(延迟补偿)要解决的:射击判定时服务器把目标回退到射击者当时实际看到的位置再算命中。
# 客户端预测 + 和解(伪代码)
pending = [] # 未确认的本地输入
def on_input(cmd):
cmd.seq = next_seq()
apply_local(cmd) # ① 本地立刻动,零延迟手感
pending.append(cmd)
net.send(cmd) # 带序号发给权威服务器
def on_server_snapshot(snap):
me.pos = snap.pos # ② 以权威位置为准
# 丢弃已被服务器确认的输入
pending = [c for c in pending if c.seq > snap.last_processed_seq]
for c in pending: # 重放还没确认的输入
apply_local(c) # → 预测对则画面不跳,错则平滑纠正
# 其他玩家:缓冲两帧,在 render_time = now - 100ms 处插值 (③)
核心 trade-off:同步精度 vs 带宽/CPU——每 tick 给每人发"整个世界"会被带宽与 CPU 双杀。
原理:权威服务器跑固定频率 tick loop(如 30Hz):吸收本 tick 全部输入 → 推进确定性物理 → 产出新状态 → 广播。广播三层优化:① Delta 压缩——只发"相对该客户端上次确认的快照"的变化字段,静止物体不占带宽。② 兴趣管理(AOI, Area of Interest)——玩家只需附近实体,用网格/四叉树做空间索引,每人只收视野内更新。100 人房全互发是 O(n²)=1 万条/tick;AOI 后每人只看附近 ~10 人,降到 O(n·k)。③ 优先级分层——近处实体高频更新,远处低频或不发。这把"每 tick 全量"从带宽灾难变成可工程化的稳定流。
核心 trade-off:OT 的中心化简单 vs CRDT 的去中心化自由。
原理:游戏要"权威 + 防作弊",但 Figma/Docs 这类协作编辑是另一种实时:可离线、可并发改同一文档、绝不能丢字、最终收敛。两大流派:OT(Operational Transformation)——把并发操作相互"变换"以保持意图(你在位置 5 插字、我在位置 3 删字,OT 调你的索引),强依赖中心服务器定序,复杂但状态紧凑(Google Docs)。CRDT——把数据设计成数学上可交换合并,任意顺序应用都收敛,天然支持 P2P 与离线,但元数据开销大(每字符带唯一 ID 与墓碑)。LWW(Last-Write-Wins)是 CRDT 最简形:每字段独立,谁最后写谁赢。
# LWW register(CRDT 最简形):靠 (timestamp, replica_id) 全序定胜负
def merge(local, remote):
if (remote.ts, remote.node) > (local.ts, local.node):
return remote # 较"新"的写入获胜,合并满足交换律/幂等
return local
# 难点不在 merge,而在"什么粒度做 LWW":整个对象?会互相覆盖丢编辑;
# 每个属性独立?并发改不同属性互不冲突 —— Figma 选了后者
高频面试题:① 为什么游戏不用 TCP?队头阻塞具体怎么害人?② 客户端预测错了怎么纠正才不突兀?③ 100 人同房每 tick 怎么不把带宽打爆?④ 命中判定为何必须服务器权威,lag compensation 又如何保证公平?⑤ Figma 的协作和一局 FPS,一致性模型差在哪、为什么?
这是 lag compensation 的核心矛盾。服务器有两个选择:(a) 用"现在"的世界判定——但射击者瞄的是 100ms 前对方所在的位置,会经常打空,高 ping 玩家吃亏;(b) 把世界回退到射击者开火那一刻实际看到的状态再判定(Valve 的做法,保留约 1 秒位置历史)——射击者"所见即所得",公平了射击方。
代价转嫁给被击中方:你已躲到墙后,却因对方高 ping 在其"过去时刻"还站在原地,被"隔墙打死"(shot behind cover)。这不是消除不公,而是在射击者与被击者之间转移不公。多数动作游戏选 (b),因为"瞄准了就该命中"更关键;设计上限制回退窗口(如 ≤200ms)给极端高 ping 封顶。
延迟:服务器每 tick 才处理一次输入,平均"输入等待被处理"约半个 tick。30Hz≈33ms/tick → 平均等 ~16ms;60Hz → 平均等 ~8ms。提频砍掉这部分延迟,更跟手。
带宽与 CPU:广播频率翻倍,出向包数与 pps 约翻倍(每包还有 IP/UDP 头固定开销,小包占比高时摊销更差);服务器每秒跑两倍物理 tick,CPU 也翻倍。2000 房 × 翻倍 = 成本显著上升。
不是越高越好:收益递减——30→60Hz 砍 8ms 很值,60→120Hz 只再砍 4ms 却又翻倍成本;且客户端渲染/网络抖动往往是更大的延迟源。竞技 FPS 常用 64/128 tick,休闲游戏 20–30Hz 足够。手感 vs 成本,按品类定。
① 元数据开销:通用 CRDT(尤其序列/文本)要给每个元素带唯一 ID、版本向量、墓碑(删除不能真删,否则并发合并出错),文档越改元数据越膨胀,对海量矢量对象成本高。
② 有中心服务器时,最难的问题消失了:CRDT 的复杂度主要用来应对"没有权威定序者"。Figma 本来就有服务器在线做全序仲裁,于是大部分字段退化成简单 LWW register 就够,不必背 CRDT 全部包袱。
③ 冲突语义可控更重要:Figma 按"每对象每属性"做 LWW(并发改不同属性互不干扰、改同属性后到者胜)——这种确定的、用户可理解的结果,比 CRDT 自动合并出的"正确但意外"更可控。树结构用父指针 + 服务器拒环,同样是用中心权威换简单。
结论:CRDT 解决"无中心"难题;只要你能保持一个中心,OT/LWW 往往更省更可控。CRDT 真正的主场是 local-first。
关键是区分"必须持久"和"可丢弃"的状态,按重要性分层:
这本质是 RPO 分级:位置态 RPO 可"无穷"(重算即可),分数态 RPO 要趋近 0。把昂贵的持久化挡在 tick 热路径之外,是有状态实时服务不掉帧的通用手法。
分水岭是数据的"时效性"与"不可丢性"哪个更重要:
选反的后果:游戏用 TCP → 丢包一卡一卡被吊打;协作编辑用裸 UDP → 丢操作=丢字、乱序=文档损坏,得在应用层把可靠有序重写一遍,等于白费。所以"实时"不是单一需求,先问"丢一条数据是无所谓还是事故",答案直接决定通道。这也是很多游戏后端同时用两条通道的原因:位置走 UDP,关键事件/聊天走可靠通道。