0.6 协调遗漏:压测报告的最大谎言
上一节:0.5 为什么 90% 利用率已经很危险 | 下一节:0.7 九条常见谬误自查清单 配套代码:00-introduction/06-coordinated-omission 这是本章最重要的一节。 它解释了一个现象:为什么你的压测 P99 很漂亮,上线后用户却在抱怨卡顿。
一句话结论
大多数压测工具是「收到响应才发下一个请求」。 这导致服务端卡顿的那一秒里,工具几乎没发请求,于是这次卡顿只被记录成 1 个慢样本、而不是几百个。尾延迟被系统性地藏起来了。这个现象叫协调遗漏(Coordinated Omission)。
一、用一个故事理解它
假设你开了一个办事窗口,外面有 100 个客户排队办事。
平时每个人办 1 秒。突然,第 50 号客户遇到一个复杂情况,窗口卡了整整 1 秒(比如系统查不动了)。
真实世界会怎样(客户自己来)
100 个客户是按自己的节奏来的。窗口卡住的那 1 秒里,外面又来了 100 个新客户(假设每秒来 100 个)。
所以这次卡顿的后果是:
- 100 个在排队的客户,每人多等 1 秒;
- 100 个新来的客户,一进门就要等;
- 总共 200 个客户的体验被拖慢了 1 秒。
用「轮到我才去」的方式会怎样(典型压测工具)
现在换一种组织方式:每次只派 1 个客户去窗口,他办完回来,再派下一个。
窗口卡住的那 1 秒里,外面没有任何新客户进来——因为那唯一一个客户还在窗口前等着呢。
于是统计结果变成:
- 这 1 秒的卡顿,只影响了 1 个客户;
- 报告里写:「100 个请求中有 1 个慢了 1 秒」,P99 看起来还凑合;
- 但那 200 个在真实世界里被拖慢的客户,根本没有出现在统计里。
这就是协调遗漏:压测工具的「节奏」与服务端的「卡顿」被协调在了一起,卡顿越大,工具发得越慢,慢样本越少,尾延迟被低估得越严重。
二、为什么会这样:机制拆解
典型的「虚拟用户(VU)模型」压测脚本长这样:
for 循环:
记录 t0
发请求
等响应 ←──── 关键在这一行:它必须等响应回来
记录 t1,延迟 = t1 - t0
问题出在「等响应」这一步。如果服务端卡了 1 秒:
| 时间 | 服务端状态 | 工具在做什么 | 发出请求数 |
|---|---|---|---|
| t=0 | 正常 | 发第 100 个请求 | 1 |
| t=0.001 | 开始卡顿 | 在等响应 | 0 |
| t=0.5 | 卡顿中 | 还在等响应 | 0 |
| t=1.0 | 恢复 | 收到响应,发第 101 个 | 1 |
在那 1 秒里,本该发出 100 个请求,实际只发了 1 个。
于是统计上:
- 真实世界:100 个请求各等了 1 秒 → P99 应该是 1000 ms
- 压测报告:只有 1 个请求等了 1 秒 → 在 100 个样本里,P99 = 1000 ms(看起来一样?)
等等,这里需要更仔细——真正的差异在于样本基数。
| 真实世界 | 闭环压测报告 | |
|---|---|---|
| 总样本数 | 100(卡顿期)+ 正常期的几百个 | 少得多 |
| 卡顿期的慢样本 | 100 个 | 1 个 |
| 慢样本占比 | 高 | 极低 |
| P99 结果 | 明显偏高 | 被稀释掉 |
结论:卡顿持续时间越长,被「藏起来」的慢样本越多,P99 被低估得越厉害。服务越糟,报告越好看——这是最讽刺的地方。
三、怎么识别一份报告有没有踩这个坑
看三个信号:
| 信号 | 说明 |
|---|---|
| ① 工具用的是「虚拟用户数」而不是「每秒请求数」 | VU 模型几乎必然是闭环。看到「我用 100 个并发用户压测」就要警惕 |
| ② 报告里只有平均值,或百分位与平均值差距很小 | 真实分布通常重尾,P99 往往是 P50 的 5–20 倍。如果 P99 只比 P50 高一点,很可能漏掉了尾部 |
| ③ 服务端 QPS 曲线是一条平线,但延迟曲线有尖刺 | 如果延迟尖刺期间 QPS 也同步下降了,说明工具跟着服务端「一起慢下来」了 → 协调遗漏 |
还有一个自检方法:看压测工具报告的请求总数。
期望请求数 = 到达率 × 时长 = 100 req/s × 300 s = 30000
实际请求数 = 22000 ← 少了 27%,说明工具在服务端变慢时被"拖住"了
实际请求数明显少于期望值,就是协调遗漏的确定性证据。
四、怎么修正
方法 1:用恒定到达率模型(最推荐)
把「N 个虚拟用户循环发」换成「每秒固定发 N 个请求,不管上一个有没有回来」。
k6 的做法是选择 constant-arrival-rate 或 ramping-arrival-rate executor:
scenarios: {
read_path: {
executor: 'constant-arrival-rate', // 关键:到达率模型,不是 VU 模型
rate: 500, // 每秒 500 个请求,不受响应速度影响
timeUnit: '1s',
preAllocatedVUs: 200, // 预备足够的"发令员"
maxVUs: 2000,
},
}
这样即使服务端卡住,工具仍然按计划继续发请求(用更多 VU 来承载积压),卡顿期的慢样本就会被如实记录。
方法 2:按「计划发送时间」算延迟
延迟不该用「从实际发出到收到」来算,而应该用:
延迟 = 收到响应的时刻 − 这个请求"本该"发出的时刻
如果一个请求本该在 t=0 发出,但因为工具排队推迟到 t=0.5 才发出、t=0.6 才收到响应,那么真实延迟是 600 ms,而不是 100 ms。
wrk2 这个工具就是专门为这个修正而生的(它用固定速率发送 + HdrHistogram 记录修正后的延迟)。
方法 3:用直方图记录,而不是只存平均值
确保工具记录的是完整的延迟分布(HdrHistogram 或分桶统计),这样即使你事后想重新分析也有原始数据。只存平均值等于销毁证据。
方法 4:交叉验证
用两种不同的工具(例如 k6 开环 + wrk2)压同一个服务,如果两者报出的 P99 差了一个数量级,那基本可以确定其中一个踩了协调遗漏。
五、这个问题对你(做过 C++ 并发的人)意味着什么
你可能会想:「我在 C++ 里做基准测试,从来没遇到过这个问题。」
确实——因为 C++ 微基准测的是纯计算:调用一千次就是一千次,没有「等待」的概念,所以不存在节奏被协调的问题。
但后端的性能有两个新特点:
- 存在排队:请求会等待,等待时间会累积,而累积本身会改变流量的到达模式。
- 观测者与被观测者耦合:压测工具既是「测量仪器」,又是「流量来源」,它的行为会被被测系统影响。
这是从「测代码」到「测系统」时,最需要重新学习的一件事:测量本身会改变被测量的对象。
六、本节小结
- 协调遗漏:闭环压测工具「收到响应才发下一个请求」,导致服务端卡顿时工具也发得少,慢样本被系统性隐藏。
- 最可怕的地方:服务越糟,报告越好看。
- 识别信号:用 VU 模型、P99 与 P50 差距过小、延迟尖刺期间 QPS 同步下降、实际请求数少于期望请求数。
- 修正方法:恒定到达率模型(首选)、按计划发送时间算延迟、用直方图存分布、多种工具交叉验证。
- 本质:从「测代码」到「测系统」,测量行为本身会影响被测量对象。
七、自测
- 一份压测报告写着:「100 个并发用户,压测 5 分钟,平均延迟 12 ms,P99 = 45 ms,QPS 稳定在 800。」请指出至少两处可疑的地方。
- 你的压测设定是 1000 req/s、持续 300 秒,但工具报告总请求数是 240000。这说明什么?
- 为什么说「这个压测工具低估了尾延迟」比「这个压测工具算错了」更准确?
- 疑点一:用的是「100 个并发用户」= VU 闭环模型,P99 很可能被协调遗漏低估。疑点二:P99 = 45 ms 而平均是 12 ms,只差 3.75 倍——真实后端的重尾分布通常 P99 是平均的 5–20 倍,这个分布「太干净了」,值得怀疑。疑点三:没有说明测量点(客户端还是服务端)、没有给样本量。
- 期望请求数 =
1000 × 300 = 300000,实际只有 240000,少了 20%。这正是协调遗漏的证据:服务端变慢时,工具被「拖住」了,没能按计划发出请求。(也可能是preAllocatedVUs不足导致的降速,两者都需要排查。) - 因为工具的计算逻辑本身没错——它记录的是「它实际发出的那些请求」的真实延迟,那些数字都是对的。错的是样本选择:慢的那段时间里,工具恰好没在发请求,所以慢样本根本没被采集到。这不是算错,是没测到。