4. PromQL 进阶:Histogram 分位数计算、聚合操作、子查询、Recording Rules 优化

PromQL 学到这,基本查询和简单聚合你肯定没问题了。但真正到了生产环境,你会发现——光会查平均值,根本定位不了问题。

举个例子,你的 API 平均延迟是 200ms,看起来还行。但用户投诉说“卡死了”。为什么?因为那 5% 的慢请求把平均值拉高了,但平均值掩盖了真相。这时候,你需要的是 分位数。

这一章,我们就啃下 PromQL 里最硬核的几块骨头:Histogram 分位数、聚合操作、子查询,还有 Recording Rules 优化。嗯,都是我在生产线上摔过跟头才搞明白的东西。

核心观点:PromQL 的威力不在单条查询,而在组合。分位数告诉你“慢在哪”,聚合告诉你“谁在慢”,子查询告诉你“为什么慢”,Recording Rules 则让你“查得快”。

4.1 Histogram 与分位数计算

先说说 Histogram。它不像 Gauge 或 Counter 那样只存一个值,而是存了一组桶(bucket)。每个桶记录落在某个延迟区间内的请求数量。

比如 http_request_duration_seconds_bucket{le="0.1"} 表示延迟 ≤ 0.1 秒的请求数。桶越多,精度越高,但存储开销也越大。

4.1.1 从 Histogram 算分位数

PromQL 提供了 histogram_quantile() 函数。它的用法很简单:

# 计算过去 5 分钟的 P99 延迟
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))

这里有个关键点:一定要配合 rate() 或 increase() 使用。为什么?因为 Histogram 存的是累积值,直接算分位数会得到从程序启动至今的累积分位数,那基本没有参考价值。

我的习惯:算分位数时,我几乎只用 rate()。它把桶的增量归一化到每秒,这样不同时间窗口的结果可比。如果你用 increase(),记得时间窗口要一致。

4.1.2 分位数的精度问题

你可能会问:P99 算出来准吗?说实话,取决于桶的分布。

Prometheus 的分位数计算是线性插值法。如果你的桶边界太稀疏,比如 1s 之后直接跳到 10s,那落在 1s 到 10s 之间的请求会被“平均”处理,结果自然不准。

我在项目中遇到过一个问题:P99 延迟算出来是 800ms,但实际压测发现有些请求跑了 3s。查了半天,发现是桶配置不合理——最大的桶只到 1s,超过 1s 的请求全被归到 le="+Inf" 里了。分位数算出来当然不准。

避坑指南:我曾经因为桶边界设置太宽,导致 P99 严重偏低。建议桶边界按指数增长,比如 0.005、0.01、0.025、0.05、0.1、0.25、0.5、1、2.5、5、10。这样既能覆盖大部分场景,又不会浪费太多存储。

4.1.3 聚合分位数

有时候你想看整个集群的 P99,而不是单个实例的。这时候需要先聚合桶,再算分位数:

# 先 sum 聚合所有实例的桶,再算 P99
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

注意 by (le) 不能少。因为 sum 会合并所有标签,但 le 必须保留,否则 PromQL 不知道桶的边界。

4.2 聚合操作:不只是 sum

很多人用 PromQL 聚合只会 sum 和 avg。其实还有几个非常实用的操作符。

操作符 作用 典型场景
sum 求和 总 QPS、总错误数
avg 平均值 平均延迟、平均 CPU 使用率
min / max 最小值 / 最大值 找出最慢的实例、最低的可用性
count 计数 有多少实例在运行、有多少实例超过阈值
stddev / stdvar 标准差 / 方差 评估延迟波动、检测异常抖动
topk / bottomk 前 K 个 / 后 K 个 找出最耗资源的 Top 10 实例
quantile 聚合分位数(非 Histogram) 对 Gauge 值直接算分位数

举个例子,你想找出 CPU 使用率最高的 5 个实例:

topk(5, avg by(instance) (rate(node_cpu_seconds_total{mode="user"}[5m])))

这里 topk 会返回 5 条时间序列,每条对应一个实例。注意 topk 是“瞬时向量”操作,不能直接用在范围向量上。

我的经验:用 topk 做告警要小心。因为 topk 的结果集会动态变化,今天排第一的实例明天可能掉出前五。如果你用它做告警,可能会漏掉持续高负载但没进前五的实例。我一般用 topk 做排查,用 阈值 + 聚合 做告警。

4.3 子查询:查得更细

子查询是 PromQL 里比较“高级”的功能。说白了,就是在一个查询里嵌套另一个查询,先算出一个中间结果,再对这个结果做二次计算。

什么时候需要子查询?举个例子:你想看过去 1 小时内,每 5 分钟的平均 CPU 使用率的最大值。普通查询做不到,因为 avg_over_time 只能算平均值,不能嵌套 max。

子查询的语法:

<原始查询>[<时间窗口>:<分辨率>]

比如:

# 先算每 5 分钟的平均 CPU,再取这 1 小时内的最大值
max_over_time(
  avg by(instance) (rate(node_cpu_seconds_total{mode="user"}[5m]))
  [1h:5m]
)

这里 [1h:5m] 表示:以 5 分钟为步长,计算过去 1 小时内的所有中间结果。然后 max_over_time 取这些结果的最大值。

性能警告:子查询非常消耗资源。因为它相当于在 Prometheus 内部执行了多次查询。我见过有人用子查询做实时告警,结果 Prometheus 直接 OOM。建议子查询只用于事后分析,或者配合 Recording Rules 使用。

4.4 Recording Rules:让查询飞起来

Recording Rules 是 Prometheus 里一个“低调但强大”的功能。它允许你把复杂的 PromQL 查询结果预先计算好,存成一条新的时间序列。以后查的时候,直接查这条新序列就行,不用再跑原始查询。

4.4.1 为什么需要 Recording Rules?

原因很简单:复杂查询太慢了。

比如你有一个 Dashboard,上面有 10 个 Panel,每个 Panel 都跑一次 histogram_quantile(0.99, rate(...))。每次刷新,Prometheus 都要重新计算一遍。如果数据量大,Dashboard 加载可能要等十几秒。

Recording Rules 的解决思路:提前算好,存起来。查询时直接读结果,毫秒级响应。

4.4.2 配置 Recording Rules

在 Prometheus 配置文件里加一个 rule_files 段,然后写规则文件:

# prometheus.yml
rule_files:
  - "rules/*.yml"

规则文件内容:

# rules/recording.yml
groups:
  - name: http_requests
    interval: 30s
    rules:
      - record: job:http_request_duration_seconds:p99
        expr: |
          histogram_quantile(0.99,
            sum by(le, job) (rate(http_request_duration_seconds_bucket[5m]))
          )
      - record: instance:http_request_duration_seconds:p50
        expr: |
          histogram_quantile(0.50,
            sum by(le, instance) (rate(http_request_duration_seconds_bucket[5m]))
          )

这里我定义了两条规则:

  • job:http_request_duration_seconds:p99:按 job 聚合的 P99 延迟
  • instance:http_request_duration_seconds:p50:按 instance 聚合的 P50 延迟

规则名称的命名规范,我建议用 层级:指标名:聚合方式 的格式。这样一看就知道这条规则是干什么的。

我的习惯:Recording Rules 的刷新间隔不要设得太短。30s 到 1 分钟是比较合理的范围。太短了 Prometheus 计算压力大,太长了数据实时性不够。另外,规则名里带上 job 或 instance 前缀,方便在 Grafana 里搜索。

4.4.3 Recording Rules 的坑

Recording Rules 也不是银弹。我踩过几个坑:

  • 存储膨胀:每条 Recording Rule 都会产生新的时间序列。如果你定义了几百条规则,存储量会翻倍。建议只对“高频查询”且“计算复杂”的指标做 Recording。
  • 标签爆炸:如果原始查询的 by 子句包含高基数标签(比如 user_id),Recording Rule 会产生大量序列。我曾经因为一个 by (user_id) 的规则,导致 Prometheus 内存飙升。后来改成 by (job) 才解决。
  • 规则更新:修改规则后,旧数据不会自动重算。你需要手动删除旧序列,或者等它们过期。我一般会在修改规则后,用 promtool 检查一下语法,然后重启 Prometheus。

4.5 本章知识体系

下面这张图梳理了本章的核心逻辑。你可以看到,从原始指标到最终告警,每一步都有对应的 PromQL 技术。

PromQL 进阶知识体系 PromQL 进阶:从原始指标到高效查询 原始指标 Counter / Gauge / Histogram 基础计算 rate() / increase() / delta() 进阶计算 histogram_quantile() 聚合操作 sum / avg / topk / count 子查询 max_over_time(...[1h:5m]) Recording Rules 预计算 + 存储 最终输出 Grafana Dashboard / 告警规则 / 事后分析 核心原则 1. 分位数要配合 rate() 2. 聚合注意保留 le 标签 3. 子查询避免实时告警

4.6 实战:组合使用

最后,我分享一个我在生产环境里实际用过的组合查询。场景是:找出过去 10 分钟内,P99 延迟超过 1s 的 job,并列出对应的 Top 3 慢实例。

第一步:先算每个 job 的 P99

# 先定义 Recording Rule
- record: job:http_p99:5m
  expr: |
    histogram_quantile(0.99,
      sum by(le, job) (rate(http_request_duration_seconds_bucket[5m]))
    )

第二步:用子查询找出超过阈值的 job

# 过去 10 分钟内,P99 超过 1s 的 job
avg_over_time(job:http_p99:5m[10m]) > 1.0

第三步:对每个超阈值的 job,查它的 Top 3 慢实例

# 假设超阈值的 job 是 "api-gateway"
topk(3,
  histogram_quantile(0.99,
    sum by(le, instance) (
      rate(http_request_duration_seconds_bucket{job="api-gateway"}[5m])
    )
  )
)

这样一套组合拳下来,你不仅能知道“哪个服务慢了”,还能知道“具体是哪几个实例拖了后腿”。

总结一下:Histogram 分位数让你看到“真实”的延迟分布;聚合操作帮你从全局视角看问题;子查询让你能做多层分析;Recording Rules 则让这一切变得“快”起来。这四个工具组合使用,才是生产级监控的正确姿势。