文档目录

1.2 百分位不可平均:直方图才是正解

上一节:1.1 指标分四层 | 下一节:1.3 测量点 配套代码:01-metrics-and-slo/02-percentiles-and-aggregation 前置:0.3 延迟是分布(百分位的定义)


一句话结论

百分位是一个「位置」,不是一个「可加减的量」。 把多台机器的 P99 求平均,得到的数字不代表任何一台机器、也不代表任何用户。正确做法是:采集直方图(分桶统计),在查询时合并后再算百分位。


一、用考试成绩理解「位置」与「数量」的区别

一个班 50 个学生考试。老师说:

「我们班的平均分是 78 分。」 「我们班的第 95 百分位分数是 96 分。」

现在把三个班合起来,问「全校的 P95 是多少」:

班级 人数 平均分 P95 分
一班 50 78 96
二班 50 80 97
三班 50 76 95

❌ 错误做法:(96 + 97 + 95) / 3 = 96。

为什么错:「第 95 百分位」是「排在第 95% 位置上的那个分数」。三个班的「第 95% 位置」是三个不同的位置,把它们平均,得到的不是任何一个真实位置上的分数。

✅ 正确做法:把 150 个分数合在一起重新排序,再取第 95% 位置。这个结果可能不是 96、97、95 中的任何一个。

核心区别:平均分是「所有分数的总和 ÷ 人数」,可以跨组相加(分子分母都能加)。而百分位是「某个位置上的值」,位置本身不可加。


二、延迟指标上的真实后果(比想的更严重)

回到后端。假设两台实例各处理 10 万个请求:

实例 P99
A(健康) 20 ms
B(有问题) 2000 ms

❌ 错误做法:求平均

(20 + 2000) / 2 = 1010 ms

这个 1010 ms 既不是 A 的情况,也不是 B 的情况,也无法解释任何用户的体验。

✅ 正确做法:合并样本后重新计算

合并 20 万个请求,重新排序取 P99。结果是:

真实 P99 ≈ 20 ms

为什么? 因为 B 的慢请求只占 B 的 1%,合并后占总样本的 0.5%,还没到 P99 的位置。所以真实的 P99 仍然落在「快请求」区间里。

最关键的发现:误差是 50 倍,而且方向不确定

算法 结果 判断
求平均 1010 ms 严重高估(50 倍)
合并后计算 20 ms 真实值

注意:如果 B 的慢请求比例是 5% 而不是 1%,合并后的 P99 会跳到 2000 ms——此时「求平均」反而低估了。

结论:百分位聚合不是「误差有点大」,而是「可能错得离谱,且方向不确定」。 用它做告警会漏报,用它做汇报会误导。


三、工程上怎么做才对

3.1 存直方图,不存百分位

做法 能否跨实例聚合 说明
上报「平均值」 ❌ 平均值可聚合,但掩盖长尾(第 0 章)
上报「P99」 ❌ 位置不可聚合
上报「直方图 / 分桶计数」 ✅ 桶可以相加,查询时再算分位数
上报「完整样本」 ✅ 但成本高 适合小规模或采样后的数据

这就是 Prometheus 推荐用 histogram 而不是 summary 的原因:

  • histogram 上报的是「落在每个桶里的请求数」,可以跨实例、跨时间相加;
  • summary 在客户端就算好了分位数,无法跨实例聚合(一旦聚合就失去意义)。

3.2 PromQL 的正确写法

# ✅ 正确:先按 le(桶边界)聚合,再算分位数
histogram_quantile(0.99,
  sum by (le) (rate(http_server_requests_seconds_bucket{uri="/orders/{id}"}[5m])))

# ❌ 错误:对每个实例算完 P99 再求平均
avg(http_server_requests_seconds{quantile="0.99"})

第一行做的事就是「合并桶 → 重新计算位置」,第二行是「对位置求平均」——正是本节要避免的错误。

3.3 桶边界怎么设(这一步决定数据有没有用)

Prometheus 默认的桶边界是 {.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}(秒)。对你的接口可能完全不合适:

  • 接口 P99 在 5–50 ms:默认桶在 10 ms 和 25 ms 之间只有 0.025 一个边界,区分度极差,P99 会严重失真。
  • 桶太少:分位数只能在桶边界上取值,误差大。
  • 桶太多:指标基数爆炸,Prometheus 内存吃不消。
  • 桶没覆盖到实际范围:所有请求都落在最后一个桶里,无法区分。

设置原则:

  1. 围绕 SLO 设置:SLO 是 P99 < 100 ms,那 50/75/100/150 ms 附近必须有边界。
  2. 覆盖实际分布范围:从最小值到最大值都要有桶,别让请求「溢出」到最后一个桶。
  3. 数量适中:10–20 个边界通常够用。
  4. 用「最小期望值」和「最大期望值」限制范围:避免因为几个异常大值导致桶范围被拉得过大。

示例(针对一个 5–100 ms 的接口):

1, 2, 5, 10, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000 (ms)

3.4 一个容易忽略的点:百分位不要用「客户端算好的」

有些库或中间件会「在客户端计算 P99 然后上报」。这在单实例视角下是对的,但一旦你有多副本、或者想把一段时间的数据合并起来,它就没用了。

统一原则:上报原始分布(桶),把计算推迟到查询时。


四、本节小结

  1. 百分位是「位置」,不是「数量」;位置不可平均、不可跨组相加。
  2. 多实例 P99 求平均的误差可能达到几十倍,且方向不确定(有时高估、有时低估)。
  3. 正确做法是上报直方图(分桶计数),查询时合并后再算分位数。
  4. Prometheus 用 histogram 而不是 summary;PromQL 必须先 sum by (le) 再 histogram_quantile。
  5. 桶边界要围绕 SLO 设置并覆盖实际分布范围,否则分位数会失真。

五、自测

  1. 三台机器的 P99 分别是 15 ms、18 ms、900 ms。有人说「平均 311 ms,超标了」。请指出这句话的两个问题,并说明正确结论可能是什么。
  2. 为什么 Prometheus 的 summary 类型不能用于多实例聚合,而 histogram 可以?
  3. 你的接口 SLO 是「P99 < 100 ms」,但桶边界设置成了 {0.1, 0.5, 1, 5, 10}(秒)。请说出这会导致什么后果,并给出一个更合理的边界集合。
  1. 问题一:百分位不能求平均——「311 ms」不代表任何一台机器的真实情况。问题二:缺少前提——没说明各机器的请求量、SLO 是多少、测量点在哪。正确结论取决于慢请求的比例:如果 900 ms 那台机器的慢请求只占其自身流量的 1%,合并后真实 P99 很可能仍在 20 ms 以内(达标);如果它占比很高(比如那台机器承担了大部分流量,或它的慢请求比例达到 5% 以上),合并后的 P99 会跳到几百毫秒(不达标)。要看合并后的分布,而不是比较各机器的分位数。
  2. summary 在客户端就算好了分位数并只上报那个数值,服务端拿到的是「位置」而非「分布」,无法与其他实例的位置相加或合并。histogram 上报的是每个桶的计数(「耗时 ≤ 0.1s 的有多少个」这种),计数是可以相加的,所以服务端能先把多实例的桶合并成一个更大的直方图,再在合并后的分布上计算分位数。
  3. 后果:桶边界的最小值是 100 ms,而 SLO 是 100 ms——所有正常请求(比如 20 ms)都会落进第一个桶 ≤0.1s,无法区分「10 ms」和「95 ms」;而 P99 这个分位数会因为桶太粗而严重失真(可能一直显示接近 100 ms 或一直显示接近 500 ms)。更合理的边界(毫秒):5, 10, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000, 2000——在 SLO 附近加密,同时覆盖从正常到异常的范围。