2 核生产机实测:150 人通过预算,200 开始卡,300 崩塌
直接给数字:腾讯云 SA2.MEDIUM4,2 vCPU、3.6 GiB、固定公网出带宽 2 Mbps,Postgres 与 Caddy 同机。150 人通过延迟预算,200 人超预算,300 人 p99 达 4.1 秒;这不是结案,而是定位出站瓶颈、优化代码并用同一阶梯复测的起点。
先给答案:150 人通过预算,200 开始卡,300 崩塌
2026 年 8 月 21 日,我们从公网直接压 app.bluffking.ai,走真实 DNS、Caddy 与 TLS。负载用的是「1 个真人独占一桌 + 5 个 AI」——每位真人都要占一个 session task,是单人练习里最贵的形态。本文把「不卡」定义为服务端出手往返 p99 不超过 150 ms。
结论不绕弯:150 人 / 150 桌通过预算;200 人 / 200 桌开始超预算;300 人 / 300 桌进入崩塌,p99 约 4.1 秒。我们测的是阶梯,所以严谨说法是:健康上界至少到 150,拐点落在 150–200 之间;不是声称第 151 人一定会卡。
| 测试对象 | 生产实值 | 这意味着什么 |
|---|---|---|
| 云主机 | 腾讯云 CVM SA2.MEDIUM4,香港 | KVM;不是本地模拟机 |
| CPU | 2 vCPU · AMD EPYC 7K62 | app 没有单独 CPU quota,与 Postgres、Caddy 共用两核 |
| 内存 | 3.6 GiB 可用 | app 容器硬上限 3 GiB;Postgres 与 Caddy 使用余量 |
| 网络 | 固定公网出带宽 2 Mbps | 压测走公网,客户端 RTT 约 45 ms;理论出站上限约 250 kB/s |
| 网络缺口 | 测试窗口的实际出带宽 / 利用率未采样 | 知道套餐上限,不等于知道 300 桌崩塌时是否已经把它打满 |
2 Mbps 是这台生产机的已购公网出带宽,不是网卡标称速率。把它平均分给 300 条连接,只有约 6.7 kbps、也就是 0.83 kB/s / 连接,还没扣 TLS、TCP 与 WebSocket 开销。本轮没记录每秒实际出站字节,所以这次实验回答了「多少玩家时牌桌延迟坏掉」,却没有证明崩塌究竟是 app 广播调度先到极限,还是 2 Mbps 公网出口先被打满。
第一步:先决定「多慢算慢」,而且要分场景
用一个统一的延迟目标,是最常见的错误,而且它同时错在两头。我们一开始的假设也是「一律控制在 500 ms 以内」。这个标准既太松,又太紧。
同样的毫秒,用户在等什么,代价完全不同:
| 用户在等什么 | 我们定的预算 | 锚在哪里 |
|---|---|---|
| 我点了跟注 → 我的筹码动了 | 服务端 150 ms | 15 秒出手倒计时的 1%;决定牌桌「活不活」的就是它 |
| 别人出手 → 我看到 | 500 ms | 没有人按着按钮等这个 |
| 加载历史牌局列表 | 500 ms | 我们的 Web 客户端根本没设 fetch 超时,慢请求不会失败,只会把界面挂住 |
| 登录(内存硬化密码哈希) | 1500 ms | 每个会话只付一次,而且哈希参数已经在安全下限 |
注意每条预算的来源:都是代码里已经存在的常量,不是口味。如果你指不出预算是从哪个东西推出来的,那你手里的就不是预算,而是偏好——而偏好在凌晨两点的争论里是站不住的。
对我们来说结论很直接:500 ms 对一次 API 读取大致合适,对出手往返则松了大约 3 倍。用错误预算测出来的上限,不是「保守估计」,而是关于另一个问题的另一个数字。
第二步:我们用三种方式测错了机器
我们测的是涌入过程,不是稳态
第一轮我们连上 N 个客户端就开始计时。那测的是 N 个用户正在到达——这确实是个真实且值得测的事件,但它和 N 个用户已经在线不是一回事,而决定「能装下多少人」的是后者。
给每一级加上 20 秒热身再清空样本之后,差别在同一次运行内部就看得见。200 张桌子时,涌入窗口和稳态窗口差了一半:
200 张桌,同一次运行:
涌入窗口 p99 379.9 ms
稳态窗口 p99 246.4 ms
ADR-105 的阶梯表只收录了稳态窗口那个数。涌入窗口的数值是压测工具对同一次运行热身窗口的报数,那一轮的原始输出没有作为已发布证据保留下来——请把它当作两个窗口差距的量级,而不是可引用的阶梯数据。
报第一个数,你会低估容量;报第二个数却不说明,你会被每一次市场推广打个措手不及。两个都测,两个都标清楚。
我们测的是自己的笔记本
第一轮阶梯跑到一半,尾延迟开始爬,而服务器 CPU 纹丝不动。压测端是开发机上的单个 Node 进程——而那台机器上,有个卡死的辅助进程正在吃满一个核。
修法不是「以后小心点」,而是:压测工具必须把自己的健康状况作为一等输出报出来。我们现在每轮都记录事件循环延迟:
generator_event_loop_delay_ms: { mean: …, p50: …, p99: 11.6, max: … }
// 空机 300 桌对照轮,2026-08-21(见 ADR-105)
正是这一行,让你有资格说「这些数字是关于服务器的」。从 50 到 300 客户端,它稳定在 p99 约 12 ms、一路平坦,且远低于报告的 p99——所以测到的排队是服务器的。没有它,任何尾延迟数字都是一个你无法自证的指控。
我们测的是一台还挤满幽灵的机器
玩家关掉标签页后,我们的服务器会把他的牌桌再留 10 到 20 分钟,才由 idle reaper 回收。作为产品决策这很合理——人会重连。但它也是个测量陷阱:跑完四轮阶梯之后,机器上挂着大约 800 张被遗弃的牌桌,安静地吃掉两个核的约 7%。
然后做那件把「怀疑」变成「事实」的事:我们在一台完全空的机器上,把崩塌的那一级又跑了一遍——运行前几秒在数据库里确认活跃牌桌为 0。结果一模一样。幽灵是真的,但它不是原因。对照实验很便宜;一个没被检验过的混淆因素,会被人引用很多年。
两个教训,第二个才是有用的那个。显然的一条:把数字归因给你的负载之前,先看清楚机器上本来在跑什么。不那么显然的一条:这份幽灵负载在生产环境同样真实存在。一个繁忙的小时之后,会残留一批没人坐的牌桌,它们和活桌占用同一个 runtime。任何只统计活跃用户的容量上限,都会在最需要它的那个高流失小时里失准。
完整阶梯:150 通过,200 超预算,300 崩塌
下面是阶梯数据,单人桌(1 个真人 + 5 个机器人,我们「每人成本」最贵的形态),从约 45 ms 之外的客户端经 TLS 端到端测量:
| 活跃牌桌 | 出手 p50 | 出手 p99 | 服务端 p99 | 应用 CPU |
|---|---|---|---|---|
| 50 | 43.0 ms | 115.9 ms | 70 ms | 15 % |
| 100 | 43.2 ms | 115.0 ms | 72 ms | 24 % |
| 150 | 43.9 ms | 161.4 ms | 118 ms | 34 % |
| 200 | 44.3 ms | 246.4 ms | 201 ms | 41 % |
| 300 | 85.6 ms | 4116 ms | 4071 ms | — 崩塌 |
把最后两行放在一起读。从 200 到 300,p99 涨了十七倍,而且牌桌基本停止推进:同样 60 秒的窗口,200 桌时完成了 697 手牌,300 桌时只完成 148 手;上一级采到 2994 个延迟样本,这一级只采到 90 个。而此时大约一个半核是空闲的。
内存也没有接近机器上限。另一次带主机采样的验证中,150 桌时 app RSS 峰值 84 MB,200 桌时 97 MB;空机器 300 桌对照在中止前 app RSS 为 53 MB。这里的数字不能跨轮次做线性内存模型,但足以排除「3.6 GiB 已经耗尽」这个解释。
日志把故障位置缩到「出站交付」:300 桌崩塌窗口出现了 199 次 fanout_backpressure_evict。这项计数能证明容量为 64 帧的出站队列触发了背压路径;单凭它不能证明当轮客户端实际经历了哪种恢复。同期压测报告把现象写成丢帧,而今天的服务端路径会驱逐连接、请求关闭,再让客户端重连并从权威 Snapshot 同步。压测窗口没有另行保存与版本绑定的关闭路径证据,所以本文不把那次历史观察改写成已证实的重连。数据库连接池等待峰值约 100 ms,Postgres 没超过约 0.1 核;压测端事件循环 p99 仍约 12 ms。还不能继续缩到唯一根因:app 广播调度会让队列填满,2 Mbps 公网出口饱和也会让同一队列排不出去,而本轮没有采出带宽利用率。背压是已证实的症状;在压测当天,调度与公网限速是两个尚未 A/B 隔离的候选原因。
两条可以直接拿走的结论:
- 基于平均 CPU 的扩容或告警规则,不会在用户掉进崩塌之前触发。我们那条规则在最后一级健康档位是舒舒服服的绿色,在尾延迟已经是秒级时依然是绿色。告警要打在尾延迟、队列深度或拒绝计数上,而不是机器「看起来有多忙」。
- 在悬崖附近,从健康点外推毫无价值。200 桌时 41% 的 CPU,并不意味着 480 桌可行。这个关系不是线性的,也不会优雅退化;它先撑住,然后直接倒。
压测只完成了一半:接下来要让拐点右移
如果工作停在「把新连接限制在 48」这里,我们得到的只是保护措施,不是性能优化。压测的目的不是给机器贴一张「最多 150 人」的标签,而是找出延迟从平稳变成崩塌的位置,再用技术手段把这个位置往右推。真正的目标是:在同一台机器、同一种负载和同一套延迟预算下,让更多玩家保持流畅,同时不牺牲牌局正确性和隐藏牌安全。
代码里确实还有优化空间,但不能靠猜。每条连接目前都有一个容量为 64 帧的出站队列(server/src/ws.rs:758);session task 用非阻塞的 try_send 投递,队列满时就驱逐连接并让客户端重连、用权威 Snapshot 恢复(server/src/session.rs:13008-13034)。把 64 简单改成 128 或 256,只会让更多旧消息排队,既没有提高发送速度,也没有增加 2 Mbps 出口;它可能推迟报错,却不是根治。
现有热路径也不是完全没有优化。对于 ActionApplied、PotUpdated 等所有人收到相同内容的事件,代码已经改成只转换、序列化一次,再把结果分发给各连接(server/src/session.rs:13116-13141)。8 人桌微基准从 1.382 µs 降到 182 ns,约快 7.6 倍。既然这项优化已经存在,下一轮就不该再泛泛地说「减少 JSON 序列化」,而应该继续追踪消息离开 session task 之后为什么排不出去。
- 先补齐观测。按
ServerMsg类型记录帧数和字节数,再记录每条连接的队列最高占用、ws_tx.send耗时,以及主机每秒实际出站字节。当前 writer 会逐帧完成投影并写入 WebSocket(server/src/ws.rs:1087-1177);没有这些数据,就无法判断卡在帧太多、单帧太大、投影调度,还是公网限速。 - 做两个能判因果的 A/B。同一个二进制先在高带宽或内网路径重跑 300 桌:如果崩塌消失,先处理公网出口与消息字节数。再做一个实验分支,把一次出手紧邻产生的
ActionApplied与PotUpdated合并成更少的传输帧:如果队列背压明显下降,帧数和调度就是主要方向。这两项目前都是待验证实验,不是已经上线的结论。这是 2026 年 8 月 24 日写下本节时的状态。当天稍晚,同一份代码在本地回环、限定 2 个 Tokio worker 的条件下跑到 1000 个单人桌仍未崩塌:动作 p99 1.2 ms,提前断线 0;但应用层 JSON 下行约 4.07 Mbit/s,已经超出生产 2 Mbps 出口的排空能力。随后,在同一本地负载下把下行字节减少 22.3%、帧数减少 18.2% 的紧凑协议已随 web-2026.08.24.7 上线生产(ADR-106,详见《WebSocket 为什么堵》)。这两次本地运行都不是上文的生产 A/B,生产阶梯也尚未复测。 - 只优化数据指向的路径。如果受限于字节数,就先找出最大的消息类型;Snapshot 仍要保留为重连时的权威状态,只检查普通状态转换中哪些完整 Snapshot 能安全地换成更窄的增量事件。如果受限于帧数或调度,就优先批处理不含用户私密差异的事件,不能为了速度绕过按用户隐藏底牌的投影。
- 用同一阶梯复测。优化后仍跑相同的 150 / 200 / 300 桌、相同 RTT、相同热身时间和相同 150 ms 预算。只有健康上界提高、p99 降低、
fanout_backpressure_evict减少,而且牌局与隐藏牌回归测试保持通过,才算真的优化成功。
这才是完整闭环:压测找到拐点,观测把症状缩到具体路径,代码改动只打那个路径,最后用原实验复测。微基准更快、CPU 更低或队列更大都不能单独算成功;玩家能承受的负载变高才算。
压测当天,线上到底放多少人进来?
机器极限和生产运行点是两个数字。2026 年 8 月 21 日部署的默认值是:48 个新 WebSocket,再为重连或加入已有牌桌保留 12 个名额。也就是说,当天正常新流量不会被放到 150 人的实测边缘,更不会让用户替我们发现 300 人的崩塌点。每个 IP 另限 12 条连接,每个账号限 4 条。它们是这次压测与 ADR-105 落地时的历史快照;以后调整线上常量,不会倒过来改写这次实验。
为什么不是把 solo 实测的 150 乘 80% 得 120?因为 6 人桌是另一种形态:session task 少了,但每次动作要向五个同桌广播。最初两级公网实测得到:
| 6 人桌负载 | 出手 p99 | 服务端 p99 | CPU | app RSS | 判定 |
|---|---|---|---|---|---|
| 60 人 / 10 桌 | 203.0 ms | 157 ms 另一轮 69 ms | 30.2% | 94 MB | 两轮跨过预算线 |
| 120 人 / 20 桌 | 384.1 ms | 339 ms | 27.2% | 91 MB | 明确超预算 |
60 人这一级其实跑过两轮:表中的主机采样轮服务端 p99 为 157 ms,另一轮为 69 ms;它不是一个稳定复现的「60 人必卡」结论。当前 48 的新连接运行点仍按较差的 157 ms 一轮保守取 60 人的 80%,属于生产保护线,不是机器只能服务 48 人。另一个默认值 ADMISSION_MAX_LIVE_TABLES=2000 也不是玩家容量:它把离场后等待 10–20 分钟回收的空桌一起算进去,只是防止程序 bug 或脚本无限开桌的内存保险丝。
数字复盘
- 机器:腾讯云 SA2.MEDIUM4;2 vCPU、3.6 GiB;app 上限 3 GiB;Postgres 与 Caddy 同机;固定公网出带宽 2 Mbps。
- solo 实测:150 人通过;200 人超预算;300 人崩塌到 4.1 秒 p99。已证明的拐点区间是 150–200,不是一个虚构的精确整数。
- 多人桌实测:60 人 / 10 桌两轮服务端 p99 分别为 69 ms 与 157 ms,跨过预算线;120 人 / 20 桌为 339 ms,明确超预算。
- 2026-08-21 生产运行点:48 个新连接 + 12 个返回连接预留;这是当日保护线,不是硬件极限。
- 仍未测:崩塌窗口的实际公网出带宽 / 利用率,以及不同地区、不同 RTT、混合真实流量下的容量。没有数据就明确留白。
这些数字不是结案报告,而是优化工作的起点:它们给出机器、负载、预算、健康档、超预算档、崩塌档和线上保护线,也指出下一步该补哪些观测、该怎样隔离瓶颈。只有代码改完后用同一阶梯证明拐点右移,压测才真正完成了使命。