← 返回博客2026-08-21

悬崖不在 CPU 那里:给一台真实生产机测容量

我们把一台 2 vCPU 的生产机压到崩塌,为了回答「能同时装下多少人」。答案有用,但更有用的是我们第一次测错的三种方式:测了涌入而不是稳态、测了自己的笔记本、测了一台还挤满幽灵牌桌的机器。以及那个反直觉的结论——尾延迟涨十七倍时,还有一个半核是空闲的。

一个大家都靠猜回答的问题

「我们的服务器能扛多少人?」大概是少数几个「答得越自信、越可能是编的」的工程问题。诚实的版本有三段,而大多数团队只说了一段:多少人,在做什么,到哪个指标变坏为止。

我们在一台很小的机器上跑一个浏览器德州扑克服务:2 vCPU、3.6 GiB、Postgres 同机、Caddy 终止 TLS。昨晚我们不猜了,直接压——打的是真实生产域名,不是 loopback 模型。这个数字对我们有用;而我们第一次测错的那三种方式,对所有人都有用。

第一步:先决定「多慢算慢」,而且要分场景

用一个统一的延迟目标,是最常见的错误,而且它同时错在两头。我们一开始的假设也是「一律控制在 500 ms 以内」。这个标准既太松,又太紧。

同样的毫秒,用户在等什么,代价完全不同:

用户在等什么我们定的预算锚在哪里
我点了跟注 → 我的筹码动了服务端 150 ms15 秒出手倒计时的 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

报第一个数,你会低估容量;报第二个数却不说明,你会被每一次市场推广打个措手不及。两个都测,两个都标清楚。

我们测的是自己的笔记本

第一轮阶梯跑到一半,尾延迟开始爬,而服务器 CPU 纹丝不动。压测端是开发机上的单个 Node 进程——而那台机器上,有个卡死的辅助进程正在吃满一个核。

修法不是「以后小心点」,而是:压测工具必须把自己的健康状况作为一等输出报出来。我们现在每轮都记录事件循环延迟:

generator_event_loop_delay_ms: { mean: 11.0, p99: 12.5, max: 15.1 }

正是这一行,让你有资格说「这些数字是关于服务器的」。从 50 到 300 客户端,它一路平坦,且远低于报告的 p99——所以测到的排队是服务器的。没有它,任何尾延迟数字都是一个你无法自证的指控。

我们测的是一台还挤满幽灵的机器

玩家关掉标签页后,我们的服务器会把他的牌桌再留 10 到 20 分钟,才由 idle reaper 回收。作为产品决策这很合理——人会重连。但它也是个测量陷阱:跑完四轮阶梯之后,机器上挂着大约 800 张被遗弃的牌桌,安静地吃掉两个核的约 7%。

然后做那件把「怀疑」变成「事实」的事:我们在一台完全空的机器上,把崩塌的那一级又跑了一遍——运行前几秒在数据库里确认活跃牌桌为 0。结果一模一样。幽灵是真的,但它不是原因。对照实验很便宜;一个没被检验过的混淆因素,会被人引用很多年。

两个教训,第二个才是有用的那个。显然的一条:把数字归因给你的负载之前,先看清楚机器上本来在跑什么。不那么显然的一条:这份幽灵负载在生产环境同样真实存在。一个繁忙的小时之后,会残留一批没人坐的牌桌,它们和活桌占用同一个 runtime。任何只统计活跃用户的容量上限,都会在最需要它的那个高流失小时里失准。

第三步:悬崖不在 CPU 那里

下面是阶梯数据,单人桌(1 个真人 + 5 个机器人,我们「每人成本」最贵的形态),从约 45 ms 之外的客户端经 TLS 端到端测量:

活跃牌桌出手 p50出手 p99服务端 p99应用 CPU
5043.0 ms115.9 ms70 ms15 %
10043.2 ms115.0 ms72 ms24 %
15043.9 ms161.4 ms118 ms34 %
20044.3 ms246.4 ms201 ms41 %
30085.6 ms4116 ms4071 ms— 崩塌

把最后两行放在一起读。从 200 到 300,p99 涨了十七倍,而且牌桌基本停止推进:同样 60 秒的窗口,200 桌时完成了 697 手牌,300 桌时只完成 148 手;上一级采到 2994 个延迟样本,这一级只采到 90 个。而此时大约一个半核是空闲的

这一点也不玄。这就是小型异步 runtime 的行为:工作成阵地到来;一个需要两个 worker 线程忙 40 ms 的突发,会把这 40 ms 内到达的一切都推后;被推后的工作又让下一阵更大。按分钟平均,机器看起来睡了一半。但队列不会被平均。

两条可以直接拿走的结论:

  • 基于平均 CPU 的扩容或告警规则,不会在用户掉进崩塌之前触发。我们那条规则在最后一级健康档位是舒舒服服的绿色,在尾延迟已经是秒级时依然是绿色。告警要打在尾延迟、队列深度或拒绝计数上,而不是机器「看起来有多忙」。
  • 在悬崖附近,从健康点外推毫无价值。200 桌时 41% 的 CPU,并不意味着 480 桌可行。这个关系不是线性的,也不会优雅退化;它先撑住,然后直接倒。

你在测的单位,多半不是你在卖的单位

我们一开始的心智模型是「同时在线用户数」,因为创始人被问的就是这个数。但服务器根本没有这个概念。它有的是牌桌:每局一个长期存活的任务,持有状态机、计时器、机器人决策和广播扇出。

六个朋友坐一张 6 人桌 = 一个这样的对象。六个人各自和机器人练习 = 六个。同样是「六个在线用户」,贵的对象差了六倍。

所以在你挑一个数字去捍卫之前,先找出真正花钱的那个东西,去数它。聊天服务大概率是房间而不是连接;视频应用大概率是转码而不是会话;对我们是牌桌。然后把上限用那个单位表达——否则同一个上限,对便宜的人群太紧,对昂贵的人群太松。

清单

  1. 把延迟预算按场景写下来,并锚在你指得出的常量上。一个全局延迟数字,对某些东西一定是错的。
  2. 测你真正在跑的那台机器,走真实网络、真实 TLS 终止。loopback 数字是另一台机器顶着你机器的名字。
  3. 先热身再计时。涌入和稳态分开报,并说明你的上限是哪一个。
  4. 给压测端上仪表。如果它无法自证健康,它的尾延迟数字就是不可证伪的。
  5. 看清楚机器本来在干什么。「空闲但活着」的负载,在测试里和生产里都是真负载。
  6. 把阶梯走过拐点。有价值的信息在它不再线性的地方,而你只能靠在自己选的时间里,故意压垮它一次来拿到。
  7. 告警打在尾延迟上,永远不要打在平均 CPU 上。

做完这些,才轮到在实测上限之下挑一个运行点——并且真的去执行它,因为没人执行的上限,等于让用户替你发现它。至于怎么执行,那是另一个话题,也是另一篇文章。