从 JSON 到 Protobuf:将实时绘图数据同步性能压榨到极致
在开发《你画我猜》(Sketchy)这款实时联机游戏时,一个核心挑战是如何在高并发、弱网环境下,依然能丝滑顺畅地同步画手的每一笔细节。
刚开始我们使用最原始的 JSON 方案,虽然能跑通,但很快就遇到了性能瓶颈。经过一连串的“压榨式”优化,我们最终将数据量缩减到了原来的 1/40。今天聊聊这段从“能用”到“极致”的重构历程。
1. 现状:JSON 方案的“虚胖”
最初的实现非常直观:
1 | |
这种方案有三个致命伤:
- 格式冗余:每个点都重复携带字段名(”x”, “y”),数据量巨大。
- 解析压力:浏览器频繁调用
JSON.parse。 - 时间精度:直接存绝对时间戳占用空间太大。
实测下来,画一个简单的圆形,数据量轻轻松松就会飙到几百 KB,这在移动端极其危险。
2. 第一阶段:Protobuf 序列化
我们引入了 Google 的 Protobuf (Protocol Buffers)。相比于文本格式的 JSON,二进制格式的 Protobuf 具有天然的压缩优势。
同时,我们改变了数据结构,引入了 “并行数组” 模式:
1 | |
这一步的关键优化点:
- 相对位移 (Delta):不记录绝对坐标
(425, 182),而是记录相对于上一个点的位移(+2, -1)。小数值在 Protobuf 的 Varint 编码下只占用 1 字节。 - 时间间隔 (Delta Time):不记录绝对毫秒戳,记录与上一点的时间差(如 16ms)。
3. 第二阶段:后端历史记录整合 (Server Consolidation)
画笔在移动时会频繁发送小包。当一个新玩家进入房间时,服务器需要把所有的历史笔迹发给他们。
如果直接原样转发几千条 MOVE 指令,客户端重绘会卡死。我们在后端实现了一个合并逻辑:
- 如果连续收到多个来自同一画笔的移动动作,通过 Go 后端将它们的坐标数组和时间戳数组合并成一条大的指令。
- 这大幅减少了 Message Header 的数量,对于长笔迹非常有效果。
4. 第三阶段:RDP 路径精简算法
即便用了 Protobuf,如果画手画一条直线,我们依然会每 16ms 记录一个点。这显然是浪费的。
我们在前端引入了 Ramer-Douglas-Peucker (RDP) 算法。每 500ms 进行一次“清洗”:
- 几何精简:如果中间的点偏离直线的距离小于 1 像素,则直接剔除。
- 时间补偿:这是最细节的地方。被剔除点的 Delta Time 必须累加到下一个保留的点上,否则回放时的速度会变得飞快(瞬移)。
实测显示,对于普通的绘画,RDP 算法能在这个阶段再次精简 50% 的点数。
5. 战果汇报
经过这一套组合拳,我们对一个包含 100 个采样点的典型操作进行了对比:
| 阶段 | 方案 | 数据量 (估算) | 优化幅度 |
|---|---|---|---|
| 初始 | 原生 JSON | ~7,000 Byte | - |
| 进阶 | Protobuf + 并行数组 | ~350 Byte | 95% ↓ |
| 极致 | RDP 精简 + 历史合并 | ~160 Byte | 97.7% ↓ |
现在的实时绘图同步,带宽占用极低。即便是高难度的复杂画作,在重连恢复时也能在瞬间“闪现”完成,不再有肉眼可见的加载条。
总结
性能优化往往不是一步到位的,而是随着对业务理解的深入,从“编码层面(Protobuf)”到“协议结构层(Delta)”,再到“算法层面(RDP)”的层层剥茧。
最让我高兴的不是省下了多少流量,而是在优化过程中,我们不仅保证了效率,还通过“时间补偿”完美保留了画作回放时的那一股“灵动感”。这才是技术服务于艺术的真谛。