直方图查询 · histogram_quantile 的真相
P99 是算出来的还是猜出来的?这一章读 quantile.go 的真实实现与注释——桶内线性插值的假设、落在最高桶时返回第二高桶上界的怪规则、以及那些返回 NaN 的特殊情况。附一套可直接抄的正确写法。
直方图查询 · histogram_quantile 的真相
第 1 部分讲过 histogram 只存桶计数,分位数是查询时算出来的。这一章看它到底怎么算——答案是:很大程度上靠猜。
理解这一点,你才不会盲信那个 P99 数字。
核心:桶内线性插值
源码注释开门见山(promql/quantile.go:70,真实注释):
The quantile value is interpolated assuming a linear distribution within a bucket.
histogram_quantile 的核心假设:桶内的数据是均匀分布的。
桶: le=0.1 le=0.5 le=1.0
计数: 240 980 1000
↑ P99 落在这个桶里(0.1 ~ 0.5 之间)
真实分布可能是:
情况 A: ▁▁▁▁▁▁▁█ ← 全挤在 0.5 附近 → 真实 P99 ≈ 0.49
情况 B: █▁▁▁▁▁▁▁ ← 全挤在 0.1 附近 → 真实 P99 ≈ 0.11
但 histogram_quantile 假设: ▄▄▄▄▄▄▄▄ ← 均匀!→ 给出 P99 ≈ 0.3两种真实情况,它给出同一个答案——而且都是错的。
这就是为什么桶必须在 SLO 阈值附近加密:桶越密,每个桶越窄,“桶内均匀”这个假设的误差上界就越小。桶宽就是你的误差棒。
实战推论: 如果你的 SLO 是 P99 < 300ms,而桶只有 [0.25, 0.5],那么 histogram_quantile 返回的 310ms 完全可能实际是 260ms 或 490ms——你根本无法判断 SLO 有没有破。第 2 部分反复强调这一点,现在你看到了源码层面的原因。
那个怪规则:落在最高桶时
注释里藏着一条很多人不知道、但会让你的 P99 看起来很怪的规则:
However, if the quantile falls into the highest bucket, the upper bound of the 2nd highest bucket is returned.
如果分位数落在最高的那个桶(即 +Inf 桶)里,返回的是第二高桶的上界**。**
桶: le=1 le=5 le=+Inf
计数: 900 980 1000
↑ P99 落在这里(5 ~ ∞)
返回: 5 ← 第二高桶的上界,不是 ∞、也不是别的为什么这么设计? 因为 +Inf 桶的上界是无穷大,插值无从谈起(你没法在 5 和 ∞ 之间做线性插值)。所以只能保守地返回已知的最大有限边界。
这个规则的实战后果极其重要:
如果你的 P99 长期“卡”在某个数字上不动(正好等于你倒数第二个桶的边界),那不是巧合——是你的桶不够高,真实的 P99 已经溢出到 +Inf 桶里了。 你看到的 “P99 = 5s” 实际可能是 8s、30s 或者更糟——你的 P99 被截断了,而且它看起来还挺“稳定”。
这是个危险的静默失败:图上一条平稳的线,你以为系统很稳,其实是指标失去了分辨能力。检查方法:看看你的 P99 是不是正好等于某个桶边界,如果是,加更高的桶。
最低桶的特殊处理
注释继续:
A natural lower bound of 0 is assumed if the upper bound of the lowest bucket is greater 0. In that case, interpolation in the lowest bucket happens linearly between 0 and the upper bound of the lowest bucket.
翻译:如果分位数落在最低桶里,插值范围是 [0, 最低桶上界]——假设了一个自然下界 0。这对延迟、大小这类非负的量是合理的(耗时不可能是负的)。
那些返回 NaN 的情况
注释把特殊情况列全了(真实源码):
| 情况 | 返回 |
|---|---|
| 桶里 0 个观测 | NaN |
| 桶少于 2 个 | NaN |
最高桶不是 +Inf | NaN |
q 是 NaN | NaN |
q < 0 | -Inf |
q > 1 | +Inf |
“最高桶不是 +Inf 就返回 NaN”这条值得注意——它意味着你的 histogram 必须有 +Inf 桶,否则 histogram_quantile 完全不工作。好消息是客户端库会自动加,你一般不用操心;但如果你手工构造直方图指标(比如用 recording rule 或从别的系统转换),别忘了它。
“0 个观测返回 NaN”也很实际:一个刚启动、还没有任何请求的服务,它的 P99 是 NaN。如果你的告警是 histogram_quantile(...) > 0.3,NaN > 0.3 是 false,所以不会误报——这是好事。但反过来,如果你写 histogram_quantile(...) < 0.3 来判断“健康”,NaN < 0.3 也是 false,你会误以为它不健康。写告警时要考虑 NaN。
正确的写法(可以直接抄)
标准姿势:histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m])))
# ✅ 全局 P99(跨所有实例)
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ 按 service 分的 P99(le 必须在 by 里!)
histogram_quantile(0.99,
sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ 只看成功请求的 P99(第 2 部分的教训)
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{status=~"2.."}[5m]))
)三条铁律:
le必须出现在by里 —— 忘了它,桶就被聚合没了,histogram_quantile拿不到分桶信息,结果是垃圾。这是最常见的错误。- 先
rate()再sum—— 和 counter 一样的道理:_bucket也是 counter,必须先rate()才能正确处理重置。 - 顺序是
histogram_quantile(q, sum(rate(...)))—— 从里到外:rate → sum by le → quantile。记住这个洋葱结构。
# ❌ 经典错误:le 丢了
histogram_quantile(0.99, sum by (service) (rate(..._bucket[5m])))
# ↑ 少了 le!结果无意义
# ❌ 经典错误:顺序反了
sum by (le) (histogram_quantile(0.99, rate(..._bucket[5m])))
# ↑ 先算每个实例的分位数再 sum = 毫无意义(第 1 部分讲过)native histograms 让这一切简单
上一部分提过的 native histograms 基本终结了本章的大部分纠结:
- 桶自动指数生成、又密又广 → 桶内均匀假设的误差极小,不用你在 SLO 阈值附近手工加密。
- 不会“溢出到 +Inf 桶” → P99 不会被静默截断。
le不再是标签 → 不会忘记by (le),也不吃基数。
查询上,histogram_quantile 对 native histogram 直接可用(语法一样,不用写 _bucket):
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
# ↑ 没有 _bucket 后缀,没有 by (le)新项目直接上 native histograms——它把本章讲的坑消掉了一大半。
小结与下一章
histogram_quantile 本质是估算,不是精确计算。核心假设是桶内线性插值(真实分布不管挤在桶的哪一头,它都当均匀 → 给同一个(错的)答案)——桶宽就是你的误差棒,这就是桶必须在 SLO 阈值附近加密的源码级原因。怪规则:分位数落在最高桶时,返回第二高桶的上界(因为 +Inf 没法插值)——后果是 P99 会被静默截断并“卡”在某个桶边界上,看起来还很稳定;检查方法是看 P99 是否正好等于某个桶边界。最低桶假设自然下界 0。特殊情况:0 个观测 / 桶少于 2 个 / 最高桶不是 +Inf → NaN。三条铁律:①**le 必须在 by 里**(最常见错误);②先 rate() 再 sum;③洋葱结构 histogram_quantile(q, sum by(le) (rate(...)))。native histograms 消掉了本章一大半的坑——新项目直接上。
PromQL 会写了,但写得慢会拖垮 Prometheus。下一章收尾第 4 部分。👉 查询性能与陷阱。