平均延迟上升 9% 就回滚?多维度数据可视化揭示 Web 服务缓存更新真相
2026 年 7 月 27 日 * 阅读时长:9 分钟
最近,我在日常工作里尝试验证与 `lld` 相关的一些性能改进。让人沮丧的是,基准测试显示有性能提升,可生产环境的实时仪表盘却没体现出来。
我有从事 Web 服务工作的背景,习惯查看单个时间序列仪表盘,有时还会查看几个百分位数的数据。我原本期待能看到明显变化,可数据太杂乱,没法得出任何结论。
后来发现,一位同事在评估构建速度改进时也碰到了类似问题。有很多变量会影响构建过程,像冷缓存、增量构建、本地构建、远程构建等等,而且构建时间会根据系统状态和工作负载的不同而有很大差异。她最终利用累积分布函数(CDF)来可视化数据,这让我眼前一亮。
这促使我探索除 CDF 之外的其他几种不同的数据可视化方法,以及为什么单张图像或单个统计数据往往不足以说明全部情况。本文将通过一个“合成”数据集,展示不同的可视化方法如何呈现同一数据的不同面貌。目的是说服你去“观察”数据,而不仅仅是用一个数字来概括它。
以下所有内容都来自一个使用固定种子生成的合成数据集。完整脚本可以在[这个代码片段](https://gist.github.com/fzakaria/17c72f0eddc0f10469e008e67e1385cc)中找到。这是一个带有 `nix - shell` 脚本头的单文件,只要你使用 [Nix](https://nixos.org/),就可以精确复现每一幅图。
> **注意**:为了撰写本文,我借助 AI 生成了数据和图表。如果这让你感到不适,很抱歉。🤷
一次“适得其反”的更新
情况是这样的:我们运营着一个典型的 Web 服务,在一周内逐步推出了一个新的缓存层,希望能降低请求延迟。
更新完全部署后,绘制**平均**延迟的仪表盘显示如下:
平均延迟从 112 毫秒**上升**到了 122 毫秒。☹️
于是我们发布了严重事件通知(SEV),回滚了更改,并撰写了事后分析报告。对吧?🤔
一个数字,四种解读
对于 Web 服务来说,查看各种百分位数的数据是个好习惯,尤其是分布尾部的百分位数,如 p95 和 p99。
| 统计指标 | 更新前 | 更新后 | 变化 |
|---|---|---|---|
| 平均值 | 112 毫秒 | 122 毫秒 | +9% |
| p50(中位数) | 99 毫秒 | 54 毫秒 | −46% |
| p95 | 224 毫秒 | 454 毫秒 | +103% |
| p99 | 309 毫秒 | 678 毫秒 | +119% |
现在问题来了,而且问题在于**每个人的观点都有道理**。平均值表明这次更新有轻微的性能下降;中位数(p50)则显示这是一次巨大的成功,典型请求的速度**几乎提高了一倍**;p99 则表明这是一次严重事件(SEV)。SEV 是用来描述事件或故障严重程度的常用方式,通常按影响程度降序进行数字排名,例如 SEV0 表示最严重的情况。最差请求的延迟增加了一倍多。
由相同数据计算得出的平均值和中位数,却指向了**相反的方向**。
工程师通常被教导要以数据为导向,但有时很容易挑选出支持自己观点的统计数据。
观察数据分布形态
处理数据分布时,接下来可以做的基本操作是绘制其形态。以下是更新前后的两种延迟分布密度图:
就是这样。🤓☝️ “更新前”的分布是**一个整齐的单峰**,而“更新后”的分布是**双峰**。
这就解释了之前的矛盾,但要正确可视化并不容易。图形的形状取决于我们选择的平滑参数,两个填充区域在重叠处相互干扰,而且很难从图中读出**百分位数**。我能看出有两个数据群体,但不容易看出中位数的变化情况。
你未曾使用的最佳图表
累积分布函数(CDF)能同时为每个百分位数回答一个问题:**在 _x_ 毫秒或更短时间内完成的请求占比是多少?**
CDF 是在单张图表中可视化多个百分位数的极其简便的方法。根据曲线,我们可以了解请求延迟在整个数据群体中的分布情况。
我发现将“更新前”和“更新后”的 CDF 绘制在同一张图表上进行比较非常有用。这样可以直观地看到各个百分位数的变化,从而了解更新对整个数据群体的影响。
在我们的案例中,对于延迟低于 140 毫秒的请求,“更新后”的曲线向左偏移,这意味着比更新前有更多的请求能更快完成;在 140 毫秒右侧,“更新后”的曲线高于“更新前”的曲线,这意味着比更新前有更多的请求完成得更慢。两条曲线在约 140 毫秒处相交,这是一个转折点,超过这个点,更新就从有益变为有害。
> **提示**:两条相交的 CDF 曲线是一种明显的信号,表明**没有一个百分位数能概括这种变化**,因为影响的正负取决于所查看的百分位数。
谁是赢家,赢了多少
CDF 能告诉我们影响的正负,即请求是变快还是变慢了。接下来显而易见的问题是,在分布的每个点上,变化的幅度有多大。我们可以为每个百分位数 _p_ 绘制更新后的延迟减去更新前的延迟,这就是所谓的**偏移函数**。
在零线下表示请求变快,在零线上表示请求变慢。我们可以直观地看到每个百分位数处变化的幅度。
性能下降问题一直存在
到目前为止,我们只查看了更新前后的两个静态快照。但实际上,更新通常不是瞬间完成的。在这个案例中,我们用了一周时间逐步推出新的缓存层,流量从 0% 逐步增加到 100%。
那么每天的数据情况是怎样的呢?将每天的分布堆叠起来,就得到了一个**脊线图**:
现在我们可以直观地看到性能下降问题是如何随时间出现的。随着更新的推进,我们可以看到主峰(快速请求)向左移动,而右侧出现了第二个峰(慢速请求)。中位数在下降,但慢速请求的数量和延迟在悄然增加。
这里的 x 轴是对数坐标轴。延迟大致呈对数正态分布,在线性坐标轴上,快速请求的峰是一个很高的尖峰,而慢速请求几乎看不见;对数坐标轴能让两个峰都清晰可见。
我们也可以将数据压缩到一个网格中,绘制**热力图**。每一列代表一天,颜色表示每个延迟区间的流量占比:
你可以隐约看到一个新的数据群体逐渐出现。
如果对整周的数据进行任何汇总计算,都会将这七个截然不同的日子的数据混为一谈,从而完全掩盖了趋势。
双峰分布的原因
现在我们已经彻底弄清楚了**发生了什么**,接下来的问题是**为什么**。这其实和我日常工作中的情况很相似,当时我不得不按二进制文件大小(例如 >50MiB)对数据进行分割,才能观察到延迟分布中的双峰现象。
在我们的案例中,新的缓存层要么从缓存中提供请求响应(**命中**),要么额外跳转一次到后端获取数据(**未命中**)。我们可以根据这个属性将“更新后”的请求进行划分,并分别绘制 CDF:
根据缓存结果进行划分后,每个数据群体又呈现出单峰分布。
我们可以很容易地看到,缓存**命中**的请求比旧基线更快,因为它们的曲线向左偏移。
缓存**未命中**的请求由于额外跳转一次,延迟明显增加,曲线位于右侧很远的地方。
哪些请求未命中缓存,原因是什么
“有些请求未命中缓存”只是一种现象,还不是原因。**哪些**请求未命中缓存,为什么?
每个请求还有一个我还未使用的字段:**响应大小**。
缓存通常存储小而热门的对象,大对象会被淘汰或根本无法存入。我们可以将延迟与响应大小绘制成图,并根据缓存命中或未命中为每个点着色。我们还可以在每个坐标轴的边缘添加密度图,以查看两个数据群体在每个坐标轴上的分布情况,这就是**联合图**:
我们可以看到两个清晰的数据群体:小而快的(缓存命中)和大而慢的(缓存未命中)。显然,延迟分布的双峰现象是由响应大小分布的双峰现象引起的。
现在我们有了可采取的措施:提高缓存的最大对象大小,或者拆分大的响应。🔥
一图胜千数
通常,单张图表最多只能传达部分信息,最坏的情况下还可能产生误导。从多个角度观察同一数据,有助于全面了解情况。
我特别欣赏 CDF 在单张图表中展示整个数据分布的方式,尤其是在比较看似多个数据群体时。
> “不要相信任何你自己没有伪造过的统计数据。”——温斯顿·丘吉尔
