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 变更;每季度系统架构会变。没有固定节奏,治理就会被日常需求挤掉,三个月后回到“没人看、没人删、没人管”。节奏的价值是把它变成例行工作,而不是英雄主义。

关联