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 |
| 动作往返 p99 | 1.2 ms |
| 应用层 JSON 下行 | 10,177,073 B |
| 平均应用层下行 | 约 4.07 Mbit/s |
| 生成器 event-loop p99 | 11.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_rules和time_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_applied 加 snapshot 占了 52.07% 的优化后字节。因此如果做 compact-v2,应该给高频消息的类型、字段和枚举值建立一整套版本化短码表,由 H5 还原为现有内部结构,然后再用同一 workload A/B;只改两个长词不会带来“很多”额外节省。
这里最重要的设计原则不是「字段越少越好」,而是:服务端每省掉一个字段,客户端都必须有唯一、可测试的恢复规则。否则省下的是带宽,欠下的是状态分叉。
同一 1000 桌负载,结果怎样
改动后重新构建 release 二进制,仍用 2 个 Tokio worker、4×250 个对齐分片和同一个 20 秒稳定窗口:
| 指标 | 改前 | 改后 | 变化 |
|---|---|---|---|
| 完成手数 | 1867 | 1892 | +1.3% |
| 应用层 JSON 字节 | 10,177,073 | 7,912,044 | −22.3% |
| 每完成一手的字节 | 5451 | 4182 | −23.3% |
| WebSocket 帧 | 55,285 | 45,222 | −18.2% |
| 动作往返 p99 | 1.2 ms | 1.2 ms | 不变 |
| 提前断线 | 0 | 0 | 不变 |
| 生成器 event-loop p99 | 11.6 ms | 11.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 的新拐点,才叫容量提升。
一套可以复用的性能优化闭环
- 先写 workload。连接数不够;要写清每连接对应多少 session、bot、动作频率和稳定窗口。
- 把压测结果写成假设。CPU、DB、队列、出口分别有什么证据?没有隔离的机制只能叫 leading hypothesis。
- 给瓶颈资源加计量。怀疑出口,就记录帧、字节和消息类型;只看延迟无法告诉你该删哪一笔成本。
- 先固定兼容与安全边界。新协议显式协商、旧客户端不变、重连自洽、隐私投影优先。
- 用同一窗口做前后对照。同时看原始吞吐、单位工作量成本、p99、错误和生成器自身延迟。
- 最后写清未证明项。本地通过不是生产通过;字节下降不是容量已经按比例上涨。
总结
- 生产压测把问题缩到了出站链路,但没有直接证明具体机制。
- 本地 1000 桌基线证明调度仍稳定,同时暴露出约 4.07 Mbit/s 的应用层 JSON 下行。
- 显式协商的
compact-v1通过默认值省略、静态字段合并和无作用帧去重,在保持旧客户端协议不变的前提下降低流量。 - 同负载复测:原始字节 −22.3%,每手字节 −23.3%,帧数 −18.2%;动作 p99 和断线数不变。
- 这完成的是「发现瓶颈 → 优化 → 同负载验证」的一轮,不是生产 1000 桌的证明。下一轮要回到真实 2 Mbps 出口复测容量拐点。