Day 34 Hard Real-time UDP/WebRTC Game Netcode CRDT

实时系统 — 在 100ms 内让世界保持一致Real-time Systems: Transport, Game Netcode, Authoritative State, Collaborative Sync

问题场景 + 需求约束

设计一个 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 异步记录结果。关键是把"实时数据面"和"持久控制面"彻底分开

关键技术点

1. 传输层选型:为什么实时系统逃离 TCP

核心 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 的队头阻塞。

Trade-off:
通道可靠/有序队头阻塞适用
TCP / WebSocket可靠+有序有(致命)聊天、协作编辑、控制面
UDP(裸)都不保证原生游戏,自实现可靠层
WebRTC DataChannel可配可关浏览器实时游戏/P2P
WebTransport (QUIC)多 stream 独立跨 stream 无现代浏览器实时数据

实战做法是双通道:位置/朝向走不可靠 UDP(丢了无所谓,下一 tick 覆盖);"开火/拾取/死亡"等关键事件走可靠通道(自定义 ACK 重传或 WebRTC reliable 模式)。

现实案例:

2. 客户端预测 + 服务器和解:把光速藏起来

核心 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 处插值 (③)
现实案例:

3. 权威服务器与状态同步:tick、快照与兴趣管理

核心 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(同步模型):
现实案例:

4. 实时协作传输:另一类实时——最终一致而非权威

核心 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 选了后者
现实案例:

扩展与优化

常见陷阱 + 面试追问

1. 用 TCP/WebSocket 做高频游戏同步。 队头阻塞会在丢包时制造一连串卡顿。低频回合制可以,30Hz 动作游戏必须 UDP 类通道。聊天/协作编辑反而该用可靠通道。
2. 信任客户端。 "客户端说我打中了就算中"= 外挂天堂。权威判定一定在服务器,预测只为手感。
3. 把绝对延迟当唯一指标。 稳定 80ms 远胜在 30–150ms 间抖动的连接。要监控 jitter 与丢包,用接收端 jitter buffer 平滑。
4. 全量广播 + O(n²) 扇出。 100 人房不做 AOI/delta,带宽和 CPU 直接爆。空间索引 + 兴趣管理是大房间的命根子。
5. 协作编辑用"整文档 LWW"。 粒度太粗会互相覆盖丢编辑。要按字段/字符级别定义合并,想清楚冲突语义(Figma 按属性、文本类按字符)。

高频面试题:① 为什么游戏不用 TCP?队头阻塞具体怎么害人?② 客户端预测错了怎么纠正才不突兀?③ 100 人同房每 tick 怎么不把带宽打爆?④ 命中判定为何必须服务器权威,lag compensation 又如何保证公平?⑤ Figma 的协作和一局 FPS,一致性模型差在哪、为什么?

深入资源

深入思考(点击展开答案)

1. 客户端预测让"我"零延迟移动,但"我"看到的别人是 100ms 前。射击时该信谁看到的画面?这背后是怎样的公平性取舍?

这是 lag compensation 的核心矛盾。服务器有两个选择:(a) 用"现在"的世界判定——但射击者瞄的是 100ms 前对方所在的位置,会经常打空,高 ping 玩家吃亏;(b) 把世界回退到射击者开火那一刻实际看到的状态再判定(Valve 的做法,保留约 1 秒位置历史)——射击者"所见即所得",公平了射击方。

代价转嫁给被击中方:你已躲到墙后,却因对方高 ping 在其"过去时刻"还站在原地,被"隔墙打死"(shot behind cover)。这不是消除不公,而是在射击者与被击者之间转移不公。多数动作游戏选 (b),因为"瞄准了就该命中"更关键;设计上限制回退窗口(如 ≤200ms)给极端高 ping 封顶。

2. 把 tick rate 从 30Hz 提到 60Hz,延迟和带宽各自如何变化?是不是越高越好?

延迟:服务器每 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 成本,按品类定。

3. CRDT 天然支持离线与 P2P、永不冲突,为什么 Figma 这种"必须强协作"的产品反而选了中心服务器 + LWW,而不是纯 CRDT?

① 元数据开销:通用 CRDT(尤其序列/文本)要给每个元素带唯一 ID、版本向量、墓碑(删除不能真删,否则并发合并出错),文档越改元数据越膨胀,对海量矢量对象成本高。

② 有中心服务器时,最难的问题消失了:CRDT 的复杂度主要用来应对"没有权威定序者"。Figma 本来就有服务器在线做全序仲裁,于是大部分字段退化成简单 LWW register 就够,不必背 CRDT 全部包袱。

③ 冲突语义可控更重要:Figma 按"每对象每属性"做 LWW(并发改不同属性互不干扰、改同属性后到者胜)——这种确定的、用户可理解的结果,比 CRDT 自动合并出的"正确但意外"更可控。树结构用父指针 + 服务器拒环,同样是用中心权威换简单。

结论:CRDT 解决"无中心"难题;只要你能保持一个中心,OT/LWW 往往更省更可控。CRDT 真正的主场是 local-first。

4. 一个房间进程崩溃,内存里 50 人的对局状态全丢。怎么设计才能既不丢关键数据、又不让 checkpoint 拖垮 30Hz 的 tick?

关键是区分"必须持久"和"可丢弃"的状态,按重要性分层:

  • 位置/朝向(高频、可丢):完全不 checkpoint。崩溃重连后客户端重新上报、几帧内收敛,损失可忽略。
  • 关键事件/分数(低频、不可丢):命中、得分、道具拾取这类"改变结果"的事件异步写入 Redis/事件日志,不在 tick 主循环同步落盘——用单独 IO 线程/队列,避免阻塞 30Hz 物理。
  • 周期性 checkpoint:每隔几秒把压缩后的关键态快照到 Redis;崩溃后新进程拉最近 checkpoint + 重放后续事件日志,重建近似对局。

这本质是 RPO 分级:位置态 RPO 可"无穷"(重算即可),分数态 RPO 要趋近 0。把昂贵的持久化挡在 tick 热路径之外,是有状态实时服务不掉帧的通用手法。

5. 同样是"实时",为什么聊天/协作编辑该用 TCP/WebSocket,而动作游戏必须用 UDP?把两者通道选反会怎样?

分水岭是数据的"时效性"与"不可丢性"哪个更重要

  • 动作游戏:位置数据高频且自我覆盖——第 N 帧位置一旦过时就是垃圾,下一帧替代它。"丢旧包继续往前"远好过"等重传一个没人要的旧值"。TCP 会因队头阻塞在丢包时连锁卡顿,手感崩坏。
  • 协作编辑/聊天:每个操作不可丢且需有序——少一个字符或乱序,文档就错、永不收敛。这里要的就是 TCP 的可靠有序;偶尔几十毫秒延迟人类完全无感(打字不是 60Hz 操作)。

选反的后果:游戏用 TCP → 丢包一卡一卡被吊打;协作编辑用裸 UDP → 丢操作=丢字、乱序=文档损坏,得在应用层把可靠有序重写一遍,等于白费。所以"实时"不是单一需求,先问"丢一条数据是无所谓还是事故",答案直接决定通道。这也是很多游戏后端同时用两条通道的原因:位置走 UDP,关键事件/聊天走可靠通道。