On-call 值班与告警治理实战:告别告警疲劳

On-call 值班与告警治理是一对孪生问题:告警一多,值班就变成消耗战;值班一累,告警就被无脑静默,真故障反而漏掉。本文不谈空泛的文化口号,而是给出一套能直接照抄的落地方案——告警三级分级、SLO 错误预算烧尽率告警、Alertmanager 路由与抑制配置、Runbook 强制绑定、排班与交接清单,以及用 PromQL 量化告警质量的方法。

一、为什么 On-call 会变成消耗战

大多数团队的监控建设路径是这样的:先把 Prometheus + Grafana 面板搭起来,看到什么指标就加一条告警规则,半年后告警群一天几百条,值班手机整夜震动。最后所有人达成一个默契——群里的红色消息不用看。

这不是执行力问题,是设计缺陷。告警的本质不是「指标越界了」,而是「需要人立刻做一件事」。如果一条告警响了以后没有人需要动手,它就不该响,应该进报表。把这条原则贯彻下去,告警量通常能砍掉七成,而故障发现能力反而变强。

告警治理要同时解决三件事:该响的必须响到人(可达性)、响了必须知道干什么(可操作性)、不该响的绝不打断人(信噪比)。下面按这三条依次落地。

二、第一步:给告警做三级分级

分级的唯一依据是响应方式,不是「感觉严重不严重」。只要你不打算为它半夜打电话,它就不是最高级。三级足够,级别越多越没人记得住。

级别判定标准通知方式响应时限典型告警
P1用户可感知的功能不可用;错误预算快速烧尽;涉及资金或数据一致性电话呼叫 + IM,未 ACK 则升级到二线5 分钟响应,15 分钟止损支付接口 5xx 飙升、主库不可用
P2容量或冗余降级,用户暂未受影响,但趋势会恶化IM 群消息,只在工作时间推送1 小时内认领,当天闭环磁盘 80%、副本掉一个、慢查询增多
P3卫生与趋势问题,有明确的处理窗口只进工单和周报,不主动打断本周内处理证书 30 天后到期、日志盘增长偏快

落地时加一条硬约束:severity 标签必须存在,且必须是 P1/P2/P3 之一,否则告警规则不许合并进主干。用 CI 卡住,比开会强调一百遍有用。

三、用 SLO 错误预算驱动 P1,而不是拍阈值

「错误率超过 1% 就报警」这种规则的问题是:低峰期 10 个请求错 1 个就触发,高峰期错 5000 个才触发。正确做法是盯错误预算的烧尽速度(burn rate):假设 API 的可用性 SLO 是 99.9%,那 30 天的错误预算就是 0.1%。如果 1 小时窗口的错误率达到预算的 14.4 倍,意味着两天就能把整月预算烧干——这才值得半夜打电话。

3.1 先用 recording rules 把窗口指标预聚合

# /etc/prometheus/rules/slo-record.yml
groups:
  - name: slo:api-availability
    interval: 30s
    rules:
      # 5 分钟短窗口:用于"确认当下还在烧"
      - record: job:http_requests:rate5m
        expr: sum(rate(http_requests_total{job="api"}[5m]))
      - record: job:http_errors:rate5m
        expr: sum(rate(http_requests_total{job="api",code=~"5.."}[5m]))
      - record: job:slo_error_ratio:rate5m
        expr: job:http_errors:rate5m / job:http_requests:rate5m

      # 1 小时窗口:快烧主判据
      - record: job:http_requests:rate1h
        expr: sum(rate(http_requests_total{job="api"}[1h]))
      - record: job:http_errors:rate1h
        expr: sum(rate(http_requests_total{job="api",code=~"5.."}[1h]))
      - record: job:slo_error_ratio:rate1h
        expr: job:http_errors:rate1h / job:http_requests:rate1h

      # 6 小时窗口:慢烧主判据
      - record: job:http_requests:rate6h
        expr: sum(rate(http_requests_total{job="api"}[6h]))
      - record: job:http_errors:rate6h
        expr: sum(rate(http_requests_total{job="api",code=~"5.."}[6h]))
      - record: job:slo_error_ratio:rate6h
        expr: job:http_errors:rate6h / job:http_requests:rate6h

预聚合有两个好处:告警规则里不再写又长又慢的表达式,评估周期稳定;Grafana 面板和告警共用同一份指标定义,不会出现「面板绿的但告警在响」这种撕裂。

3.2 双窗口双烧尽率告警规则

# /etc/prometheus/rules/slo-alert.yml
# SLO = 99.9% → 错误预算 0.001
groups:
  - name: slo-burnrate-alerts
    rules:
      # 快烧:1h 达 14.4x 且 5m 仍在烧 → 2 天耗尽月度预算,值得电话呼叫
      - alert: ApiErrorBudgetBurnFast
        expr: |
          job:slo_error_ratio:rate1h > (14.4 * 0.001)
          and
          job:slo_error_ratio:rate5m > (14.4 * 0.001)
        for: 2m
        labels:
          severity: P1
          service: api
          owner: platform-team
        annotations:
          summary: "API 错误预算快速烧尽(1h 烧尽率超 14.4 倍)"
          description: "当前 1h 错误率 {{ $value | humanizePercentage }},按此速度约 2 天耗尽 30 天预算。"
          runbook_url: "https://wiki.internal/runbook/api-5xx"

      # 慢烧:6h 达 6x → 不打电话,进工单,工作时间处理
      - alert: ApiErrorBudgetBurnSlow
        expr: job:slo_error_ratio:rate6h > (6 * 0.001)
        for: 15m
        labels:
          severity: P2
          service: api
          owner: platform-team
        annotations:
          summary: "API 错误预算中速消耗(6h 烧尽率超 6 倍)"
          runbook_url: "https://wiki.internal/runbook/api-5xx"

加上 and 的短窗口确认是关键:它能过滤掉「一小时前抖了一下、现在已经恢复」的场景,避免值班人爬起来发现一切正常。for: 2m 则再挡一层瞬时毛刺。

如果你还没有分布式链路数据,收到这类告警只能靠猜。建议同步补上 OpenTelemetry 链路追踪,告警负责「什么时候叫人」,链路负责「叫来了怎么定位」,两者分工不能混。

四、Alertmanager 路由与降噪配置

规则决定「是否触发」,Alertmanager 决定「谁被打断、被打断几次」。这里是降噪收益最大的地方。

4.1 按 severity 分流的路由树

# /etc/alertmanager/alertmanager.yml
global:
  resolve_timeout: 5m

route:
  receiver: default-workchat
  # 聚合维度:同一服务同一告警合并成一条,避免 50 个 Pod 发 50 条
  group_by: ['alertname', 'service', 'cluster']
  group_wait: 30s        # 首次等待,攒一波再发
  group_interval: 5m     # 同组有新成员时的最小间隔
  repeat_interval: 4h    # 未恢复时的重复提醒间隔
  routes:
    # P1:电话呼叫,等待时间压到最短,重复更频繁
    - matchers:
        - severity = P1
      receiver: oncall-phone
      group_wait: 10s
      repeat_interval: 30m
      continue: true      # 继续往下匹配,让 IM 也收到一份
    - matchers:
        - severity = P1
      receiver: default-workchat

    # P2:只进 IM,半天提醒一次就够
    - matchers:
        - severity = P2
      receiver: default-workchat
      repeat_interval: 12h

    # P3:只进工单系统,永远不打断人
    - matchers:
        - severity = P3
      receiver: ticket-only
      group_interval: 30m
      repeat_interval: 24h

receivers:
  - name: oncall-phone
    webhook_configs:
      - url: 'http://alert-gateway.internal:8080/phone'
        send_resolved: true
  - name: default-workchat
    webhook_configs:
      - url: 'http://alert-gateway.internal:8080/workchat'
        send_resolved: true
  - name: ticket-only
    webhook_configs:
      - url: 'http://alert-gateway.internal:8080/ticket'
        send_resolved: false

continue: true 这个细节容易被忽略:Alertmanager 的路由默认命中第一个匹配分支就停止,不写 continue 的话 P1 只会打电话、IM 群里毫无痕迹,事后复盘缺时间线。

4.2 抑制规则:让根因盖住症状

# 接在 alertmanager.yml 末尾
inhibit_rules:
  # 1. 整机失联时,压掉这台机器上所有进程级告警
  - source_matchers: [ alertname = NodeDown ]
    target_matchers: [ severity =~ "P1|P2|P3" ]
    equal: [ instance ]

  # 2. 主库不可用时,压掉由它引发的业务 5xx 告警
  - source_matchers: [ alertname = MysqlPrimaryDown ]
    target_matchers: [ alertname = ApiErrorBudgetBurnFast ]
    equal: [ cluster ]

  # 3. 同一服务同一告警,高级别压低级别,避免一件事叫两次
  - source_matchers: [ severity = P1 ]
    target_matchers: [ severity = P2 ]
    equal: [ alertname, service ]

抑制的心智模型是「因果链」:源告警是因,目标告警是果,equal 指定二者必须相同的标签范围。写错 equal 的后果很严重——比如漏掉 instance,一台机器宕机会把整个集群的告警全压住。上线前务必在测试环境构造一次真实抑制场景验证。

4.3 静默要有到期时间和责任人

# 变更维护窗口:给 db-01 打 2 小时静默,必须写清作者与变更单号
amtool silence add instance=db-01 \
  --alertmanager.url=http://alertmanager.internal:9093 \
  --duration=2h \
  --author="zhangsan" \
  --comment="MySQL 8.0.36 版本升级,变更单 CHG-20260902-07"

# 定期审计:查出所有生效中的静默,重点抓"永久静默"这种技术债
amtool silence query --alertmanager.url=http://alertmanager.internal:9093

# 变更结束立刻手动过期,不要等它自然到期
amtool silence expire <SILENCE_ID> --alertmanager.url=http://alertmanager.internal:9093

# 配置上线前先做语法校验,别把错误配置带上生产
amtool check-config /etc/alertmanager/alertmanager.yml

团队纪律:单条静默最长 7 天,没有 author 和 comment 的静默一律清除。永久静默等于把告警删掉,还骗自己有监控。

五、噪音的五种来源与对应治理动作

噪音类型典型表现治理动作
阈值过敏CPU 一冲高就报,几分钟自愈for 持续时间;改用分位数或趋势代替瞬时值
症状风暴一个根因触发几十条下游告警inhibit_rules 抑制 + group_by 聚合
无人负责告警没有 owner,谁都不认领强制 labels 带 service/owner,缺失则 CI 拒绝合并
无法处置收到了但不知道该干什么强制 runbook_url,无 Runbook 的一律降为 P3
僵尸静默永久 silence 掩盖真实问题静默最长 7 天,到期强制复核

日志类噪音单独说一句:很多团队把「日志里出现 ERROR 关键字」直接接成告警,结果每次发版刷屏。更合理的做法是在日志侧做聚合与采样,只把「错误率相对基线突增」升级为告警,具体可参考 Loki 与 ELK 的选型与落地

六、每条 P1/P2 都必须绑 Runbook

Runbook 的作用是让「值班的人」等于「能处理的人」。它不需要写成百科,只需要回答四个问题:这告警什么意思、先做什么止损、然后怎么定位、什么时候升级。

# Runbook: api-5xx(API 错误预算快速烧尽)

## 1. 这条告警意味着什么
API 服务 30 天错误预算正在被快速消耗,1 小时窗口错误率超 SLO 的 14.4 倍。
用户侧表现:下单、支付接口间歇报错。

## 2. 先止损(目标 5 分钟内完成)
- 看流量是否异常:Grafana 面板 "API Overview" → QPS 与 5xx 曲线
- 若 30 分钟内发过版:kubectl rollout undo deploy/api -n prod
- 若集中在单实例:kubectl delete pod <pod-name> -n prod 让它重建
- 若为下游依赖故障:按预案开启降级开关 feature.order.fallback=true

## 3. 再定位(止损之后再做,不要顺序颠倒)
- 错误分布:日志平台过滤 service:api AND status:500,看 URI 是否集中
- 调用链:按 traceID 下钻,确认是哪一跳耗时或报错
- 依赖面:MySQL 活跃连接数、Redis 命中率、第三方接口成功率

## 4. 升级条件
- 15 分钟内未止损 → 呼叫二线(服务 Owner: platform-team)
- 涉及资金或数据一致性 → 立即拉应急群并通知值班经理

## 5. 关联资料
- 面板:https://grafana.internal/d/api-overview
- 仓库:git@internal:platform/api.git
- 同类历史复盘:https://wiki.internal/postmortem/2026-07-11-api-5xx

Runbook 最好的素材来源就是真实事故。像Nginx 502/504 排查实录数据库连接池耗尽排查这类排障过程,处理完当天就应该沉淀成一页 Runbook,否则三个月后换个人还得从零摸索。

七、排班与交接:把隐性信息显性化

7.1 排班的几条硬原则

  • 一主一备:主值班 15 分钟未 ACK 自动升级到备班,不依赖「他应该看到了」。
  • 轮换周期一周起:一天一轮的交接成本高于收益,上下文永远建立不起来。
  • 值班期间降低开发负载:明确减掉 30%~50% 需求量,否则值班就是白干的加班。
  • 夜间被叫起来,次日可补休:让打扰有成本,团队才会真心去治噪音。
  • 新人先跟班两轮再独立:跟班期只观察和记录,不承担止损决策。

7.2 交接单模板

## On-call 交接单(每次轮换必填,5 分钟写完)

值班区间:2026-09-01 09:00 ~ 2026-09-08 09:00
交班人:zhangsan    接班人:lisi

### 1. 本轮告警统计
- P1 电话呼叫:2 次(其中夜间 1 次)
- P2 工单:11 条
- 判定为噪音:4 条(已提 3 条阈值调整 issue)

### 2. 未收尾事项(逐条交接,不许写"应该没事了")
- [ ] db-01 磁盘 78%,扩容变更单 CHG-20260902-11 待审批
- [ ] 订单服务偶发超时,已补链路埋点,持续观察中

### 3. 生效中的静默(接班第一件事就是核对)
- instance=db-01,到期 2026-09-02 16:00,原因:MySQL 版本升级

### 4. 本轮变更与发版
- api v2.14.3(09-03 上线,无异常)
- 网关限流阈值 2000 → 3000 QPS(09-05 生效)

### 5. 给下一班的提醒
- 09-09 有大促压测,QPS 类告警可能批量触发,提前确认压测窗口

交接单和故障复盘 Postmortem是配套的:交接单解决「进行中的事别断线」,复盘解决「已结束的事别重演」。两者都不该是走过场的文档,判断标准很简单——下一个人能否只靠这份文档接住工作。

八、用数据证明告警在变好

治理不能靠感觉。Prometheus 自身暴露的 ALERTSALERTS_FOR_STATE 两个内置指标,足够撑起一张告警健康度面板。

# 1. 近 7 天各告警触发次数排行——榜首基本就是噪音之王
topk(10, count_over_time(ALERTS{alertstate="firing"}[7d]))

# 2. 近 7 天 P1 触发次数,衡量值班被打断的强度
count_over_time(ALERTS{alertstate="firing", severity="P1"}[7d])

# 3. 当前每条 firing 告警已持续的分钟数
#    长期只持续几分钟就消失的,说明阈值过敏
(time() - ALERTS_FOR_STATE) / 60

# 4. 按服务维度统计告警分布,找出最该被优化的那个服务
sum by (service) (count_over_time(ALERTS{alertstate="firing"}[7d]))
健康度指标计算方式健康阈值
单班 P1 呼叫数一个值班周期内 P1 触发次数≤ 5 次/周
夜间打断率22:00–08:00 的 P1 占全部 P1 比例≤ 20%
告警可操作率有明确处置动作的告警 / 总告警≥ 90%
自愈噪音比5 分钟内自动恢复且无人处置的占比≤ 10%
Runbook 覆盖率runbook_url 的 P1/P2 占比100%

这套指标建议和DORA 研发效能度量放在同一张看板上:变更失败率、故障恢复时间与告警质量本来就是同一条链路上的因果关系,割裂看容易得出「多加告警就更安全」的错误结论。

九、四周落地路线图

  • 第 1 周|盘点:导出近 30 天全部告警,按触发次数排序。对 Top 20 逐条判定「响了以后有人动手吗」,没有的直接删或降 P3。这一步通常就能砍掉一半。
  • 第 2 周|分级与路由:给存活告警补齐 severity/service/owner 标签,上线 Alertmanager 三级路由与抑制规则,先在测试环境用 amtool check-config 验证。
  • 第 3 周|Runbook 与 SLO:为全部 P1 写 Runbook(每条不超过一页),并把 2~3 个核心服务的阈值告警改造成 burn rate 告警。
  • 第 4 周|排班与度量:固化一主一备轮换与交接单,上线告警健康度面板,把「单班 P1 数」列入团队周会议题。

整套动作不需要买新平台,全部基于现有 Prometheus 栈的配置能力就能完成,成本主要是删告警时的心理阻力——总有人担心「删了万一出事」。应对方式是留痕:把删除的规则归档到仓库并写明判定理由,三个月后回看,几乎没有一条会被恢复。

十、小结

告警治理的目标不是「监控得更全」,而是让每一次打断都值得。三条判据可以随时自查:这条告警有人负责吗?响了以后有事可做吗?值得在凌晨三点叫醒一个人吗?三个答案都是「是」,才配拿到 P1。

把分级、烧尽率、抑制、Runbook、交接单这五件事做扎实,On-call 就会从人人躲避的苦役,变成一项可交接、可度量、可持续的工程职责。而这也是团队真正具备生产责任感的分水岭。

上一篇 Spring Boot GraalVM 原生镜像编译实战
下一篇 大模型偏好对齐实战:RLHF 与 DPO 选型落地