平均值可能让一个令人不舒服的服务看起来很快。如果九十五次请求各耗时五十毫秒,另外五次耗时两秒,平均延迟也只有一百四十七点五毫秒。直方图能够在不保存每条原始请求的情况下,保留更多关于这部分慢请求的信息。但它保存的是分桶后的分布近似,不是全部原始观测,也不会天然提供精确的分位数。理解这个区别,才能避免把计算输出当成重新测得的真实延迟。
先看桶里究竟统计了什么
在 Prometheus 经典直方图中,桶的边界标签表示小于或等于该上界的全部观测。计数具有累积性:一条五十毫秒的请求,既进入一百毫秒桶,也进入所有更大的桶。正无穷桶覆盖全部观测,计数和总和组件则分别记录样本数量与累计耗时。因此,直接把所有累积桶相加会重复统计请求,不能把结果当成真实访问量。
相邻累积桶相减,可以得到互不重叠区间内的观测数量,但仍然不知道每条请求在该区间中的具体位置。边界兼容的直方图可以先把对应桶的计数相加,再估计总体分位数。直接平均多个实例已经算出的分位数,无法恢复合并后的分布,即使各实例自己的分位数都计算得很精确,也不存在这种简单的总体换算关系。
分辨率决定答案能有多精细
对于常见的正数经典桶,分位数函数先定位目标排名所在的桶,再假定该区间内的观测均匀分布,执行线性插值。真实延迟却可能集中在桶的某一侧,所以返回值是与桶宽相关的估计,并不是被重新找回的实际观测。应该围绕业务真正关心的运行区间设置边界,而不是把大部分精度浪费在与目标无关的范围内。
如果桶边界正好位于三百毫秒目标上,就可以直接统计达到该延迟要求的请求数。这通常比观察一个插值得出的高分位数是否恰好低于阈值,更贴近事件型目标的定义。不过,经典直方图每增加一条边界,都会为每组标签组合增加桶序列。现代 Prometheus 也支持原生直方图,当前文档建议在埋点及相关系统支持时优先考虑;标准指数型原生桶采用不同的插值方式,下面的实验特意只讨论经典桶。
坏掉的样子
上层桶过宽时,少量观测移动就可能让尾部估计出现看起来很大的跳跃。经典分位数计算还要求存在正无穷桶,遗漏它可能使结果无效。如果目标排名落在最后的无限桶中,Prometheus 会返回前一个有限桶的上界,而不是编造一个无限延迟。上述输出都不能告诉你最慢那条请求的准确耗时,追查单次异常仍需要更细的观测材料。
空样本总体没有可解释的观测分位数。跨实例桶边界不匹配、同一指标混入不同单位,或者累积计数出现不合法的下降,也会破坏结果含义。按时间查询累计计数器时,应先计算合适的增量或速率,再聚合实例,使重置得到正确处理。另外,全请求分布也要说明结果范围:快速失败可能降低表面延迟,却同时让服务变得更不可用,不能仅凭曲线变快就宣布改善。
快速验证
保存为 histogram-shape.py,使用 Python 3 执行。程序先从人工构造的原始样本计算精确的最近秩高分位数,再分别根据两套经典桶估计相同分位点。精简插值函数只适用于这里的正数、非空样本,并断言选中的桶有有限边界;它用于展示桶内假设,不替代 Prometheus 对所有特殊输入和边界情况的完整实现。
from math import ceil, inf
from statistics import mean
values = [0.05] * 95 + [2.0] * 5
coarse = [0.1, 0.3, 1.0, 5.0, inf]
fine = [0.1, 0.3, 1.0, 2.0, 5.0, inf]
def cumulative(bounds):
return [sum(value <= bound for value in values) for bound in bounds]
def estimate_p99(bounds):
counts = cumulative(bounds)
rank = 0.99 * counts[-1]
lower, previous = 0.0, 0
for upper, count in zip(bounds, counts):
if count >= rank:
assert upper != inf and count > previous
return lower + (upper - lower) * (rank - previous) / (count - previous)
lower, previous = upper, count
raise AssertionError('rank not covered')
exact = sorted(values)[ceil(0.99 * len(values)) - 1]
counts = cumulative(coarse)
increments = [counts[0]] + [right - left for left, right in zip(counts, counts[1:])]
assert counts == [95, 95, 95, 100, 100]
assert increments == [95, 0, 0, 5, 0] and sum(increments) == 100
assert exact == 2.0 and abs(estimate_p99(coarse) - 4.2) < 1e-12
assert abs(estimate_p99(fine) - 1.8) < 1e-12
print(f"mean={mean(values):.4f}s; raw nearest-rank p99={exact:.1f}s")
print(f"coarse cumulative counts={counts}; sum_of_buckets={sum(counts)}")
print(f"classic linear p99: coarse={estimate_p99(coarse):.1f}s; fine={estimate_p99(fine):.1f}s")
print(f"at or below 0.3s={counts[1]}/{counts[-1]}")
原始样本的百分之九十九分位数为两秒,粗桶却给出四点二秒,因为有关观测只被定位在一秒到五秒之间。增加两秒边界后,估计变成一点八秒。在这组数据中,较窄的桶确实减少了误差,但仍然没有等于真实样本分位数,说明提高分辨率不会凭空恢复原始数值。原记录还将两项估计与 Prometheus 3.5.1 的实际分位数函数做了核对,在万亿分之一的数值容差内一致。排障时应先查看所在区间和边界,再解释分位数的小幅变化。
累积桶计数相加得到四百八十五,而实际只有一百条请求;换成互斥区间增量后,总和才是一百。零点三秒边界对应的桶还直接给出九十五条达标、总计一百条的精确阈值结果。把这些输出一起看,才能区分两种能力:与边界对齐的阈值计数可以准确,桶内插值得到的分位数仍然只是近似。
延伸阅读
来源
- Prometheus: Histograms and Summaries,分布表达、聚合与原生直方图,已核验 2026-09-13。
- Prometheus: histogram_quantile,经典桶插值与边界情况,已核验 2026-09-13。
- Google SRE: Monitoring Distributed Systems,延迟长尾与有意义的监控,已核验 2026-09-13。