Protobuf 在牌桌上的取舍:生产 WS 流量减少 46%
100 桌、900 连接,同一生产版本 A/B:相对已有的紧凑 JSON,WS 下行载荷从 1.73 MB/s 降到 0.93 MB/s,动作吞吐基本持平。四类事件迁移、内部队列共享对象,以及保留 JSON 的成本与理由。
100 桌的实测:每次动作少发约 46% 字节
2026 年 9 月 13 日,我们把牌桌高频广播的一部分改成 Protobuf,并在生产版本 web-2026.09.13.1 上做了对照测试。100 张桌、每桌 9 个模拟玩家,全部 WebSocket 下行应用载荷从约 1.73 MB/s 降到 0.93 MB/s,减少约 46%;实际完成的动作都约为每秒 414 次。
基线已经是上一轮优化后的 compact-v1 紧凑 JSON,并非原始完整 JSON。新方案 protobuf-v1 保留原有紧凑处理,只把四类公共事件改成二进制。两轮使用同一个服务端二进制,区别是连接协商的协议。这个比较能衡量换线格式的效果;同版本里已经存在的队列共享等优化,两边都会用到,不能把它们的全部收益也算进来。
| 100 桌 / 900 连接 | 紧凑 JSON | Protobuf 混合协议 |
|---|---|---|
| WS 下行载荷总字节 | 104,109,573 B | 56,505,168 B |
| WS 下行载荷速率 | 1,731,657 B/s | 933,142 B/s |
| 每次已完成动作的 WS 字节 | 4,181.44 B | 2,253.99 B |
| 实际完成动作 | 24,898 | 25,069 |
| 实际动作吞吐 | 414.13 次/s | 414.00 次/s |
| REST 成功请求 | 60,000 / 60,000 | 60,000 / 60,000 |
按速率算,降幅为 1 − 933142 / 1731657 ≈ 46.1%;按每次动作归一化也约为 46.1%。两轮实际计量窗口分别为 60.121 秒和 60.554 秒,因此总字节降幅略有不同,为 45.7%。这里使用十进制 MB,只计文本的 UTF-8 字节与二进制载荷,不包含 WebSocket 帧头、TCP、TLS 或其他接口流量,不能直接写成整台服务器的带宽账单减少 46%。
为什么先迁移这四类事件
上一轮紧凑 JSON 优化已经省掉了默认值、重复静态字段和无作用消息。剩下的高频事件仍在反复发送字段名和 JSON 结构。一张九人桌发生一次动作,同一份公共结果要送给多个连接,这部分字节值得单独处理。
| 二进制事件 | 承载内容 |
|---|---|
actor_deadline | 当前座位、决策截止时间与轮次 |
action_applied | 动作结果、投入、下注状态与下一行动者 |
pot_updated | 底池与边池 |
street_revealed | 新公共牌与该街的行动状态 |
它们都是高频、对接收者内容一致的公共事件。快照、私牌、加密发牌消息和其他控制消息继续走原来的 JSON 路径,保留隐私投影与每连接的紧凑状态。REST 本轮也没有迁移。每扩大一类消息,都会增加字段语义、权限边界和恢复行为的验证量;先用较小的改动覆盖主要热流量,收益更容易归因。
Protobuf 用字段编号标识字段,整数可用变长编码表达,因而不需要逐条带上长字段名;具体格式见官方编码说明。我们仍保留了动作种类、街名等字符串,没有再叠加一套自定义短码。它们还可以更小,但这一轮先控制协议语义的改动范围。
连接仍是普通 WebSocket:H5 使用 wire=protobuf-v1,四类事件收到二进制消息,其余收到 JSON 文本。这次没有引入 gRPC,也没有开启 permessage-deflate。减少消息表示的字节数与通用压缩是两种机制,本轮只测前者,没有做压缩方案间的优劣比较。
内部队列保留对象,到了连接边界再编码
最初的问题还包括“Protobuf 能不能减少内部队列解析”。对于进程内队列,更直接的办法是让公共事件以 Rust 对象通过队列,避免为了内部传递先编码成字节、下游又解码。
牌桌任务生成公共事件
→ Arc<PreparedEvent> 共享到各连接队列
→ 按连接协议首次生成并缓存 JSON / Protobuf
→ 写出 WebSocket 消息
→ H5 解码为现有 ServerMsg,交给原有状态处理逻辑
server/src/outbound.rs 中的 PreparedEvent 持有原始消息,并用两个 OnceLock 缓存不同格式。多个接收者共享同一事件;同一格式的编码结果可以复用。纯 Protobuf 公共事件的发送路径不必先生成 JSON;如果其他用途需要 JSON,例如按原有预算计算排队字节,缓存仍可按需生成。
这是减少重复序列化的机制,并非零拷贝:交给各 socket 时,当前实现仍会复制已编码的字节。牌桌可变状态仍由自己的任务持有,共享的是已准备好的公共事件。含私有内容的消息没有进入这条公共事件捷径,队列背压、结算预算和会话隔离也继续保留。
这部分改动没有独立的生产开关对照。因此,本文的 46% 是协议 A/B 的整条 WS 载荷结果;我们没有测出“单独去掉内部解析”能提升多少吞吐。
省字段之前,先保住零值、缺失值和筹码精度
二进制解码成功,不等于牌桌状态正确。座位 0 是真实座位,不能因为判断真假而变成“没有下一行动者”;min_raise_to: null 与数值 0 也不能互换。边池缺失和存在但为空,同样需要按现有客户端契约还原。
protocol/realtime.proto 用 optional 表达需要区分缺失的数值字段,用消息包装边池列表。H5 在 client-game/src/realtime.ts 把解码结果恢复为原有 ServerMsg 结构。对 64 位无符号数,转换为 JavaScript 数字时检查安全整数范围;越界就拒绝该帧,不能悄悄把筹码或时间戳四舍五入。
Rust 与 TypeScript 使用同一组 protocol/realtime-fixtures.json 对照样本,验证真实字节与恢复后的语义;另外保留真实 WS 协商、队列背压、隐私和结算路径的针对性检查。字段编号也成了长期契约:后续不能随意换号或把已删除字段的编号赋给其他含义。
代价是多了一份 schema、生成代码、解码适配层和跨语言样本。浏览器网络面板里的二进制也不如 JSON 直观,需要带解码器的工具。我们让压测器复用 H5 解码器,并让抓包分析支持二进制动作关联;否则协议换了,观测工具可能把事件漏掉,反而报出虚假的低流量或低延迟。
这次生产 A/B 怎样运行
四次有效工作量测试都使用提交 ddb6c724 的生产服务:50 桌和 100 桌各一组紧凑 JSON / Protobuf 对照。每桌 9 个已认证模拟玩家,目标每桌每秒 5 次动作机会,执行过牌或跟注;换手或前一动作尚未确认时,不强行发送无效操作,所以目标机会数不等于实际完成动作数。
每轮预热 10 秒,目标测量 60 秒。50 桌同时提供 500 REST 请求/s,100 桌提供 1,000 请求/s,全部查询已入座牌桌状态 /api/lobby/active。两种协议都关闭 WS 通用压缩,模拟账号的访问地址用于分摊正常的每地址准入预算。这是指定接口与牌局节奏的混合负载,不能外推到任意 REST 接口组合。
压测器以较低调度优先级运行在生产宿主机上,通过私有网络直连游戏容器。宿主机为 2 vCPU、约 3.6 GiB 内存。这个路径绕开公网、TLS 与反向代理,有利于比较相同服务版本的载荷,但压测器也与服务争用主机资源。900 连接是本次私有路径的测试规模,不能解释为 900 个公网玩家的体验保证。
四轮均完成计划请求,连接保持到结束、所有桌持续活动、动作得到确认,落库和退出座位检查通过,未记录应用错误。测试结束后,900 个临时账号及其相关测试数据已清理。每个规模只做一组顺序 A/B,没有随机交错或多轮置信区间,因此毫秒级差异仍需重复验证。
吞吐持平,延迟证据要分开看
减少传输字节给出口留出了余量,但这次提供的负载是固定的,并没有一直加压到饱和。两种协议都完成相近的动作量,因此不能把“字节减少 46%”改写为“吞吐提高 46%”,也没有证据声称最大承载人数按比例增加。
50 桌两轮抓包没有丢包,请求和同桌九人的动作广播关联完整。服务端 WS P99 从 2.9418 ms 降到 2.2299 ms,约下降 24%;同轮客户端端到端 P99 却更高:
| 50 桌 / 450 连接,P99 | 紧凑 JSON | Protobuf |
|---|---|---|
| 服务端收到动作 → 九人广播写出 | 2.9418 ms | 2.2299 ms |
| 客户端计划动作 → 九人收齐 | 34.585 ms | 57.718 ms |
| 客户端 REST 端到端 | 64.943 ms | 145.098 ms |
服务端边界从完整动作请求的最后一个字节进入测量接口起,到关联广播向九个接收者全部写出止;不是只计业务处理器,也不等待远端确认。端到端计时还包含压测器计划发送的等待与接收处理。同机争用和生成器开销会影响结果,但这轮没有把各自的影响独立分离,所以不能据此声称玩家操作延迟改善。
100 桌时把抓包解析移到外部机器,采集仍发生内核丢包和关联缺口。那两轮的抓包延迟验收未通过,本文不采用它们的服务端 P99。流量和动作量来自客户端完整计数,仍可使用;不能把这部分有效结果说成整轮所有验收都通过。
85% 与 46% 的差别,决定下一步优化哪里
在 100 桌的紧凑 JSON 轮次,四类迁移事件合计占约 54.37 MB,约为全量 WS 载荷的 52.2%;Protobuf 轮次它们约为 7.84 MB,减少约 85.6%。两轮事件数并不完全相同,这只是该负载下的类别汇总比较。
只优化约一半流量,即使那一半缩得很多,整条流也不会减少 85%。其余 JSON 里,快照、房间状态和排名仍然占据不少字节。按这个占比粗估,52.2% × 85.6% ≈ 44.7%,与实测整流约 46% 接近;差异来自两轮事件组成和实际工作量。这也是为什么只展示最小的一条 Protobuf 消息,会高估业务收益。
| 本轮选择 | 获得什么 | 承担或保留什么 |
|---|---|---|
| 四类公共事件先迁移 | 覆盖高频广播,验证范围可控 | 混合 JSON / 二进制协议,仍有大消息未优化 |
| 进程内共享事件,延迟编码 | 复用同一广播的编码结果 | 每 socket 仍复制字节;缺少这部分独立收益测量 |
| 保留现有 H5 状态契约 | 不用重写牌桌状态处理逻辑 | 需要维护 schema、生成代码和语义适配 |
| REST 保持 JSON | 控制迁移范围,保留现有接口工具链 | 这次没有 REST 载荷减少的成果 |
Android 和 iOS 的默认牌桌都嵌入远程 H5,因此这一改动随 H5 加载生效,不需要为了牌桌编解码重新发商店包。但已打开的页面不会凭空换成新代码;连接仍显式协商格式,服务端也保留 JSON 路径。我们没有为这次任务改写原生 Dart 协议消费者。
在自己的项目里复用这套方法
- 先按消息类型计字节。记录全部流量和每次有效业务动作的成本,避免只看精挑的编码样本。
- 从公共、高频、语义稳定的事件开始。先写清缺失值、零值、整数范围与权限投影,再改变表示。
- 进程内尽量保留类型化对象。只有确实需要跨网络或持久化的边界才编码;同时保留队列预算和背压。
- 同一版本切换协议做对照。固定负载和路径,校准生成器,同时检查动作确认、错误、断线和数据落库。
- 把载荷、吞吐和延迟分别验收。采集不完整就停止解释那项指标;固定负载通过后,若要宣称容量增加,还需要独立压测机上的重复阶梯测试。
这次已经量到的成果是:在指定的生产私有路径负载下,相对紧凑 JSON,每次动作的 WS 下行载荷减少约 46%。下一步若继续优化,应先检查剩余快照与排名的字节分布,再比较扩大二进制范围的收益和复杂度;这些方向尚未包含在本次结果里。