- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 平均值恶化了 9%,中位数却改善了 46%
- 同一份数据,四个结论
- 为什么平均值会失灵——多峰分布
- 负载生成器抹掉的尾部——coordinated omission
- 该画什么才对——五种视角
- 诊断走查——当平均值毫无波动、分布却已经分裂时
- 在生产环境里真正把它画出来的工具
- 结语——汇总统计量是假设,不是结论
- 参考资料
引言 — 平均值恶化了 9%,中位数却改善了 46%
2026 年 7 月 27 日,Farid Zakaria 发布了《The mean means nothing》一文,两天后在 Hacker News 讨论帖中获得 73 点赞,一度占据榜单前列。题材并不新鲜——花了一周时间逐步上线缓存层之后,仪表盘上的平均延迟从 112ms 涨到了 122ms,涨幅 9%。这画风简直就是在为一场回滚会议铺路。
但在同一时间段、同一份请求日志里,中位数却从 99ms 降到了 54ms,下降了 46%。p95 从 224ms 涨到 454ms,p99 从 309ms 涨到 678ms,涨幅超过一倍。缓存这件事,既是成功的,也是失败的。这两种说法都没有错。
有一点需要先说明。原文中的数据并非真实的故障记录,而是一份公开脚本用固定随机种子生成的合成数据。脚本开头的注释就写明了这一点,作者本人也提到图表的生成得到了 AI 的协助。所以,与其把这篇文章当成"发生过这样一次故障"的事故报告来读,不如把它当作"这种形状的数据该用什么视角去看"的教学素材,这样理解会更准确。而作为教学素材,它做得相当不错——缓存命中与未命中混在一起产生的双峰分布,是任何一套接上过哪怕一个缓存的系统,几乎必然会遇到的形状。
本文以这个场景为起点,梳理平均值为什么会失灵、负载生成器是如何把尾部抹掉的,以及应该改画什么图。
同一份数据,四个结论
原文给出的汇总统计如下。
| 统计量 | 上线前 | 上线后 | 变化 |
|---|---|---|---|
| 平均值 | 112ms | 122ms | +9% |
| p50(中位数) | 99ms | 54ms | −46% |
| p95 | 224ms | 454ms | +103% |
| p99 | 309ms | 678ms | +119% |
这四个数字各自催生出一场不同的会议。只看平均值的团队会决定回滚,只看中位数的团队会把它当作成功案例来汇报,只看 p99 的团队则会拉响事故警报。三个团队看的是同一份日志。
平均值给出暧昧数字的原因,在算术上很简单。平均值会把变快的多数和变慢的少数放上同一台天平、彼此抵消。如果一半流量变快了 45ms,5% 变慢了 370ms,加总后的结果会趋近于零。这个被抵消掉的结果,其正负号并不能说明这套系统的任何事情。在这个意义上,平均值与其说是一个错误的指标,不如说是一个不包含任何问题的指标。
为什么平均值会失灵——多峰分布
核心在于,缓存层把请求总体一分为二。命中缓存的请求会跳过后端,比原来的基线更快返回。未命中的请求则要多绕一趟查缓存,之后才会走回原来的路径。于是,上线前只有一个的峰值,到了上线后就变成了两个。
这里统计学教育里的经典案例正好可以直接套用。Hacker News 评论区里被引用最多的,是 Anscombe 四重奏(Anscombe's quartet)以及它的现代版 Same Stats, Different Graphs。它们的要点在于:均值、方差、相关系数、回归线可以完全相同,画出来的图形却可以截然不同。后者的数据集里有一组,画成散点图会呈现出一只恐龙的形状。
延迟数据出现多峰的原因,通常来来去去就是那几种。
- 缓存命中与未命中
- 冷启动与热实例
- 从连接池里立刻拿到的连接与重新握手的 TLS 连接
- 主(leader)区域与跨区域降级(fallback)
- 小体积响应与没有分页、整块甩出去的大体积响应
- 撞上 GC 暂停或压缩(compaction)的请求
原文最后的诊断,恰好落在这份清单的最后一项附近——把响应体积和延迟画在同一张散点图上后可以看到,体积大的响应放不进缓存,正是它们撑起了未命中一侧的那个峰。所以开出的药方也不是"回滚",而是"扩大缓存容量,或者把大响应拆开"。只有先找到那条切开分布的轴,药方才开得出来。
负载生成器抹掉的尾部——coordinated omission
如果你看的不是生产环境指标,而是负载测试结果,那么在画分布图之前还有一件事要先确认:测量本身有可能已经把尾部抹掉了。
Gil Tene 命名的 coordinated omission 结构是这样的。假设负载生成器配置成每秒发送 1,000 个请求,其中有一次响应花掉了 2 秒。如果是同步生成器,这 2 秒里本该发送的 2,000 个请求,它根本不会发送。这 2,000 个请求本来分别应该被等待最多 2 秒,但因为压根没被发出去,所以也就不会被计入直方图。结果就是,系统最慢的那段时间的样本被整段抹去了,负载生成器反倒对自己制造出来的背压表现出了一种"配合"。
这个缺陷的症状很有辨识度。
- 就算把负载往上加,p99 也几乎纹丝不动。实际上队列已经在爆炸,但测出来的尾部看起来风平浪静。
- 报告出来的吞吐量比设定的目标值要低,但延迟分布看起来却像是在目标吞吐量下测出来的。
- 用生产流量观测同一套系统,尾部会比负载测试测出来的差得多。
解决办法有两条路子。一条是像 wrk2 那样,使用一款在保持恒定目标吞吐量的同时,按预定发送时刻记录延迟的生成器。wrk2 把逐请求的采样缓冲区换成了 HdrHistogram,并且从"请求本该发出的那个时刻"开始计算响应延迟。另一条路是使用 HdrHistogram 提供的校正 API:在已知期望间隔的情况下,合成并补上缺失的样本。
// 以期望间隔 1ms(=1,000,000ns)记录时,一旦某个样本超过这个间隔,
// 直方图会自动把本该缺失的中间样本补齐。
Histogram h = new Histogram(3600L * 1000 * 1000 * 1000, 3);
h.recordValueWithExpectedInterval(latencyNanos, 1_000_000L);
生产环境的观测不存在这个问题,因为真实用户不会因为上一个请求慢,就"体贴地"推迟发出下一个请求。正因为如此,当负载测试结果和生产指标对不上时,通常应该怀疑的是负载测试这一边。
该画什么才对——五种视角
原文真正的价值,在于把同一组数字用好几种方式画了出来。每一种图回答的问题都不一样。
密度图——回答的是"有几个峰"这个问题。它是最先要画的图,如果只有一个峰,后面大半的分析都可以省掉。延迟往右边拖着长尾,所以 x 轴默认用对数刻度。
CDF 回答的是"百分之多少的请求在多少 ms 内完成"。把变更前后的 CDF 画在同一组坐标轴上重叠对比时,如果两条曲线出现交叉,那个交叉点就是改善与恶化的分界线。原文中两条曲线在 140ms 附近交叉。CDF 出现交叉,正是"没有任何单一的百分位数能概括这次变化"的可视化证明。Hacker News 的评论里还有一个更进一步的建议:与其画 CDF,不如画用 1 减去它得到的值(CCDF,也就是尚未完成的请求比例),并且用 log-log 坐标画出来——这样一来,在 CDF 里被压缩成贴着 1 的一条水平线的尾部,就会在整个区间上展开。看尾部的时候,这种画法更好。
位移函数(shift function)回答的是"到哪个百分位数为止是赚的,从哪里开始变亏"。它针对每一个百分位数 p,画出"变更后的值减去变更前的值"。原文中的曲线在 p76 附近之前都是负值(改善),过了这个点之后陡然转为正值。仅凭这一张图,就能得出"76% 的用户受益,其余的用户受损"这句话——而 SLO 划在哪里,正是决定上线与否的分水岭。
山脊图(ridgeline plot)回答的是"从什么时候开始变成这样的"。把每天的密度图上下堆叠起来,就能看到上线比例从 0 涨到 100% 的过程中,第二个峰是如何一点点长出来的。山脊图在实战中的价值,在于能分辨回归究竟是随着这次上线一起出现的,还是早在此之前就已经存在。
热力图(heatmap)把和山脊图一样的信息压进了一个网格里。x 轴是时间,y 轴是延迟分桶,颜色代表落进那个分桶的流量大小。它的信息密度比山脊图更高,更重要的是可以长期挂在仪表盘上。一旦时间轴拉长到几天以上,山脊图就会叠在一起没法看,但热力图依然清晰可读。
再加上原文最后用到的两招,诊断就完整了——条件 CDF(按缓存命中/未命中拆开后分别画图,就会重新变回单峰)和散点图(延迟对响应体积)。前者确认"是什么把分布劈开的",后者解释"为什么会分成这一组"。
诊断走查——当平均值毫无波动、分布却已经分裂时
原文这个案例之所以显眼,恰恰是因为平均值好歹动了一下。更棘手的情形是,平均值纹丝不动,只有分布本身裂开了。当有一半流量变快的量和另一半变慢的量恰好抵消时,就会出现这种情况,而这时候不会有任何告警响起。
顺序可以这样安排。
- 先看密度图或热力图。确认有几个峰、是从什么时候开始裂开的。如果这里是单峰,就不用走第 2 步,直接跳到第 6 步(按端点拆解)。
- 把变更前后的 CDF 叠在一起画。找出交叉点。如果确实交叉,就放弃"只用平均值/中位数/p99 三选一来汇报"的打算。
- 用位移函数锁定边界百分位数。SLO 阈值落在这个边界的哪一侧,直接决定了上线与否的判断。
- 找到那条切开分布的轴。把候选逐一代入,按条件拆开分别画图——是否命中缓存、端点、区域、实例、客户端版本、响应体积区间。拆开后各自都变成单峰的那条轴,就是答案。
- 看这条轴和其他变量的关系。原文里是响应体积。药方就是从这里来的。
- 按端点重新拆解一遍。整体指标是按流量加权的平均值,会把流量小、速度慢的端点身上的回归完全藏起来。
如果第 4 步想不出候选轴,有一个还算好用的技巧:只挑属于慢峰那部分的请求来采样追踪(trace)。如果已经在用分布式追踪,按延迟区间过滤后比较 span 构成,是最快的办法。这部分和可观测性三大支柱与 LLM 工作负载一文中讨论的追踪采样策略是相通的。
另外,在做第 4 步之前有一件事必须先确认——如果系统里存在扇出(fan-out),中位数撒的谎会比 p99 更离谱。在一个用户请求会散布到 100 台叶子服务器、必须全部返回齐了才能出响应的架构里,每台叶子服务器的 p99 会主导用户侧延迟的中位数。Jeff Dean 的 The Tail at Scale 早就把这一点讲清楚了,Hacker News 的评论里也有人提到这一点。在扇出系统里,"组件的尾部"会变成"用户的平均值"。
在生产环境里真正把它画出来的工具
教学素材里的图是用 plotnine 画的,但生产环境里每天都要看的图,走的是另一条流水线。
Prometheus 原生直方图是目前最现实的基础方案。经典直方图需要提前定好桶的边界,边界切得越细,时间序列就越是线性增长。原生直方图按指数刻度自动分配桶,从根本上消除了这个权衡。它在 v3.8.0 转正为稳定功能,而且因为数据模型和 OpenTelemetry 的 exponential histogram 是对应的,通往 OTLP 后端的路径也是打通的。不过要真正打开它,从抓取协议协商、存储到仪表盘查询都有好几处要动手——这部分实操我另外写在了原生直方图都 stable 了,为什么我们还没打开它这篇里。
查询大概长这样。
# 只看一个百分位数——只盯着这一个值,就会直接掉进本文说的那个陷阱
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# 用于热力图:把每个桶的增长率原样导出,交给绘图那一侧去映射颜色
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# 如果是原生直方图,就没有 le 标签。序列本身就是分布
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
histogram_fraction(0, 0.14, sum(rate(http_request_duration_seconds[5m])))
最后一行的 histogram_fraction 在这篇文章的语境下特别有用。它能直接取出"在 140ms 内完成的请求比例",这样就可以把 CDF 上的某个特定点当成时间序列挂起来。如果已经知道交叉点在哪里,把那一个点做成告警,会比 p99 告警精确得多。
Grafana 的热力图面板可以直接接住上面的第二条查询。把格式设成 Heatmap,并把 y 轴改成对数刻度,实际上是一项必须做的设置。y 轴是线性刻度的热力图,会把下面那些桶全部压扁成一条线,没法显示出双峰形状。
如果需要内核侧的分布,bpftrace 的 hist() 是成本最低的选择。它不需要任何应用层埋点,就能把某个系统调用或函数的延迟分布直接以 log2 分桶的形式打印出来。
# 块 I/O 完成延迟分布 (usec, log2 分桶)
sudo bpftrace -e '
kprobe:blk_account_io_start { @s[arg0] = nsecs; }
kprobe:blk_account_io_done /@s[arg0]/ {
@us = hist((nsecs - @s[arg0]) / 1000); delete(@s[arg0]);
}'
用 eBPF 抓取分布的整体思路,在eBPF 正在吞噬可观测性一文中有更详细的讨论。
如果是负载测试,就用前面提到的 wrk2 那一类工具,或者内置了 HdrHistogram、能维持目标吞吐量的生成器。不管用什么工具,看结果时要确认的事情只有一件——报告出来的实际吞吐量是否和设定的目标吞吐量一致。如果不一致,那次运行测出的延迟分布就不可信。
结语——汇总统计量是假设,不是结论
总结一下。
- 平均值会把变快的多数和变慢的少数相互抵消。被抵消掉的那个值的正负号,说明不了这套系统的任何事情。
- 如果两条 CDF 出现交叉,就没有任何单一的百分位数能概括这次变化。这时候该做的不是再多选一个百分位数,而是画一条位移函数。
- 如果分布裂开了,找到那条切开它的轴,诊断工作就基本完成了。原因就是那条能让条件拆分后各自变回单峰的轴。
- 如果负载测试测出来的尾部乖得反常,先怀疑 coordinated omission。目标吞吐量和实际吞吐量对不上,就是这个信号。
- 在扇出系统里,组件的尾部会变成用户侧的平均值。
在只看一个数字就决定回滚之前,先画一张图要花多少成本?如果已经在采集直方图,那就只是一行查询的事。问题不在工具,而在习惯。
参考资料
- The mean means nothing: data visualization to debug a latency problem — Farid Zakaria (2026-07-27)
- Hacker News 讨论帖 (item 49096170, 2026-07-29)
- 原文配图生成脚本 gist —— nix-shell + plotnine,合成数据
- wrk2 —— 维持恒定吞吐量、内置 coordinated omission 校正的负载生成器
- HdrHistogram —— 高精度延迟直方图与校正 API
- Prometheus —— Native Histograms 规范
- Prometheus —— Histograms and summaries 实操指南
- The Tail at Scale — Dean & Barroso, CACM
- Same Stats, Different Graphs — Matejka & Fitzmaurice, CHI 2017
- Anscombe's quartet
- 原生直方图都 stable 了,为什么我们还没打开它(相关文章)
- eBPF 正在吞噬可观测性(相关文章)
- 可观测性三大支柱与 LLM 工作负载(相关文章)