56.4 告警治理
一句话
告警治理不是“再调一次阈值”,而是把告警当资产做存量清理、增量准入、定期审计;最终只看两个指标——可行动率与盲区。
先划边界:设计一条告警 ≠ 治理一套告警
50.5 告警(分级 / 症状 vs 原因 / runbook / 降噪) 讲的是单条告警怎么设计:分级、症状 vs 原因、燃烧率、runbook、分组/抑制/静默。
本节讲的是一套告警怎么长期不腐烂:谁负责、多久审一次、什么时候删、新增怎么准入、盲区怎么补。前者是“怎么写”,后者是“怎么管”。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “治理 = 降噪” | 降噪只解决“太多”;治理还要解决“没有”(盲区)、“没人管”(无 owner)、“过期没删”(债) |
| “告警越多越安全” | 告警的价值 = 可行动率 × 覆盖关键症状;数量本身是成本,不是安全 |
| “调低阈值就能少误报” | 调低阈值会漏报;真正的解法是换指标(症状/燃烧率)、加 for、合并/抑制,而不是把阈值调钝 |
| “告警是监控团队的事” | 告警的 owner 是服务 owner;监控团队只提供平台与规范,不能替业务判断“这条要不要叫人” |
治理的三件事
| 事 | 做什么 | 频率 | 产出 |
|---|---|---|---|
| 存量清理 | 删无效、降原因类、合并重复、补 owner/runbook | 月度 | 告警清单变短、噪声下降 |
| 增量准入 | 新告警必须过模板与评审,带 owner/runbook/单测 | 每次新增 | 不把新债写进系统 |
| 定期审计 | 全量盘点:噪声比、覆盖率、静默、过期 | 季度 | 记分卡 + 行动项 |
存量清理的优先级:先删“没人认领”和“从不行动”的,再补“缺 runbook”的,最后才调阈值。 顺序反了,就是在给没人看的告警做精装修。
新增告警的准入清单
| 项 | 要求 | 不合格的后果 |
|---|---|---|
| owner | 具体到团队/服务,不写“运维” | 响了没人认领 |
| 级别 | P0–P3,按“要不要现在叫醒”定 | 全量电话 = 没有电话 |
| 信号类型 | 症状或“未来症状”;原因类必须说明为什么不可逆 | 半夜被 CPU 叫醒 |
| 阈值来源 | 从 SLO/历史数据反推,写进注释(燃烧率公式见 50.7 SLO 与错误预算) | 拍脑袋阈值,频繁误报/漏报 |
| runbook | 五段齐全(含义/确认/止血/定位/找人) | 新人从零排查 |
| 单测 | promtool test rules 覆盖“该响/不该响” | 改规则靠线上验证 |
| 静默策略 | 最长静默时长 + 过期提醒 | 真故障在静默期 |
🔑 新告警的默认答案是“不加”。加一条告警,等于给未来每一个值班的人增加一次潜在打扰;没有 owner 和 runbook 的告警,不该进生产。
审计指标:四个就够
| 指标 | 算法 | 经验目标 | 超标动作 |
|---|---|---|---|
| 可行动率 | 处理后确认“确实需要动作”的告警 / 总告警 | ≥ 80% | 删/降级/合并低价值告警 |
| 噪声比 | 不可行动告警数 / 总告警数 | ≤ 30% | 见右侧动作 |
| 无 owner 告警 | owner 为空的告警数 | 0 | 补 owner 或删除 |
| 过期未评审 | 超过 180 天未评审 / 总数 | ≤ 20% | 排期评审,过期即删 |
另外两个辅助指标:盲区数(复盘里“当时没有告警”的事故数,目标 0)与静默过期率(过期未清理的静默数)。
一个能跑的“告警存量审计器”
package main
import (
"fmt"
"os"
"sort"
)
type alert struct {
name string
sev string
owner string
runbook bool
firesWeek int
actionable int // 收到后确实需要动作的比例
reviewedDays int
}
func (a alert) noise() float64 {
return float64(a.firesWeek) * float64(100-a.actionable) / 100
}
func main() {
alerts := []alert{
{"checkout-error-budget", "P1", "order", true, 12, 90, 10},
{"cart-5xx", "P1", "cart", true, 30, 60, 45},
{"node-cpu-high", "P2", "", true, 200, 5, 200},
{"disk-90", "P3", "infra", true, 8, 50, 30},
{"cert-expiry", "P2", "infra", false, 2, 100, 120},
{"mq-lag", "P2", "order", true, 50, 30, 60},
{"login-fail-rate", "P1", "auth", true, 6, 80, 5},
{"old-service-restart", "P3", "", true, 40, 10, 400},
}
totalFires, totalNoise := 0, 0.0
missingOwner, missingRunbook, stale, critNoRB := 0, 0, 0, 0
for _, a := range alerts {
totalFires += a.firesWeek
totalNoise += a.noise()
if a.owner == "" {
missingOwner++
}
if !a.runbook {
missingRunbook++
if a.sev == "P0" || a.sev == "P1" {
critNoRB++
}
}
if a.reviewedDays > 180 {
stale++
}
}
fmt.Println("== 告警存量审计 ==")
fmt.Printf("%-24s %-4s %-7s %-5s %7s %7s %8s %s\n",
"ALERT", "SEV", "OWNER", "RB", "FIRES", "ACT%", "NOISE/W", "LAST REVIEW")
sort.Slice(alerts, func(i, j int) bool { return alerts[i].noise() > alerts[j].noise() })
for _, a := range alerts {
owner := a.owner
if owner == "" {
owner = "—"
}
rb := "no"
if a.runbook {
rb = "yes"
}
mark := ""
if a.reviewedDays > 180 {
mark = " ← 过期"
}
fmt.Printf("%-24s %-4s %-7s %-5s %7d %6d%% %8.1f %d 天前%s\n",
a.name, a.sev, owner, rb, a.firesWeek, a.actionable, a.noise(), a.reviewedDays, mark)
}
fmt.Printf("\n== 记分卡 ==\n")
fmt.Printf(" 每周告警 %d 条,其中不可行动 %.1f 条(噪声比 %.1f%%)\n", totalFires, totalNoise, totalNoise/float64(totalFires)*100)
fmt.Printf(" 缺 owner %d 条;缺 runbook %d 条;超过 180 天未评审 %d 条(%.0f%%)\n",
missingOwner, missingRunbook, stale, float64(stale)/float64(len(alerts))*100)
type rule struct {
name string
pass bool
detail string
}
rules := []rule{
{"噪声比 ≤ 30%", totalNoise/float64(totalFires) <= 0.30,
fmt.Sprintf("当前 %.1f%%", totalNoise/float64(totalFires)*100)},
{"P0/P1 无缺 runbook", critNoRB == 0, fmt.Sprintf("当前 %d 条(P2 缺 runbook 另计 %d 条)", critNoRB, missingRunbook-critNoRB)},
{"无 owner 的告警 = 0", missingOwner == 0, fmt.Sprintf("当前 %d 条", missingOwner)},
{"过期未评审 ≤ 20%", float64(stale)/float64(len(alerts)) <= 0.20,
fmt.Sprintf("当前 %.0f%%", float64(stale)/float64(len(alerts))*100)},
}
fail := 0
for _, r := range rules {
status := "PASS"
if !r.pass {
status = "FAIL"
fail++
}
fmt.Printf(" %-4s %-22s %s\n", status, r.name, r.detail)
}
fmt.Println("\n优先动作:")
fmt.Println(" 1. node-cpu-high:200 次/周、5% 可行动 → 降为看板或删掉(原因类信号不该叫人)")
fmt.Println(" 2. old-service-restart:服务已下线/无 owner → 直接删除")
fmt.Println(" 3. mq-lag:30% 可行动 → 调阈值/加 for,或改为容量趋势单")
fmt.Println(" 4. 所有无 owner 的告警:补 owner 或删除,二选一,不允许“暂时留着”")
if fail > 0 {
os.Exit(1)
}
}
本机运行结果(退出码 1):
== 告警存量审计 ==
ALERT SEV OWNER RB FIRES ACT% NOISE/W LAST REVIEW
node-cpu-high P2 — yes 200 5% 190.0 200 天前 ← 过期
old-service-restart P3 — yes 40 10% 36.0 400 天前 ← 过期
mq-lag P2 order yes 50 30% 35.0 60 天前
cart-5xx P1 cart yes 30 60% 12.0 45 天前
disk-90 P3 infra yes 8 50% 4.0 30 天前
checkout-error-budget P1 order yes 12 90% 1.2 10 天前
login-fail-rate P1 auth yes 6 80% 1.2 5 天前
cert-expiry P2 infra no 2 100% 0.0 120 天前
== 记分卡 ==
每周告警 348 条,其中不可行动 279.4 条(噪声比 80.3%)
缺 owner 2 条;缺 runbook 1 条;超过 180 天未评审 2 条(25%)
FAIL 噪声比 ≤ 30% 当前 80.3%
PASS P0/P1 无缺 runbook 当前 0 条(P2 缺 runbook 另计 1 条)
FAIL 无 owner 的告警 = 0 当前 2 条
FAIL 过期未评审 ≤ 20% 当前 25%
优先动作:
1. node-cpu-high:200 次/周、5% 可行动 → 降为看板或删掉(原因类信号不该叫人)
2. old-service-restart:服务已下线/无 owner → 直接删除
3. mq-lag:30% 可行动 → 调阈值/加 for,或改为容量趋势单
4. 所有无 owner 的告警:补 owner 或删除,二选一,不允许“暂时留着”
这张表暴露了三类问题,处理动作完全不同:
| 现象 | 根因 | 动作 |
|---|---|---|
node-cpu-high 200 次/周、5% 可行动 | 对原因告警(CPU 是原因,不是症状) | 降为看板或删除,不是调阈值 |
old-service-restart 无 owner、400 天未评审 | 服务下线了,告警没清 | 直接删除 |
mq-lag 30% 可行动、35 次噪声/周 | 阈值/for 不合理,或本该是趋势单 | 调参或改成容量趋势 |
盲区:比噪声更危险
| 信号 | 含义 | 动作 |
|---|---|---|
| 事故复盘里“当时没有告警” | 监控假设错了 | 补一条症状告警,并加单测 |
| 告警响了但没人知道是哪个服务 | 路由/标签错 | 补 owner、service 标签、runbook |
| 同一故障反复发生、每次都是人先发现 | 告警覆盖不到 | 从依赖图反推:这个服务的上游失败,谁会先疼? |
| 告警只在工作时间响 | 阈值/通知渠道问题 | 检查静默、路由、值班覆盖 |
盲区不是靠“再加几条告警”解决的,而是靠每起事故都问一句:当时应该有什么告警? 这条问题要写进复盘模板(见 56.6 无指责复盘)。
治理节奏:周、月、季
| 周期 | 做什么 | 谁 | 产出 |
|---|---|---|---|
| 每周 | 看 TOP5 噪声告警、处理当周新增的误报 | 值班 + 服务 owner | 降噪 PR |
| 每月 | 新增告警评审、清理过期静默、补 owner/runbook | 服务 owner + 平台 | 准入与清理记录 |
| 每季度 | 全量审计、删长期无用告警、覆盖度复盘 | 稳定性负责人 | 记分卡 + 季度计划 |
治理不是“一次性运动”。没有节奏的治理,三个月后一定回到原样。
生产落地:指名 → 可抄 → 可验 → 可退
指名:规则即代码放 git(Prometheus rules + Alertmanager 配置),CI 里跑 promtool;告警元数据(owner、runbook、级别)写在规则 label 或独立的告警目录里;事件与值班用 PagerDuty / Grafana OnCall(见 56.3 on-call (值班 / 升级路径 / runbook))。
可抄(本机无 Prometheus,以下命令未验证):
# 1) 规则语法 + 单测:改阈值/窗口必须过 CI
promtool check rules rules/*.yml
promtool test rules rules_test.yml
# 2) Alertmanager 配置检查
amtool check-config alertmanager.yml
# 3) 规则必须带 owner/runbook(CI 里用脚本校验 label)
# - alert: CheckoutErrorBudgetBurnFast
# labels: {severity: P1, team: checkout, runbook: "https://.../checkout"}
可验:本节审计器真跑过,输出如上;把它接进 CI 或月度任务,噪声比/无 owner/过期项任一超标就产出报告。线上验证:随机抽 5 条告警,确认 owner 能在 5 分钟内说出“这条告警该做什么”;抽 3 起历史事故,确认“当时是否有告警”。
可退:
- 误删告警:规则在 git,回滚 commit 即可;删除前先确认没有 runbook/看板引用它。
- 调参导致漏报:保留旧规则一个发布周期,双跑对比触发次数;有异常立即回滚。
- 静默误配:静默必须带过期时间与 owner,过期自动提醒;发现真故障被静默,先删静默,再按 56.5 事故响应 处置并复盘。
反模式
| 反模式 | 后果 |
|---|---|
| 只加不删 | 告警清单越来越长,噪声吞掉真信号 |
| 用告警数量考核团队 | 团队把阈值调松/加静默,指标好看、盲区变大(见 59.3 度量的陷阱) |
| 无 owner 的告警长期存在 | 响了没人认领,最后全体静默 |
| 把“调低阈值”当治理 | 误报变漏报,问题更大 |
| 静默不设过期 | 真故障在静默期,没人知道 |
| 新告警不写单测 | 改规则靠线上验证,回归靠运气 |
| 只治理“响得多的”,不看盲区 | 噪声降了,但事故仍然靠用户报 |
| 把治理做成季度运动 | 三个月后回到原样 |
自检
Q1 50.5 已经讲了告警设计,为什么还需要“告警治理”?
▸ 答案:设计解决“单条告警怎么写”,治理解决“一套告警怎么长期保持有效”。系统会变、服务会下线、人会轮换,告警会腐烂:owner 离职、runbook 链接失效、阈值过期、静默忘了删。治理就是给这套资产定期的清理、准入与审计机制,否则设计得再好也会退化。
Q2 为什么“可行动率”比“告警数量”更能代表告警体系健康?
▸ 答案:告警的目的是让人采取动作。数量多但大多不需要动作,只会消耗信任,最终导致“看到就关”。可行动率高,说明每条告警都在传递“需要做事”的信号;再配合覆盖度(盲区),才能同时避免“太吵”和“太聋”。
Q3 一条每周响 200 次、只有 5% 需要动作的 CPU 告警,应该怎么处理?
▸ 答案:先判断信号类型:CPU 是原因类,不是用户症状,默认不该叫人。处理顺序:① 降为看板(保留观测);② 如果它越界会不可逆(如触发 OOM),改成“未来症状”告警并给出“还剩多久”;③ 如果确实需要动作,调阈值/加 for/合并。直接调低阈值只会把误报变成漏报。
Q4 盲区怎么发现?为什么不能靠“多加告警”解决?
▸ 答案:盲区只能从事后反推:每起事故问“当时应该有什么告警、为什么没响”。靠多加告警是“用数量掩盖假设错误”——你可能加了一堆原因类告警,仍然漏掉真正的用户症状。盲区的根因通常是:监控假设错了、标签/路由错了、或者根本没人定义“正常”。
Q5 治理节奏为什么必须是周/月/季,而不是“想起来就做”?
▸ 答案:告警腐烂是持续发生的:每周都有新告警、新误报;每月都有服务上线/下线、owner 变更;每季度系统架构会变。没有固定节奏,治理就会被日常需求挤掉,三个月后回到“没人看、没人删、没人管”。节奏的价值是把它变成例行工作,而不是英雄主义。