上周那台核心交换机掉电的时候,我正好盯着通知通道。两分钟进来 1638 条告警,全是红的。等我翻完前二十条,故障已经自己结束了。
我后来把那半小时的告警流在本地重放了一遍:40 台设备(1 台核心、3 台汇聚、12 台接入、24 台服务器),采集周期 60 秒,观察窗口 30 分钟,事件是「核心掉电 15 分钟」+「同窗口里一台接入交换机自己坏了」。ground truth 只有两个根因,剩下的一千多条全是噪声。
这篇记的是我往上叠的四层收敛,每一层吃掉多少,又各自漏掉什么。
第一层:把「同一件事」并成一条
第一反应是去重。问题在于用什么当 key。我把四种取法都跑了一遍:
const KEYS = {
raw: a => a.host + '|' + a.type + '|' + a.raw, // 原始消息
norm: a => a.host + '|' + a.type + '|' + normalize(a.raw), // 数字打码
type: a => a.host + '|' + a.type,
host: a => a.host,
};
function normalize(s) { return s.replace(/\d+/g, '#'); }1638 条告警跑下来:
none -> 工单 1638
raw -> 工单 872 SRV-13: heartbeat×1 icmp×25 svc×1 heartbeat×1 svc×1 ...
norm -> 工单 104 SRV-13: heartbeat×25 icmp×25 svc×25
host -> 工单 40 SRV-13: heartbeat×75raw 那一档是我最开始的写法,合到 872 就卡住了。查了一下是 443 端口连接超时 耗时 3001ms 这种消息里的变量在作怪——每次采集耗时长得都不一样,同一台机器同一个症状被拆成 25 条。icmp 那类消息里没有变量(丢包率 100%),所以还能正常并,看着像"去重生效了",其实只生效了一半。
把数字统一打码成 # 之后降到 104。再激进一点只按主机名分,能到 40,但代价是我看不到"到底是 ping 不通还是 443 超时",这一档我不接受。
第二层:把被连累的摁下去
104 条还是看不完。绝大多数设备是因为核心挂了才不可达,它们自己没病。我按拓扑往上去找失联的祖先,找着就摁掉:
// 子告警的起点落在某个祖先的失联区间内(含 grace 宽限)-> 判为被连累
for (const a of ancestorsOf(topo, e.host)) {
for (const [s, en] of hostDown.get(a) || []) {
if (e.first >= s - grace && e.first < en) { e.suppressed = true; break; }
}
}这里踩了一个坑。grace = 0 的时候,工单只从 104 降到 74——该摁的一台没摁掉。原因是检测延迟不一样:服务器走 Agent 心跳,故障当拍就能报;交换机走 SNMP 轮询,要慢 60 秒。于是 24 台服务器在 t=300 就"失联"了,而核心到 t=360 才被判失联。子比父还早,抑制条件压根不成立。
grace = 60 之后,74 直接掉到 2。这一层是四层里收益最大的。
顺手对照了一下 Prometheus Alertmanager 的做法:它没有一个叫 grace 的参数,但 group_wait 的设计目的之一就是等抑制告警先到——官方文档原话是「Allows to wait for ... one or more inhibiting alerts to arrive and mute any target alerts before the first notification」,默认 30s。我这套环境里检测延迟差是 60 秒,按默认值跑,就是我刚才那个 74 条的下场。
中间还有个判据我返工过一次。我第一版写的是"子告警整段被父的失联区间完全包含才摁掉",看着比现在更严格,跑出来却是工单 4、根因仍然只有核心——该摁的没摁掉,独立故障也没识别出来,两头不讨好。改成只看起点之后才对:抑制判断的是起因,不是时长。一台设备撑得比父故障久,说明它另有毛病,但那是恢复对账该管的事,不该混进抑制条件里。
第三层:抑制会吞掉真故障
工单是干净了,但只有 2 条的时候我心里不踏实——ground truth 是两个根因,我只拿到一个(核心)。那台同窗口自己坏掉的接入交换机,因为它挂在核心下面,整段告警被当成"被连累"摁掉了。
我加了一层恢复对账:祖先全都恢复了而自己还没恢复的,补发。
// 只有"所有曾经失联的祖先都恢复了、而自己还没恢复"才补发
const aes = ancestors.map(a => maxEndOf(overlap(all.get(a), [s, en]))).filter(Boolean);
if (aes.length && Math.max(...aes) + CHECK < en) {
reissue(h, { delay: Math.max(...aes) + CHECK - s });
}跑下来是这样:
父故障 300s -> 工单 3 根因 [SW-ACC-07, SW-CORE-01] 命中 2/2 补发延迟 360s
父故障 900s -> 工单 3 根因 [SW-ACC-07, SW-CORE-01] 命中 2/2 补发延迟 960s
父故障 1500s -> 工单 2 根因 [SW-CORE-01] 命中 1/2 无法补发命中率回到 100% 了,但延迟等于父故障的剩余时长。核心坏 15 分钟,那台独立故障就要等 16 分钟才被我发现。更糟的是最后一行:父故障一直不恢复,对账就永远触发不了。这不是调参能解决的,是这条路子的结构性缺陷。
第四层:「谁先坏」这条信息值钱
我换了个角度:如果一台设备比它所有失联的祖先都更早失联,那它显然不是被连累的,当场就能定成独立根因,不用等谁恢复。
我在另一组数据上验了这个判据——接入交换机比核心早 15 分钟坏:
grace= 60s -> 工单 4 根因 [SW-ACC-03, SW-CORE-01] 命中 2/2
grace=600s -> 工单 4 根因 [SW-ACC-03, SW-CORE-01] 命中 2/2
grace=900s -> 工单 2 根因 [SW-CORE-01] 命中 1/2 <- 被误抑制时间序判据在 grace 600 秒以内是零延迟救回的。但最后一行给了我一巴掌:grace 一旦大到盖过"独立故障提前量",先坏的那台会被直接摁掉,连进候选列表的资格都没有,时间序也救不回来。
所以 grace 是夹在中间的:下界是检测延迟差(我这里是 60 秒),上界是你能容忍的独立故障提前量。两头都不能松。
顺手验的两件事
采集器自己挂了怎么办。 我造了一次采集器故障,6 台服务器被误报不可达,90 条告警。拓扑抑制对这种情况完全无效——祖先没病,没有可抑制的依据。改成用另一条路径主动探测一次:30 次探测,90 条全判噪声,0 工单。不过这招有边界:核心真的 down 的时候,探测路径同样走不通,那时候探测给不了我任何信息。
去重窗口别开太大。 核心抖了两下(300~420 和 900~1200),我把窗口开到 600 秒试试,结果两次故障被并成一条 360~1200,中间的恢复事件整个丢了。窗口这个东西,开小了是工单多,开大了是丢事件,我宁可选前者。
我的判断
Google SRE 那本书里有一句话我以前只是点头,这次是真信了:「When pages occur too frequently, employees second-guess, skim, or even ignore incoming alerts, sometimes even ignoring a 'real' page that's masked by the noise.」1638 条的时候我就是在 skim,不是在排查。
但我现在不会把「压得越狠越好」当目标。四层叠完从 1638 到 3,压缩了 99.8%,代价是一个真故障延迟 16 分钟才被看见,grace 调大一点还会被彻底吞掉。收敛这件事,真正的指标不是工单数,是漏报的代价。抑制和对账这两层,我宁可多留几条工单,也不愿意在根因上赌一把。
另外一个意外收获:这套东西里最值钱的不是抑制算法,是拓扑数据本身。没有那张"谁挂在谁下面"的表,后面三层全都无从谈起。我以前把 CMDB 当资产台账在维护,现在觉得它更像是告警系统的地基。
整套逻辑我最后落成一个不到两百行的零依赖脚本,接在告警出口上,不依赖任何平台特性。收敛这件事本身没有技术门槛,难的是你得先承认"我压掉的每一条,都可能是真的"——不承认这一点,就会一路调到只剩 1 条工单,然后在某次真正的多点故障里集体失明。
信息源与核对日期(2026-10-22)
- Prometheus Alertmanager 配置文档
inhibit_rules语义、group_wait/group_interval/repeat_interval默认值(30s / 5m / 4h),核对自 prometheus.io 官方文档(2026-10-22,200 无跳转)。 - Google SRE《Monitoring Distributed Systems》关于 page 的四条原则与噪音告警的论述,核对自 sre.google 原文(2026-10-22)。该站本机连接被重置,按惯例不挂链接,只写文字。
- 文中所有告警条数、工单数、命中率、延迟均为本机零依赖 Node 脚本实跑输出,拓扑与故障时刻是我在脚本里构造的,不是生产环境数据。