行业背景与现场痛点:为什么这件事反复出问题
设备从几十个测点扩到上千个之后,告警条数几乎从不按比例增长,它沿着三条通道被放大。第一条是一对多:一台网关或一台采集主机掉线,挂在它下面的几十个测点会各自判定自己失联,同一个原因因此在十几分钟里变成几十条不同资源的告警。第二条是抖动:观测值在判定边界附近来回浮动,同一条规则一小时内反复触发又反复恢复,而每一次恢复通知也是一次外发,等于噪声翻了一倍。第三条是同源多规则:同一个物理故障被温度、电流、通信质量三条规则分别命中,同一时刻发出三条措辞不同、含义相同的告警。这三条通道都不需要新设备,只要系统还在往上加规则,它们就会一直存在。
噪声的代价不只是看着烦,它会改变人的行为。值班人最先学会的是清屏:按“全部已读”,或者把群消息设成免打扰,只在有人单独打电话时抬头看一眼。接下去是误报固化:某条规则一晚上响几十遍而每次都是假的,第二天就有人把它整条关掉,系统里不留记录,几个月后谁也想不起这条规则为什么不存在了。再往后是通道被占满:严重与提示共用同一个短信模板、同一个群,低优先级把高优先级挤出去,真正需要半夜起来处理的那条排在一百条未读消息的中间。最后是没有闭环:谁认领了、处理到哪一步、是不是同一个人在跟,系统里查不到,交接班只能靠口头问一句“那个报修是谁在跟”。
只加规则的路线必然走到这一步。规则越多、覆盖越全,噪声就越多;噪声越多,愿意看的人越少;看的人越少,剩下的唯一动作就是“全部静音”。静音之后系统在形式上还在告警,但已经没有人拿它做判断,它退化成一份事后追责用的记录,而不是一个能让故障被发现的机制。
所以这件事靠调高判定边界、或者多培训几轮值班人员都解决不了。把现场的问题拆开,其实只有三件事:哪些告警应该合并成一条,哪些应该有条件地不发,哪些必须叫醒人。第一件是去重与收敛,第二件是抑制与静默,第三件是分级与路由。三件事各有自己的字段、写权限和验证方法,压成一层去调,就退化成“多发几条还是少发几条”的参数问题——参数调完当周确实安静了,下一批设备上线,噪声又会一样地回来。
方案与取舍:可落地的结构怎么定
先给结构,再谈取舍。整套东西是三层加一份资源拓扑。第一层是产生:规则命中之后,这条要不要成为一条告警事件。第二层是收敛:同一个故障已经有一条在发,这条要不要再发出去。第三层是路由:这一条走哪个通道、多久没人接就升级。三层之上还有一份资源拓扑——网关下面挂着哪些测点、测点属于哪个地块或机房。拓扑不是可选件:没有「谁挂在谁下面」,就算不出影响面,既没法分级,也没法判断抑制关系是否成立。
| 层 | 回答什么问题 | 谁能写 | 写错了怎么改 |
|---|---|---|---|
| 产生 | 规则命中后是否成为一条告警事件 | 规则由运维修订,事件只由引擎追加 | 规则带版本号修订;事件只追加,判错用冲正记录抵消,不删不改 |
| 收敛 | 同指纹是否合并、是否再外发 | 引擎自动写,人只读 | 收敛组只累加计数与首末时间,不覆盖已有记录 |
| 路由 | 走哪个通道、多久升级 | 分级矩阵由运维定,认领与升级由人写 | 分级规则带原因修订;动作记录只追加,含人与时间 |
第一条取舍是指纹粒度。按完整告警文本做键太细,观测值一变就是一条新告警,收敛等于没做;只按「资源」做键又太粗,同一台网关下的不同故障会被并成一条,恢复时说不清恢复了什么。取「资源 + 规则 + 故障类型」三段拼接,观测值不进指纹。代价是同一资源上真正并发的两个故障只能靠故障类型区分,字段填错就会并错,这一点只能靠事后抽样核对兜住。
第二条是收敛、抑制、静默必须分开。收敛是自动的,按窗口计数;抑制是有条件的,父告警存在期间子告警折叠进父告警的计数,条件消失自动解除;静默是人工的,必须带到期时间和原因。把静默当收敛用,等于给自己造一个没有到期日的盲区。
第三条是分级维度。只留一个「严重度」字段,最后一定退化成按规则名分级——谁写的规则,谁觉得自己那条最严重。改成「影响面 × 时效」二维:影响面分单点、一组、整场,时效分可等、不可等,二维交叉出来才是通道与升级节奏。
第四条是规则引擎与统计或模型降噪之间怎么选。先规则。没有标注数据、没有稳定基线的时候,模型给出的是一个说不清理由的静音,值班人没法为它负责;规则至少能回答「这条为什么没发出去」。
第五条是拓扑谁维护。影响面完全依赖上下游关系,这份关系由谁维护、什么时候更新,必须在方案里写死——写进运维交接单,每次新增或迁移网关时更新一次。否则半年后拓扑过期,分级还在按过期关系算。
代价要写在明面上:拓扑是长期维护成本,每加一层设备就多一次更新;窗口收敛会让首次通知晚几十秒。所以必须同时有一份不进收敛的例外清单——人身安全、门禁与消防联动,这些逐条发,宁可重复也不合并。
技术实现:字段、口径与关键逻辑
落到系统里是四类记录,每类只允许一种写方式:规则可版本化修订,事件只追加、判错写冲正记录,收敛组只更新计数与首末时间,动作只追加且带人和时间。代价是表只涨不落,得按周期归档。
| 对象 | 必需字段 | 校验重点 |
|---|---|---|
| 规则 | 规则标识、版本、资源范围、故障类型、判定表达式、生效区间 | 同一资源同一故障类型不得有两条同版本规则同时生效 |
| 告警事件 | 事件号、资源、规则标识与版本、故障类型、首次与末次采样时间、观测值、指纹 | 事件号全局唯一且只追加;指纹写入时算好落库,不在前端补算 |
| 收敛组 | 指纹、窗口起点、累计次数、首末时间、状态 | 同一指纹同一窗口只能有一个未关闭的组 |
| 动作 | 动作类型、操作人、时间、原因、关联事件或组 | 静默必须带到期时间;升级必须记录判定依据 |
指纹由资源标识、规则标识、故障类型三段拼接后取哈希前缀。观测值不进指纹——它每采样一次就可能变,进了指纹等于每次采样都是新告警,收敛直接失效。
去抖用连续采样数而不是单点:连续 N 次超限触发,连续 M 次回到正常区间才判恢复。换来的是延迟,减少的是边界附近来回造成的翻转。N 与 M 要一起按采样频率与工况标定,只调触发不调恢复,恢复通知照样把噪声翻倍。
窗口收敛是同指纹在一个窗内只外发首条,后续只累加计数;恢复时发一条通知,带持续时长与累计次数。窗口长度与去抖参数必须一起看:短于采样周期等于没收敛,过长又让持续故障迟迟不升级。
抑制是父告警存在期间子告警不单独外发,折叠进父告警的计数,关系必须留记录,事后要能回答「这条为什么不发」。父告警恢复时被抑制的子告警要重新评估,不能跟着一起消失——否则网关恢复、测点还离线,系统里一条告警都没有。代价是恢复瞬间有一波集中评估,要排队串行处理。
分级按「影响面 × 时效」二维交叉,映射到通道与升级节奏:
| 影响面 \ 时效 | 可等 | 不可等 |
|---|---|---|
| 单点 | 站内消息,30 分钟内首推,4 小时未认领转群通知 | 群通知,5 分钟内首推,30 分钟未认领升级短信 |
| 一组 | 群通知,10 分钟内首推,1 小时未认领升级短信 | 短信,5 分钟内首推,20 分钟未认领升级电话 |
| 整场 | 短信,10 分钟内首推,1 小时未认领升级电话 | 电话,即时首推,10 分钟无人接再呼一次 |
影响面从拓扑算,时效由这个故障在业务里的后果定。两者都不写成规则里的一句说明——说明不参与计算,半年后也没人核对。
状态机只有四个:firing → acknowledged → resolved,另加 suppressed,带到期时间。认领、关闭、静默三类转换必须留痕(人、时间、原因);系统自动的收敛与抑制只改收敛组计数,不改事件状态。代价是报表要按「状态 × 是否被抑制」两个轴读。
下面这段是上述判定的最小骨架,只跑虚构数据,不连任何真实系统:
# 告警治理最小骨架:指纹化 -> 窗口收敛 -> 影响面×时效分级
# 只用标准库,输入是虚构事件,不连任何真实系统
# expect: 原始事件 12 条,指纹 6 个
# expect: 窗口收敛后外发 8 条(原始 12 条)
# expect: 分级结果 电话 1 / 短信 3 / 群通知 1 / 站内 3
import hashlib
from collections import OrderedDict
WINDOW_SEC = 300 # 收敛窗口,需按现场采样频率标定
# 拓扑:资源 -> 影响面(single / group / site);由运维在接入时维护
IMPACT = {"gw-a": "group", "gw-b": "site", "sensor-c": "single"}
# 故障类型 -> 时效(urgent 不可等 / waitable 可等)
URGENCY = {"link-down": "urgent", "overload": "waitable",
"high": "waitable", "retry": "waitable"}
# (资源, 规则, 故障类型, 时间偏移秒, 观测值)
EVENTS = [
("gw-a", "offline", "link-down", 0, 0),
("gw-a", "offline", "link-down", 60, 0),
("gw-a", "offline", "link-down", 120, 0),
("gw-a", "current-high", "overload", 30, 12),
("gw-a", "offline", "link-down", 400, 0),
("gw-b", "offline", "link-down", 10, 0),
("gw-b", "offline", "link-down", 50, 0),
("gw-b", "current-high", "overload", 80, 18),
("sensor-c", "temp-high", "high", 15, 9),
("sensor-c", "temp-high", "high", 20, 10),
("sensor-c", "comm-retry", "retry", 25, 3),
("sensor-c", "temp-high", "high", 500, 11),
]
def fingerprint(res, rule, fault):
"""资源 + 规则 + 故障类型;观测值不进指纹,否则每次采样都是一条新告警。"""
return hashlib.sha1(f"{res}|{rule}|{fault}".encode("utf-8")).hexdigest()[:8]
def converge(events, window):
"""同一指纹在同一窗口内只外发首条,其余只累加计数与首末时间。"""
groups = OrderedDict()
for res, rule, fault, ts, val in sorted(events, key=lambda e: e[3]):
key = (fingerprint(res, rule, fault), ts // window)
g = groups.get(key)
if g is None:
groups[key] = {"res": res, "fault": fault, "first": ts,
"last": ts, "count": 1, "peak": val}
else:
g["last"], g["count"] = ts, g["count"] + 1
g["peak"] = max(g["peak"], val)
return groups
# 影响面 × 时效 -> 通道 / 首次通知时限 / 升级节奏
MATRIX = {
("single", "waitable"): ("站内", "30 分钟内", "4 小时未认领转群通知"),
("single", "urgent"): ("群通知", "5 分钟内", "30 分钟未认领升级短信"),
("group", "waitable"): ("群通知", "10 分钟内", "1 小时未认领升级短信"),
("group", "urgent"): ("短信", "5 分钟内", "20 分钟未认领升级电话"),
("site", "waitable"): ("短信", "10 分钟内", "1 小时未认领升级电话"),
("site", "urgent"): ("电话", "即时", "10 分钟无人接再呼一次"),
}
def grade(g):
impact = IMPACT.get(g["res"], "single")
urgency = URGENCY.get(g["fault"], "waitable")
return (impact, urgency) + MATRIX[(impact, urgency)]
groups = converge(EVENTS, WINDOW_SEC)
fps = {g["res"] + "/" + g["fault"] for g in groups.values()}
print(f"原始事件 {len(EVENTS)} 条,指纹 {len(fps)} 个")
print(f"窗口收敛后外发 {len(groups)} 条(原始 {len(EVENTS)} 条)")
tally = OrderedDict((c, 0) for c in ("电话", "短信", "群通知", "站内"))
for g in groups.values():
impact, urgency, channel, deadline, escalate = grade(g)
tally[channel] += 1
print(f"{g['res']} {impact}/{urgency} -> {channel},{deadline}首推,"
f"{escalate}(窗口内 {g['count']} 次,峰值 {g['peak']})")
print("分级结果 " + " / ".join(f"{c} {n}" for c, n in tally.items()))
输出三行对应三件事:原始事件的指纹数、收敛前后的条数、按矩阵分出的通道分布;同一网关的三条离线事件在同一窗口并成一条、跨窗口后重新开组,输出里都能直接看到。真实系统还要在收敛组关闭时补发恢复通知,示例只保留判定部分。
以上是记录结构与判定顺序;具体阈值、去抖次数、窗口长度必须由现场按设备与工况标定,本文不给默认值。
项目实践与价值边界:一次真实落地和它的局限
先把边界写死:本节没有可引用的客户验收记录,也不提供实测收益数字。下面这个场景是虚构的,只用来解释字段与判定顺序怎么对上——它不代表真实生产记录,也不等于已验证的降噪能力。
场景是一个园区平台:若干网关,每台下面挂若干测点,某台网关断电 12 分钟。不做治理时,每个测点各报一条离线,中间夹着通信重试失败与恢复通知,值班人看到的是几十条同义告警,前几条还看一眼,后面的直接未读清屏。按三层治理后,人看到两件事:一条父告警(网关离线),带被抑制子告警的计数;恢复时一条恢复通知,带持续时长与影响测点数。差别在结构——「折叠为一条并计数」是算法性质,写不出百分比,写成百分比就是编数据。
上线前要过的验证清单:
| 验证项 | 预期 | 待采集证据 |
|---|---|---|
| 指纹稳定性 | 同一故障反复采样只出一个指纹 | 采样序列与指纹列表对照 |
| 窗口收敛 | 窗口内只外发首条,计数等于事件数 | 收敛组与原始事件数核对 |
| 抑制有效性 | 父告警在时子告警不外发,计数进父组 | 抑制记录与父组计数 |
| 漏报回归 | 父告警恢复后子告警重现;静默到期恢复外发 | 恢复后首轮评估事件清单 |
| 抖动抑制 | 边界附近往返时触发与恢复远少于采样翻转 | 翻转次数与状态转换次数 |
| 分级命中 | 每个收敛组落到预期通道与时限 | 矩阵输出与实际分发记录 |
上表后两条最容易漏:漏报是治理唯一的真实风险,抖动只能靠翻转次数与状态转换次数比出来。度量口径要连分子分母一起写:收敛比=外发条数 / 原始事件数,按天;去抖后的状态翻转次数按天计;首次通知时延=送达时间减首采时间,按周取分位;静默到期复核率=按期复核数 / 到期数;漏报复盘条数按周统计,只数事后确认该报而未报的。分母不写清,比率随时能被口径拉高。
实施分四步,顺序不能反:先定指纹与拓扑,把编号唯一范围、资源层级写清;再用虚构或历史数据离线回放,逐项跑通;然后与现有告警抽样核对,差异逐条关闭;最后才评估试运行范围。本轮没有进入任何生产环境,也没有改动在跑的系统。
最后是价值边界。治理不消灭告警,也不替代根因分析,产出只有一件事——让该叫醒人的那几条真的能叫醒人。把降噪本身当成目标,第一轮就会把有用的告警一起关掉,而关掉的那几条不会有人来投诉。
