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 内存吃不消。
- 桶没覆盖到实际范围:所有请求都落在最后一个桶里,无法区分。
设置原则:
- 围绕 SLO 设置:SLO 是 P99 < 100 ms,那 50/75/100/150 ms 附近必须有边界。
- 覆盖实际分布范围:从最小值到最大值都要有桶,别让请求「溢出」到最后一个桶。
- 数量适中:10–20 个边界通常够用。
- 用「最小期望值」和「最大期望值」限制范围:避免因为几个异常大值导致桶范围被拉得过大。
示例(针对一个 5–100 ms 的接口):
1, 2, 5, 10, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000 (ms)
3.4 一个容易忽略的点:百分位不要用「客户端算好的」
有些库或中间件会「在客户端计算 P99 然后上报」。这在单实例视角下是对的,但一旦你有多副本、或者想把一段时间的数据合并起来,它就没用了。
统一原则:上报原始分布(桶),把计算推迟到查询时。
四、本节小结
- 百分位是「位置」,不是「数量」;位置不可平均、不可跨组相加。
- 多实例 P99 求平均的误差可能达到几十倍,且方向不确定(有时高估、有时低估)。
- 正确做法是上报直方图(分桶计数),查询时合并后再算分位数。
- Prometheus 用
histogram而不是summary;PromQL 必须先sum by (le)再histogram_quantile。 - 桶边界要围绕 SLO 设置并覆盖实际分布范围,否则分位数会失真。
五、自测
- 三台机器的 P99 分别是 15 ms、18 ms、900 ms。有人说「平均 311 ms,超标了」。请指出这句话的两个问题,并说明正确结论可能是什么。
- 为什么 Prometheus 的
summary类型不能用于多实例聚合,而histogram可以? - 你的接口 SLO 是「P99 < 100 ms」,但桶边界设置成了
{0.1, 0.5, 1, 5, 10}(秒)。请说出这会导致什么后果,并给出一个更合理的边界集合。
- 问题一:百分位不能求平均——「311 ms」不代表任何一台机器的真实情况。问题二:缺少前提——没说明各机器的请求量、SLO 是多少、测量点在哪。正确结论取决于慢请求的比例:如果 900 ms 那台机器的慢请求只占其自身流量的 1%,合并后真实 P99 很可能仍在 20 ms 以内(达标);如果它占比很高(比如那台机器承担了大部分流量,或它的慢请求比例达到 5% 以上),合并后的 P99 会跳到几百毫秒(不达标)。要看合并后的分布,而不是比较各机器的分位数。
summary在客户端就算好了分位数并只上报那个数值,服务端拿到的是「位置」而非「分布」,无法与其他实例的位置相加或合并。histogram上报的是每个桶的计数(「耗时 ≤ 0.1s 的有多少个」这种),计数是可以相加的,所以服务端能先把多实例的桶合并成一个更大的直方图,再在合并后的分布上计算分位数。- 后果:桶边界的最小值是 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 附近加密,同时覆盖从正常到异常的范围。