延迟平均值可能隐藏一部分特别慢的请求。直方图通过记录落入不同桶的观测数量,保留更多分布信息,但仍然丢弃桶内部的细节。Prometheus 可以从这些计数估计分位数,无需保存每次请求的原始耗时。估计结果取决于桶边界与聚合方式,不能称为完整恢复了原始延迟分布,也不能因为输出包含小数就认为测量具有同样精度。

经典直方图由多条相关序列组成

经典直方图导出累计的 _bucket 计数器,以及 _count 与 _sum。le 标签表示每个桶包含的上界。一点五秒的观测会增加两秒桶和所有更大的桶,所以这些桶不是互不重叠的区间,不能直接相加得到总请求数。正无穷桶包含全部观测,与总计数对应。一个经典直方图因此不是一条可以单独理解的普通序列。

对于非负延迟观测,分别对总和与总计数应用 rate,可以得到每秒累计的耗时和每秒观测数量;两者相除就是以秒为单位的平均延迟。桶速率则描述选定时间范围内的分布。应该先对各条计数器求速率,再合并实例,这样各序列已经观测到的重置能够独立处理,不会在求和时被另一实例的增长掩盖。

先合并桶,再估计总体分位数

需要整个服务的一个分位数时,应在保留 le 的前提下合并桶速率,再调用 histogram_quantile。查询需要区分服务等维度时,也应保留相应标签。参与合并的桶边界要兼容,事件定义也要一致。如果先把 le 消掉,就丢失了经典分位数函数所需的桶身份,留下的数值无法继续表达累计分布。

不要平均各实例的分位数。分位数是分布中的位置,不是可相加的计数,即使各实例请求量完全相同,平均分位数也未必等于总体分位数。合并桶曲线可以具有与任何单个实例都不同的形状。如果问题是两秒以内完成的请求比例,恰好以两秒为边界的桶除以总计数,就可以直接回答这个阈值问题,不必绕到分位数再反推。

摘要指标可以导出客户端预先计算的分位数,这些数值也不能通过求和或平均合成总体分位数。不过,只要事件定义一致,它的总计数与总和仍可用于计算合并平均值。选择指标类型时,应从所需聚合能力出发,而不是仅看面板希望直接显示什么字段。

坏掉的样子

关注的分位数落在很宽的桶内时,输出容易显得比实际测量更精确。经典分位数计算会在选中的桶内插值,却不知道每个观测在桶内的真实位置。应围绕服务目标设置有用的边界,并在升级前审阅桶定义,避免滚动发布期间不同实例混用不兼容方案。事后加密桶边界也不会恢复旧观测已经丢失的细节。

桶或导出器缺失,与没有观测不同。面板上看似改善的分位数,也可能只是最慢实例的指标丢了。解释改善之前,应检查抓取完整性、参与计算的序列和样本数量。观测太稀疏或完全缺失时,分位数可能没有可用含义;把这种结果替换成零延迟,会制造虚假的成功信号,而不是补全测量。

快速验证

保存为 histogram-query.py,运行 python3 histogram-query.py。环境需要已经安装的 promtool,可以从 PATH 找到,或用 PROMTOOL 指定其可执行文件路径。本轮使用 Python 3.12.14 与 promtool 3.14.0。真实查询引擎在临时目录中评估合成样本,不启动服务器、监听端口或容器,也不联系外部服务。

from pathlib import Path
from tempfile import TemporaryDirectory
import os
import shutil
import subprocess

# Uses a real, installed promtool; no server or Docker runtime is started.
promtool = shutil.which(os.environ.get("PROMTOOL", "promtool"))
if not promtool:
    raise SystemExit("Set PROMTOOL to an installed promtool executable")

tests = r"""evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"1\"}"
        values: "0+95x5"
      - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"2\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"5\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_bucket{instance=\"fast\",le=\"+Inf\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_count{instance=\"fast\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_sum{instance=\"fast\"}"
        values: "0+17x5"
      - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"1\"}"
        values: "0+0x5"
      - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"2\"}"
        values: "0+0x5"
      - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"5\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_bucket{instance=\"slow\",le=\"+Inf\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_count{instance=\"slow\"}"
        values: "0+100x5"
      - series: "api_duration_seconds_sum{instance=\"slow\"}"
        values: "0+400x5"
      - series: 'tail_duration_seconds_bucket{le="1"}'
        values: "0+0x5"
      - series: 'tail_duration_seconds_bucket{le="5"}'
        values: "0+90x5"
      - series: 'tail_duration_seconds_bucket{le="+Inf"}'
        values: "0+100x5"
    promql_expr_test:
      - expr: "abs(histogram_quantile(0.99, sum by (le) (rate(api_duration_seconds_bucket[2m]))) - 4.94) < bool 1e-12"
        eval_time: 5m
        exp_samples:
          - labels: "{}"
            value: 1
      - expr: "abs(avg(histogram_quantile(0.99, rate(api_duration_seconds_bucket[2m]))) - 3.385) < bool 1e-12"
        eval_time: 5m
        exp_samples:
          - labels: "{}"
            value: 1
      - expr: "sum(rate(api_duration_seconds_bucket{le=\"2\"}[2m])) / sum(rate(api_duration_seconds_count[2m]))"
        eval_time: 5m
        exp_samples:
          - labels: "{}"
            value: 0.5
      - expr: "abs(sum(rate(api_duration_seconds_sum[2m])) / sum(rate(api_duration_seconds_count[2m])) - 2.085) < bool 1e-12"
        eval_time: 5m
        exp_samples:
          - labels: "{}"
            value: 1
      - expr: 'histogram_quantile(0.99, rate(tail_duration_seconds_bucket[2m]))'
        eval_time: 5m
        exp_samples:
          - labels: '{}'
            value: 5
"""
rules = r""""""

with TemporaryDirectory(prefix="prometheus-query-check-") as directory:
    root = Path(directory)
    (root / "tests.yml").write_text(tests, encoding="utf-8")
    if rules:
        (root / "rules.yml").write_text(rules, encoding="utf-8")
    result = subprocess.run([promtool, "test", "rules", "tests.yml"],
                            cwd=root, text=True, capture_output=True, timeout=45)
    print(result.stdout, end="")
    if result.returncode:
        print(result.stderr, end="")
        raise SystemExit(result.returncode)

成功时输出 SUCCESS。正确合并后的百分之九十九分位数估计是四点九四秒;分别计算两个实例后再平均,得到三点三八五秒,并不是总体结果。满足两秒阈值的观测占一半,合并平均延迟为二点零八五秒。断言对浮点结果采用很小的数值容差,避免要求十进制小数必须具有完全相同的二进制表示。

实验验证查询算术与聚合,不验证真实应用的埋点,也不能还原每个桶内部的分布。执行结束后会删除临时测试文件。当前 Prometheus 指南也讨论原生直方图,它使用不同的桶编码,在仪表化和存储链路支持时能够简化查询。本例明确使用经典直方图,所以需要保留 le、正确合并计数器速率,并把输出分位数称为估计值。

第二个直方图把百分之十的观测放在最大有限边界五秒之外,但其百分之九十九分位数仍返回五。经典分位数落入正无穷桶时,函数返回前一个有限桶的上界。这个数不是测得的最大值,也不证明百分之九十九的请求在五秒内完成。新增断言把这个显示陷阱直接暴露出来,提醒读者观察尾部是否超出当前桶范围。

来源