从“全量快照”到“精细化属性同步”:WebSocket 大厅状态同步的极致演进
当一个多人联机游戏的大厅在线人数从 10 个人增长到 100 个人,再到 1000 个人时,最拉胯的性能瓶颈往往不是数据库,而是那个看似简单的“房间用户列表”和“游戏桌列表”的同步。
今天聊聊我们在 Game Hub 项目中对 WebSocket 大厅同步进行的三波“压榨式”重构。
1. 现状:全量广播的“地毯式”轰炸
最开始的实现非常直观:每当有玩家进入、离开或者准备,服务器就对这个分区的所有人广播一次完整的 UserList 或 TableList。
这种方案在人数较少时非常稳健,但缺点也极其明显:
- 流量爆炸:100 个人在房间,一个人进来,发 100 份包,每份包里带 100 个人的信息。流量呈指数级上升。
- CPU 抖动:频繁的大规模 JSON 序列化(甚至 Protobuf 全量序列化)对内存和 CPU 极不友好。
2. 第一波优化:增量同步模式 (Snapshot + Incremental)
我们引入了 “快照 + 增量” 的模式。核心思想类似于 Redis 的 AOF 或是视频编码的 I 帧/P 帧。
- Snapshot (全量):用户第一次连入房间时,下发一次完整的数据。
- Incremental (增量):后续只有在数据变动时,才发送
Upsert(更新/新增)或Delete(删除)指令。
为了让这种模式在代码层面“长治久安”,我们封装了双端工具:
- 后端 Go:设计了
SyncTracker追踪器。它会标记哪些 ID 脏了,并在一个短的时间窗口(如 300ms)内自动去重,到期后执行Flush。 - 前端 TS:提供统一的
mergeIncrementalItems工具函数,强制执行Object.assign的同步规范。
3. 第二波优化:精细化字段更新 (Partial Updates)
即便有了增量同步,我们很快又发现了一个槽点。
如果一个玩家仅仅是切换了一个分区(Location),服务器依然会把他的 nickname, avatar_url, gold 等一大堆保持不变的数据重新发一遍。
这算哪门子增量?我们需要字段级的增量。
利用 Protobuf 3 的 optional 字段(在 Go 中体现为指针),我们实现了属性级的精细同步:
1 | |
在 Go 后端,赋值逻辑变得极度精简:
1 | |
4. 极致进化:自描述差量累积 (Partial Accumulation)
这是本次优化的点睛之笔。在 SyncTracker 的缓冲窗口期内,如果同一个玩家连续发生了多次属性变动,我们该如何处理?
我们利用了 proto.Merge 的特性。SyncTracker 内部维护的是一个局部差量对象。当新的变动到来时,不断与其执行 Merge。
最终,在窗口结束时:
- 所有变更被合并成了一个最小化的差异包。
- 未变动的字段完全不占带宽。
配合前端的 ... (Spread Operator) 浅合并,这一套机制实现了**“空气级”**的流量开销:仅同步真正发生改变的那几个字节。
5. 战果与反思
经过这一套精细化大修,我们在大厅压测中得到了一组亮眼的数据:
- 同步流量:在频繁切换分区的场景下,单次广播包体减小了 90% 以上。
- 开发体验:利用 Protobuf 的强类型,代码从“拼掩码字段”变成了“直接操作 Partial Struct”,编译器成了最好的 Reviewer。
总结:架构设计的“硬约束”
这次优化让我最深刻的体会是:文档是靠不住的,唯有工具类能带来真正的规范。
我们没有写一份长达万字的《大厅同步协议优化指南》,而是直接将逻辑锁死在了 SyncTracker 和 mergeIncrementalItems 这两个核心工具中。开发者只要想做同步,就必须按这套“精细化增量”的套路走。
“重构不仅仅是为了省那点流量,更是为了在未来增加第 100 个复杂状态属性时,大厅依然能像当初那样轻盈、丝滑。”
2026-03-31 记于二店长的游戏实验室