文档目录

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++ 微基准测的是纯计算:调用一千次就是一千次,没有「等待」的概念,所以不存在节奏被协调的问题。

但后端的性能有两个新特点:

  1. 存在排队:请求会等待,等待时间会累积,而累积本身会改变流量的到达模式。
  2. 观测者与被观测者耦合:压测工具既是「测量仪器」,又是「流量来源」,它的行为会被被测系统影响。

这是从「测代码」到「测系统」时,最需要重新学习的一件事:测量本身会改变被测量的对象。


六、本节小结

  1. 协调遗漏:闭环压测工具「收到响应才发下一个请求」,导致服务端卡顿时工具也发得少,慢样本被系统性隐藏。
  2. 最可怕的地方:服务越糟,报告越好看。
  3. 识别信号:用 VU 模型、P99 与 P50 差距过小、延迟尖刺期间 QPS 同步下降、实际请求数少于期望请求数。
  4. 修正方法:恒定到达率模型(首选)、按计划发送时间算延迟、用直方图存分布、多种工具交叉验证。
  5. 本质:从「测代码」到「测系统」,测量行为本身会影响被测量对象。

七、自测

  1. 一份压测报告写着:「100 个并发用户,压测 5 分钟,平均延迟 12 ms,P99 = 45 ms,QPS 稳定在 800。」请指出至少两处可疑的地方。
  2. 你的压测设定是 1000 req/s、持续 300 秒,但工具报告总请求数是 240000。这说明什么?
  3. 为什么说「这个压测工具低估了尾延迟」比「这个压测工具算错了」更准确?
  1. 疑点一:用的是「100 个并发用户」= VU 闭环模型,P99 很可能被协调遗漏低估。疑点二:P99 = 45 ms 而平均是 12 ms,只差 3.75 倍——真实后端的重尾分布通常 P99 是平均的 5–20 倍,这个分布「太干净了」,值得怀疑。疑点三:没有说明测量点(客户端还是服务端)、没有给样本量。
  2. 期望请求数 = 1000 × 300 = 300000,实际只有 240000,少了 20%。这正是协调遗漏的证据:服务端变慢时,工具被「拖住」了,没能按计划发出请求。(也可能是 preAllocatedVUs 不足导致的降速,两者都需要排查。)
  3. 因为工具的计算逻辑本身没错——它记录的是「它实际发出的那些请求」的真实延迟,那些数字都是对的。错的是样本选择:慢的那段时间里,工具恰好没在发请求,所以慢样本根本没被采集到。这不是算错,是没测到。