← 返回博客2026-08-24

WebSocket 为什么堵:2 Mbps 出口嫌疑与 22.3% 减流实测

生产 300 桌出现 199 次发送队列淘汰,CPU 和数据库却未满;同代码本地跑 1000 桌时动作 p99 仅 1.2 ms,JSON 下行却要 4.07 Mbit/s,超过生产 2 Mbps 出口。因此瓶颈先锁定出站链路,再用 compact-v1 把同负载字节从 10.18 MB 降到 7.91 MB;具体单点与生产新容量仍待复测。

先回答:瓶颈、改动、证据

问题直接答案
瓶颈在哪里?已定位到生产出站链路,尚未证明唯一单点。300 桌时出现 199 次发送背压淘汰,CPU 和 Postgres 却未满;同代码在本地用 2 个 Tokio worker 跑 1000 桌仍是 1.2 ms p99,但仅 JSON 就要 4.07 Mbit/s,高于生产固定的 2 Mbps 公网出口。这排除了“200 桌就是 CPU 极限”,但还没把 Caddy、TLS、socket 队列与 2 Mbps 限速完全分开。
做了什么优化?H5 显式协商 wire=compact-v1:服务端不再重复发送默认值、未变的静态字段和对客户端无作用的帧;H5 按确定规则恢复完整状态。没有加大队列,也没有开通用 WebSocket 压缩。
怎么证明生效?在同一本地机器、同一 release 配置、同一 1000 桌 workload 和同一 20 秒窗口做 A/B:JSON 从 10,177,073 B 降到 7,912,044 B(−22.3%),每完成一手从 5451 B 降到 4182 B(−23.3%);动作 p99 仍为 1.2 ms,提前断线仍为 0。这证明“少发字节”生效,不证明生产容量已从 200 桌变成 1000 桌。

一句话:服务端算得动,但生成的下行 JSON 比 2 Mbps 出口发得快;这一轮先减少每手要发的字节,下一轮才能回生产确认真正的新拐点。

压测留下的,不该只是一条容量数字

上一篇压测测到的现象很明确:2 核生产机上,150 个单人练习桌还在延迟预算内,200 个开始超预算,300 个的 p99 冲到 4.1 秒。崩塌那一档还出现了 199 次出站背压淘汰。

但 CPU 没跑满,Postgres 也没跑满。这个结果只能支持一个待验证假设:问题更像出站发送链路,而不是算力或数据库。它还不能回答两个关键问题:

  • 卡住的是 Tokio 调度、JSON 序列化、每连接队列,还是生产机固定的 2 Mbps 公网出口?
  • 即使找到了主因,代码改动到底省了多少?容量边界有没有真的移动?

所以压测不能以「300 崩了」收尾。下一步必须把模糊的“出站压力”拆成可计量的帧数、字节数和消息类型,再让改前改后承受同一组负载。

先给真正稀缺的资源装上仪表

负载生成器原来会记录动作延迟、断线和完成手数,却不知道服务器到底发了多少数据。我们给每条收到的 WebSocket 文本帧计算 UTF-8 字节数,按 kind 累加,并把爬坡阶段与稳定窗口分开。四个分片共用同一个未来时间戳打开 20 秒窗口,避免把不同时间段的百分位和流量硬拼在一起。

然后先不改协议,跑一个本地隔离基线:release 二进制、2 个 Tokio worker、4 个各 250 连接的分片,共 1000 个同时打牌的单人练习桌,每桌 1 个真人模拟器加 5 个 bot。

基线指标结果
WebSocket 连接 / 提前断线1000 / 0
20 秒内完成手数1867
动作往返 p991.2 ms
应用层 JSON 下行10,177,073 B
平均应用层下行约 4.07 Mbit/s
生成器 event-loop p9911.6 ms

这一步改写了问题:同一套服务端代码在本机没有遇到 200 桌的调度上限,1000 桌仍能保持 1.2 ms p99;但光 JSON payload 就需要约 4.07 Mbit/s,已经是生产实例固定公网出口的两倍。“200 是 CPU 极限”不成立;出口字节成了第一轮最值得优化的对象。

为什么没有直接加队列或打开通用压缩

队列变大只会让拥塞排得更久:出口不变,内存占用和尾延迟反而继续涨。通用 WebSocket 压缩虽然可能省更多字节,却会把秘密牌面和用户可控文本放进压缩上下文;牌类应用不该为了一个漂亮数字,轻率增加压缩侧信道的攻击面。

我们也不能直接改变现有 JSON 的缺省语义。Flutter、旧版 H5 和未知客户端仍在消费完整协议;如果服务端单方面开始省字段,节省下来的字节会变成兼容性事故。

最终方案是一个显式协商的下行 profile:

GET /ws?session_id=...&role=player&wire=compact-v1
  • 只有当前 H5 主动带上 wire=compact-v1,服务器才走紧凑路径。
  • 参数缺失或未知时,旧路径继续原样转发完整 JSON。
  • 每个 socket 拥有自己的紧凑状态;重连会从空缓存开始,第一份状态不依赖旧连接。
  • 观众身份和暗牌隐私投影先执行,字段省略后执行;优化不能绕过信息边界。

少发字节,但不改变牌桌状态

compact-v1 没有重新设计整套协议,只处理那些「缺失语义」能够写成确定规则的内容:

  • 默认值省略。snapshot 里的 0 底池、0 当前下注、空公共牌、空聊天,以及玩家的 0 本街投入、0 待入池筹码和 folded=false 不再反复发送。H5 把缺失值恢复成 0、空数组或 false。
  • 静态字段去重。同一连接后续快照可省略未变化的玩家名和累计买入;排名消息可省略未变化的名称、真人标志、累计买入和在桌状态。动态的筹码、盈亏、手数仍逐次发送。
  • 无作用帧直接丢弃。发给非持有者的 hole_cards_dealt 原本只有 cards:null,H5 收到后什么也不做;紧凑路径不再发送这类帧。真实两张底牌从不省略。
  • 幂等状态去重。room_rulestime_bank 只有在内容与上一帧完全相同时才被丢弃;任何变化都会完整发送。会释放乐观锁或触发一次性副作用的消息不参与去重。

能不能把长字段改成一个字母?

可以。例如把消息类型 hole_cards_dealt 编成 h,每次可少 15 字节;把字段名 folded 编成 f,每次可少 5 字节。但 compact-v1 先做了更大的事:对非持有者的 hole_cards_dealt 无作用帧整条不发,folded=false 整个字段也不发。

优化后的 7,912,044 B 中,剩余全部 hole_cards_dealt 消息只有 61,100 B,占 0.77%。即使这一类能全删掉,理论上限也只有 0.77%;只缩短其中的类型名,收益必然更小。相比之下,action_appliedsnapshot 占了 52.07% 的优化后字节。因此如果做 compact-v2,应该给高频消息的类型、字段和枚举值建立一整套版本化短码表,由 H5 还原为现有内部结构,然后再用同一 workload A/B;只改两个长词不会带来“很多”额外节省。

这里最重要的设计原则不是「字段越少越好」,而是:服务端每省掉一个字段,客户端都必须有唯一、可测试的恢复规则。否则省下的是带宽,欠下的是状态分叉。

同一 1000 桌负载,结果怎样

改动后重新构建 release 二进制,仍用 2 个 Tokio worker、4×250 个对齐分片和同一个 20 秒稳定窗口:

指标改前改后变化
完成手数18671892+1.3%
应用层 JSON 字节10,177,0737,912,044−22.3%
每完成一手的字节54514182−23.3%
WebSocket 帧55,28545,222−18.2%
动作往返 p991.2 ms1.2 ms不变
提前断线00不变
生成器 event-loop p9911.6 ms11.6 ms不变

原始窗口已经少了 22.3% 字节;考虑改后多完成了 25 手牌,按每手归一化后的降幅是 23.3%。动作尾延迟和稳定性没有退步,说明这不是用少做事换来的数字。

改后的应用层 JSON 平均约 3.16 Mbit/s。它比改前低,但仍高于生产实例的 2 Mbps 固定出口,而且还没计 WebSocket、TCP、TLS 和反向代理开销。

这不是「生产从 200 优化到 1000」

最容易写错的结论,恰好也是最吸引人的结论:代码优化前,生产 200 桌开始卡;优化后,本地 1000 桌通过;所以容量从 200 涨到了 1000。

这三个数字来自两套不同环境,不能这样相减。事实上,改动前的本地代码已经能稳定跑 1000 桌。这次优化没有把本地上限从 200 推到 1000;它证明了本地调度不是生产悬崖的充分解释,并把同一负载的应用层出站流量压低了约 22%。

生产 1000 桌仍未证明,当前数字甚至明确提醒我们不要这样声称:3.16 Mbit/s 的应用层 payload 仍超过 2 Mbps 出口。真正的下一步是在代码部署后,用原来的生产阶梯复测 150、200、300 及更高档位,同时观察实际网卡、Caddy 队列、fanout_backpressure_evict 和动作 p99。只有同一环境、同一 workload 的新拐点,才叫容量提升。

一套可以复用的性能优化闭环

  1. 先写 workload。连接数不够;要写清每连接对应多少 session、bot、动作频率和稳定窗口。
  2. 把压测结果写成假设。CPU、DB、队列、出口分别有什么证据?没有隔离的机制只能叫 leading hypothesis。
  3. 给瓶颈资源加计量。怀疑出口,就记录帧、字节和消息类型;只看延迟无法告诉你该删哪一笔成本。
  4. 先固定兼容与安全边界。新协议显式协商、旧客户端不变、重连自洽、隐私投影优先。
  5. 用同一窗口做前后对照。同时看原始吞吐、单位工作量成本、p99、错误和生成器自身延迟。
  6. 最后写清未证明项。本地通过不是生产通过;字节下降不是容量已经按比例上涨。

总结

  1. 生产压测把问题缩到了出站链路,但没有直接证明具体机制。
  2. 本地 1000 桌基线证明调度仍稳定,同时暴露出约 4.07 Mbit/s 的应用层 JSON 下行。
  3. 显式协商的 compact-v1 通过默认值省略、静态字段合并和无作用帧去重,在保持旧客户端协议不变的前提下降低流量。
  4. 同负载复测:原始字节 −22.3%,每手字节 −23.3%,帧数 −18.2%;动作 p99 和断线数不变。
  5. 这完成的是「发现瓶颈 → 优化 → 同负载验证」的一轮,不是生产 1000 桌的证明。下一轮要回到真实 2 Mbps 出口复测容量拐点。