Prometheus 的告警规则如果写得太“敏感”,评估的时候就容易反复去查同一个指标。每次查询可不是白看的,底层存储一边要忙着把新写入的数据刷到磁盘上,一边还要腾出手来处理查询请求,结果缓冲区和写入路径在抢资源。时间一长,压力就从查询接口一路传导到 chunk 层,整个存储引擎开始吃力。最根本的解决办法,不是去加机器,也不是盲目调大超时,而是从规则本身的精度控制入手。这篇文章我们就聊聊怎么把规则写得既灵敏又省资源。

一、告警规则评估和底层存储之间到底发生了什么

1.1 一次“无感”的评估背后有哪些动作

在 Prometheus 里,一条告警规则会按照固定的间隔去执行查询。比如你设置了 for: 5m,那么每过一分钟,评估器就会把这条 PromQL 重新跑一遍。假设规则长这样:

# 告警规则配置示例(YAML)
groups:
  - name: example_alerts
    rules:
      - alert: HighErrorRate
        expr: job:api_errors:rate5m > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "API error rate is too high"

每次评估这条规则,Prometheus 都要去 TSDB 里读取 job:api_errors:rate5m 这个序列。如果这个序列的基数很大,比如按 instanceendpoint 拆分成几百个时间序列,那么一次评估就要扫描很多 chunk。与此同时,Prometheus 还在持续接收新的样本,并且每隔一段时间就把内存里的 chunk 刷到磁盘。如果评估频率过高,查询和刷盘就会在同一个存储引擎里争抢锁和 IO 带宽。

1.2 压力传导链路的直观感受

你可以把 TSDB 想象成一个忙碌的收银台。写入数据相当于顾客不停排队结账,查询请求相当于有人不停问“现在总共卖了多少”。如果每个问问题的人都要反复核对账单,收银员就没办法专心结账。当查询特别频繁时,查询接口会先感受到延迟,然后 chunk 层的压缩和读取操作开始互相阻塞,最终导致整个存储响应变慢。

要缓解这种情况,最直接的办法是让告警规则不要那么“折腾”。换句话说,不要让同一个查询在短时间内被反复执行,或者让每次查询加载的数据量尽量小。这就是规则精度控制的思路。

二、规则精度控制的核心手段

2.1 控制评估间隔,避免短周期长查询

Prometheus 的全局配置里有一个 evaluation_interval,它决定了规则多久被评估一次。很多人的默认值是 15s。如果你的告警规则里包含复杂的聚合查询,比如按多维度分组,或者查了很长时间范围的数据,那么 15s 评估一次就非常浪费。

建议根据规则的业务重要性来调整。比如对于日志错误率这种变化很快的指标,可以保持 15s;但如果你监控的是磁盘使用率或者证书过期时间,这些指标本身变化很慢,完全可以把评估间隔拉到 1 分钟甚至 5 分钟。

示例配置如下:

# Prometheus 全局配置示例(YAML)
global:
  scrape_interval: 15s
  evaluation_interval: 1m  # 全局评估间隔调整为 1 分钟,减少重复查询

这样一改,同样一条规则,每 15s 查一次变成每 60s 查一次,底层存储的查询压力直接降到原来的四分之一。

2.2 使用 for 语句消除频繁抖动

有时候一个指标只是因为网络抖动或者瞬时尖刺,超出了阈值几秒钟,但你的规则没有 for 字段,那么每次评估都满足条件,就会立即触发告警。告警本身虽然不直接产生查询,但一旦告警触发,对应的 webhook、Alertmanager 通知可能会引发其他系统反向查询 Prometheus,间接增加负载。

更合理的写法是在规则里加一个 for 窗口,让阈值条件连续稳定一段时间后才算真的告警。比如:

# 告警规则示例(YAML)
- alert: HighErrorRate
  expr: job:api_errors:rate5m > 0.05
  for: 10m  # 连续 10 分钟超过阈值才触发

这样做的好处是:如果规则本身一直处于 pending 状态,Prometheus 依然要去查询,但至少避免了告警风暴,也减少了因为告警而引发的下游系统请求。

2.3 用 recording rules 把复杂查询结果缓存成新指标

最消耗 TSDB 资源的,往往是那些计算量很大的查询,尤其是用到了 ratesumhistogram_quantile 这种带聚合计算的表达式。如果一个复杂的表达式被多个告警规则重复使用,那你可以把它先算好,存成一个新的指标序列。这个操作就是 recording rule。

假设你有两个告警规则都依赖 sum(rate(api_requests_total[5m])) by (service),那你完全可以先建一条记录规则:

# recording rule 配置示例(YAML)
groups:
  - name: recording_rules
    rules:
      - record: job:api_requests:rate5m  # 新指标名字
        expr: sum(rate(api_requests_total[5m])) by (service)

然后你的告警规则就直接查这个新指标:

# 告警规则配置示例(YAML)
groups:
  - name: example_alerts
    rules:
      - alert: HighTraffic
        expr: job:api_requests:rate5m > 800
        for: 2m

这样一来,复杂的原始查询只会在 recording rule 的评估间隔里执行一次,所有依赖它的告警规则都变成了简单的单值比较。TSDB 查询压力大幅度下降,因为结果已经是预聚合好的,不需要再去 chunk 层扫描原始样本。

2.4 精确匹配标签,减少扫描范围

很多人在写 PromQL 时不注意标签的筛选,导致查询覆盖了所有序列。比如你只想监控 service="api" 的错误率,却写成了 rate(http_requests_total[5m]),那就会把其他服务的请求全部纳入计算。虽然最终结果可以按 service 分组,但中间查询过程会加载大量无关的 chunk。

改成带标签选择器的写法:

# 只查询指定服务的指标
sum(rate(http_requests_total{service="api"}[5m]))

这样 TSDB 在遍历索引时就能快速过滤到目标序列,不需要逐个打开无关的 chunk。特别是在高基数的环境下,这个优化能明显降低查询延迟和存储 IO。

2.5 合理设置 lookback delta 和查询时间范围

有些告警规则会用到 offset 或者比较长的 range vector。比如 rate(metric[30m]),这意味着每次评估需要加载半个小时的样本数据。如果这条规则每 15s 跑一次,TSDB 就要反复读取最近的 30 分钟数据,chunk 层的压力自然很大。

解决办法就是根据自己的实际数据采样频率来调整区间。如果你的指标每 15s 采一个点,那么计算 5 分钟的速率只需要 20 个样本,完全没有必要用 30 分钟。把查询区间缩短,加载的数据量就成倍下降。

示例:

# 减少了时间窗口的查询
sum(rate(node_cpu_seconds_total{mode="user"}[2m]))

但也要注意窗口太短会导致速率不稳定。这里需要你在精度和灵敏度之间做权衡。

三、技术优缺点和应用场景分析

3.1 优点

通过规则精度控制,最直观的好处是 TSDB 的查询性能更稳定。因为评估次数减少、计算量降低,chunk 层不再频繁地读取大量数据块。同时,写入刷盘和查询之间的竞争也得到缓解,整个存储的响应时间会变得平稳,不会出现偶尔的大延迟。另外,预聚合还能让告警规则的维护变得更简单,因为公共的计算逻辑统一放在 recording rule 里,修改一处即可。

3.2 缺点

精度控制也有代价。把评估间隔调大,意味着告警的发现会有延迟。原来 15s 就能发现的故障,现在可能要等 1 分钟。对于追求秒级响应的业务,这显然不适用。而 recording rule 虽然缓存了结果,但会增加额外的存储占用,因为新指标也需要保存样本。并且 recording rule 本身的评估时间也是独立的,如果它的评估周期设置不当,也可能成为新的压力源。

3.3 适用场景

这种方式特别适合以下场景:监控指标基数大、告警规则数量多,或者存储节点 IO 能力有限。比如 Kubernetes 集群中监控几百个容器的 CPU 和内存指标,机器数量多维度高,如果没有预聚合和合理的评估间隔,TSDB 很容易被拖垮。相反,如果你的监控对象很少,指标只有几十个序列,那么就算不做任何精度控制也不会有什么问题,就不必为了优化而优化。

四、一个完整的示例:从原始规则到优化后规则

为了让你更直观地看到精度控制的全过程,我用一个实际场景来演示。场景是监控某后端服务的请求错误率,指标名为 api_requests_total,标签包括 serviceendpointstatus_code。原始规则写得很随意,会导致高频扫描 chunk。

4.1 技术栈说明

本示例统一使用 Prometheus + YAML 配置文件。下面所有的规则片段都放在同一个规则文件里,你可以直接复制到 Prometheus 的 rules.yml 中使用。

4.2 原始写法:未做任何精度优化

# 原始告警规则:每次评估都会全量聚合,性能堪忧
groups:
  - name: original_alerts
    rules:
      - alert: EndpointErrorRateHigh
        # 每 15 秒评估一次,并且没有按 service 过滤,扫描所有 endpoint 的序列
        expr: sum(rate(api_requests_total{status_code=~"5.."}[5m])) by (endpoint) / sum(rate(api_requests_total[5m])) by (endpoint) > 0.05
        for: 0m  # 没有持续时间,瞬时超限就告警
        labels:
          severity: page
        annotations:
          summary: "Endpoint {{ $labels.endpoint }} error rate high"

这条规则的三个问题:

  • 表达式里计算了两个 rate,并且每个都按 endpoint 聚合,底层需要扫描所有服务的序列。
  • for: 0m 表示只要有一次评估超阈值就马上触发,非常容易抖动。
  • 默认全局评估间隔是 15s,也就是每 15s 就要全量跑一遍这个复杂查询。

4.3 引入 recording rule 预聚合

先把错误率计算单独抽出来,写入一个新的指标。注意这里我们按 serviceendpoint 分组,并且只保留 5xx 的请求。

# recording rule 配置示例(YAML)
groups:
  - name: recording_rules
    rules:
      # 记录 5xx 错误请求的速率
      - record: job:api_requests:error_rate5m
        expr: sum(rate(api_requests_total{status_code=~"5.."}[5m])) by (service, endpoint) / sum(rate(api_requests_total[5m])) by (service, endpoint)
        labels:
          kind: error_rate
      # 记录总请求速率(可选,如果你想减少重复的 rate 计算)
      - record: job:api_requests:total_rate5m
        expr: sum(rate(api_requests_total[5m])) by (service, endpoint)

这样,复杂的除法计算已经提前完成了。告警规则只需要拿现成的错误率和我们预定义好的阈值比较就行。

4.4 优化后的告警规则

# 优化后的告警规则(YAML)
groups:
  - name: optimized_alerts
    rules:
      - alert: EndpointErrorRateHigh
        # 直接查预聚合指标,扫描的数据量很小
        expr: job:api_requests:error_rate5m > 0.05
        # 连续 5 分钟超过阈值才触发,避免瞬时抖动
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Endpoint {{ $labels.endpoint }} error rate is {{ $value }}"

同时把全局评估间隔调大一点,或者只对这部分规则使用单独的评估组。Prometheus 允许在 groups 级别覆盖评估间隔,写法如下:

# 针对低频告警组,使用更长的评估间隔(YAML)
groups:
  - name: optimized_alerts
    interval: 1m  # 这组规则只每 1 分钟评估一次
    rules:
      - alert: EndpointErrorRateHigh
        expr: job:api_requests:error_rate5m > 0.05
        for: 5m

这样一来,从原来每个 15s 执行两次全量 rate 计算,变成了每 1 分钟只查一个单值。TSDB 的查询负载下降得非常明显。

4.5 另一种精度控制:用 delta 代替 rate 减少取样范围

如果你的指标是计数器,rate 是标准做法。但对于某些实时性要求不高的指标,比如内存使用量,你完全不需要用 rate,直接比较当前值或者用 delta 检查变化量。这里我们演示一个 delta 示例,用来监控内存突增。

# 使用 delta 的规则(YAML)
groups:
  - name: memory_alerts
    interval: 2m
    rules:
      - alert: MemoryBurst
        # 检查最近 5 分钟内内存使用量突增是否超过 20%
        expr: delta(jvm_memory_used_bytes{area="heap"}[5m]) > 20971520
        for: 10m
        labels:
          severity: info
        annotations:
          summary: "Heap memory increased by more than 20MB in last 5m"

注意 delta 查询的范围是 5 分钟,但评估间隔是 2 分钟。这就意味着每次评估会读取最近的 5 分钟数据,实际读取窗口比评估周期还长。如果这是真实环境,你可以缩小窗口到 3 分钟,甚至直接用 jvm_memory_used_bytes > 阈值 这种即时向量查询,不必带 [5m],那就不需要读 range 数据了。

4.6 动态阈值与基础线的局限

精度控制不仅是对时间窗口做手术,也包含对阈值的合理设计。有些规则写成固定的绝对值,比如 memory_usage > 80%。但很多应用平时就在 70% 徘徊,出现一点小波动就容易触发。更合理的做法是使用相对变化,比如和过去 1 小时的平均值比较。这里的思路上可以用 predict_linear 预测未来趋势,但这会增加查询复杂度。推荐的做法是:对于难以确定固定阈值的指标,先通过 recording rule 生成历史基线,再用 (current - baseline) / baseline > 0.5 的方式来判断。

示例如下:

# 基于历史基线的 recording rule(YAML)
groups:
  - name: baseline_rules
    rules:
      - record: job:high_card_metric:baseline_1h
        expr: avg_over_time(job:high_card_metric[1h])

然后告警规则查当前值和基线值的偏差:

# 偏差告警规则(YAML)
groups:
  - name: anomaly_alerts
    interval: 5m
    rules:
      - alert: MetricAnomaly
        expr: (job:high_card_metric / job:high_card_metric:baseline_1h) > 1.5
        for: 15m

这种方法比固定阈值更精准,但代价是需要额外维护一个基线指标。如果你的存储压力本来就大,要谨慎使用,因为 avg_over_time 同样需要读取长范围数据。建议把基线的计算周期拉长到 10 分钟一次,用更粗的粒度换取更低的查询频率。

五、注意事项和踩坑经验

5.1 不要把所有规则都放在一个组里

如果你把 100 条规则放在同一个 group,那么这些规则都会使用相同的评估间隔。有的规则需要秒级响应,有的规则是低频检查,混在一起会导致要么查询太多,要么响应太慢。所以你应该按照业务的重要程度和时间敏感度,把规则拆分成多个 group,每个 group 设置独立的 interval

5.2 注意 recording rule 本身的成本

recording rule 虽然能预聚合,但它自己也是一个查询。如果你定义了一个 sum(rate(metric[5m])) by (pod),而且这条指标有 10 万个 pod,那么 recording rule 每次计算照样很重。这时候你还需要考虑设定合适的评估间隔,同时尽量在 recording rule 的表达式里加标签过滤条件,缩小表量级。

5.3 警惕 for 带来的额外无意义评估

for 不是用来降低查询频率的。只要规则处于 pending 或者 firing 状态,Prometheus 在每次评估时还是要跑一遍查询。for 只是控制告警状态变化的“门槛”,并不会让 Prometheus 在 for 时间内跳过查询。所以如果你的底层存储扛不住高频查询,不能只靠加 for,还是要从评估间隔和预聚合入手。

5.4 观察查询延迟和存储指标

优化之后,要持续关注 Prometheus 自身的指标。比如 prometheus_tsdb_head_seriesprometheus_engine_queries,以及 prometheus_storage_tsdb_wal_replay_status 等。如果每次规则评估导致 prometheus_engine_query_duration_seconds 的 p99 一直在涨,说明你的规则还是太重,需要进一步缩小查询范围。

5.5 不要让规则直接查询原始的 upscrape_duration_seconds

这类指标虽然简单,但如果你的实例数量巨大,每次查询 up == 0 也会遍历所有实例的序列。建议用 count by (job)(up == 0) 先聚合,或者直接用 group by 缩减序列数量。示例:

# 统计下线的实例数量(YAML)
groups:
  - name: down_instances
    interval: 2m
    rules:
      - alert: InstanceDown
        expr: count by (job) (up == 0) > 2
        for: 5m

这比每次查询 up == 0 返回一个很大的结果集要更高效,因为 count by 已经在 Prometheus 内部做了聚合,传输到规则引擎的数据量小得多。

六、文章总结

Prometheus 告警规则的评估过程,本质上是高频短查询和底层存储 IO 之间的博弈。当规则写得粗糙,评估间隔又很密集时,TSDB 的 chunk 层就要同时应付查询缓冲和写入刷盘,压力不可控。要提高整个监控系统的稳定性,不能头疼医头脚疼医脚,而要回到规则本身,从精度控制开始优化。

关键手段就是四件事:一是合理拉长评估间隔,特别是低频指标的告警;二是用预聚合的 recording rule 把复杂计算变成简单查询;三是准确使用标签选择器来缩小扫描范围;四是精心设计阈值和 for 时长,避免无谓的抖动和误报。

当然,精度控制不是越粗越好。你需要根据业务对告警实时性的要求,在存储负载和故障发现速度之间找到平衡。希望这篇文章的示例能帮你建立一个清晰的优化路径。下次你的 Prometheus 告警规则再让 TSDB 喘不过气来的时候,你可以先从你的规则配置文件里翻一翻,看看有没有可以“减脂”的地方。