告警指纹:一次故障为什么只产生一条 FIRING 记录

告警指纹:一次故障为什么只产生一条 FIRING 记录

Alioth 技术笔记 · 第 06 篇 · 告警模型 仓库:github.com/aide-family... | 在线演示:web.alioth.asia/

导语

告警系统最容易被低估的一件事,是"同一条告警到底算一条还是十条"。评测器每两秒跑一轮,一轮命中、下一轮还命中,如果每一轮都插一条事件,值班页面会瞬间被同一个故障刷满。Alioth 的做法是给"这组标签上的这次故障"算一个稳定指纹,再用一张只有三列的表把它钉成唯一。这篇讲指纹怎么算、占位表怎么用,以及为什么"恢复"是靠缺席判断出来的。

一、指纹:规则 + 数据源 + 排序后的标签

go 复制代码
func Of(ruleUID, datasourceUID string, labels map[string]string) string {
    keys := make([]string, 0, len(labels))
    for key := range labels { keys = append(keys, key) }
    sort.Strings(keys)
    sum := sha256.New()
    sum.Write([]byte(ruleUID));        sum.Write([]byte{0})
    sum.Write([]byte(datasourceUID));  sum.Write([]byte{0})
    for _, key := range keys {
        sum.Write([]byte(key));        sum.Write([]byte{0})
        sum.Write([]byte(labels[key])); sum.Write([]byte{0})
    }
    return hex.EncodeToString(sum.Sum(nil))
}

三个要素各有用意:

  • 规则:不同规则命中同一组标签,是两条独立告警。
  • 数据源 :同一条规则匹配两个 Prometheus,instance 标签可能一模一样,但它们是两个地方的问题。少了这一项,两个集群的宕机会被合并成一条。
  • 标签 :一条规则的实际命中是"某组标签",比如 {instance="10.0.0.1", job="api"}。这才是故障的身份。

按键排序是必须的:Go 的 map 遍历顺序是随机的,不排序的话同一个标签集每次算出来的指纹都不一样,指纹就退化成了随机数。字段之间插 \0 是为了防碰撞------{"a":"bc"} 和 {"ab":"c"} 在朴素拼接下会得到同一份字节流。

二、占位表:一张只有三列的表

alert_event_firings 只有四列:租户、指纹、事件 UID、规则 UID,其中 (namespace_uid, fingerprint) 唯一:

go 复制代码
// app/alioth/internal/data/ent/migrate/schema.go(节选)
Indexes: []*schema.Index{
    {
        Name:    "alerteventfiring_namespace_uid_fingerprint",
        Unique:  true,
        Columns: []*schema.Column{AlertEventFiringsColumns[1], AlertEventFiringsColumns[2]},
    },
    {
        Name:    "alerteventfiring_event_uid",
        Unique:  true,
        Columns: []*schema.Column{AlertEventFiringsColumns[3]},
    },
}

这张表的语义是:一行 = 一个"当前仍在触发"的身份 。事件本体在 alert_events 里,历史归档也在那边;占位表只回答"现在还响着吗"。恢复时把这一行删掉,历史行留着------"是否仍在触发"和"曾经发生过什么"因此解耦,不必在事件表上加部分唯一索引(历史表还要按租户归档、分区,索引越简单越好)。

三、同一批命中先在内存里收敛

一份评测报告里可能有多个样本标签完全相同(例如两种驱动返回重复序列),落库前先按指纹去重:

go 复制代码
hits := make(map[string]bo.EvalHit, len(report.Hits))
for _, hit := range report.Hits {
    if hit.Fingerprint == "" { continue }   // 空指纹直接跳过
    hits[hit.Fingerprint] = hit
}

后面所有循环都遍历这个 map:已存在占位就更新摘要、样本值、级别;不存在就新建事件 + 新建占位。去重发生在数据库之前,所以不会出现"同一个指纹插两次再靠约束报错"的噪音。

四、恢复由"缺席"驱动

一轮评测结束后,逻辑是:当前占位集合 - 本次命中集合 = 需要恢复的事件。

go 复制代码
for fingerprint, ref := range current {
    if _, ok := hits[fingerprint]; ok { continue }   // 还在触发
    tx.AlertEvent.Update().Where(...StatusEQ("FIRING")).
        SetStatus("RECOVERED").SetEndsAt(now).SetEvalSlot(report.EvalSlot).Save(ctx)
    tx.AlertEventFiring.Delete().Where(alerteventfiring.IDEQ(ref.firing.ID)).Exec(ctx)
    // 写时间线、发恢复通知、取消升级
}

这解释了第 05 篇那条硬规则为什么必须存在:查询失败不能返回空采样。一旦"查不到"被当成"没命中",整条规则的所有事件会在一轮之内全部恢复,然后下一轮又全部重新触发------值班的人会收到一波恢复通知和一波新建通知,而故障根本没消失。

代码在入口处就把这条路堵死了:

go 复制代码
// 查询失败不能当成恢复信号:失败批次只用于数据源可用性统计,
// 不进入 applyHits,否则仍在触发的规则事件会被误恢复。
if report.Failed { ... return }

五、标签变了,指纹就该变

mergeRuleLabels 有一句注释:规则上配置的标签只补缺,采样标签优先。再接一句:这样规则标签修改后指纹会变化,旧事件恢复、新事件按新标签建立。

这个行为初看像缺陷------"我就改了个标签,为什么告警变成两条了?"------但反过来想:告警的身份就是那组标签,身份变了就不该复用旧事件,否则时间线上会出现一条"标签中途变化"的告警,统计和订阅都无从解释。所以改标签的代价是旧事件恢复 + 新事件建立,这是显式的行为,而不是静默合并。

同样地,级别(level)不参与指纹:把告警从 P2 升到 P1 是同一次故障的严重程度变化,应该更新同一条事件,而不是新建。摘要、描述、自定义变量也都不参与------它们只是文案。

六、幂等与并发

评测报告可能被重复提交(锁失效、重启重试),所以整份报告的处理包在一个事务里,并用批次表做幂等键:

go 复制代码
recorded, err := tx.AlertEvalBatch.Query().Where(
    alertevalbatch.RuleUIDEQ(report.RuleUID),
    alertevalbatch.DatasourceUIDEQ(dsUID),
    alertevalbatch.EvalSlotEQ(report.EvalSlot),
).Exist(ctx)
if recorded { return nil }

(rule_uid, datasource_uid, eval_slot) 上有唯一索引。即使预检漏过去(两个副本同时到),插入失败会被识别成 IsConstraintError 并当成"已处理"返回,而不是报错------因为另一份报告已经改好了状态。

占位表同理。新建占位撞唯一约束时,代码注释写得很清楚:

go 复制代码
// 并发回写时另一个副本已经建立同指纹的 FIRING 占位;事务会整体回滚,
// 事件由那个副本负责,调用方不需要报错。
if ent.IsConstraintError(err) { return errAvailabilityRaced }

七、几个真实的坑

  1. 孤儿占位必须清。 事件被归档但占位没删干净时,这条规则之后的每一轮评测都会卡在"占位对应的的事件不存在"。代码在读占位时顺手删除孤儿行,否则一条脏数据能让整条规则永久停摆。
  2. 探测事件没有数据源。 探测规则的事件按目标标签收敛,applyHitsScoped 用 scope 函数区分范围,避免"另一个数据源的事件被本轮恢复"。
  3. 跳过评测也要走恢复判断。 规则从指标改成日志时,某些数据源会被标记 skipped;如果直接 return,旧的 FIRING 会永远留在实时列表里。
  4. 可用性告警用固定指纹。 数据源连续失败会生成一条"可用性"事件,指纹是 fingerprint.Of("datasource-availability", dsUID, {kind: "availability"})------它不是某条规则产生的,但同样遵守"一个身份一条事件"的规则。

八、怎么验证

pkg/fingerprint 的测试很短,但钉住了三条不变量:键序无关、不同规则不同指纹、不同数据源不同指纹。端到端验证可以跑起来看:

bash 复制代码
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080

配一条永远满足条件的规则,观察实时告警:无论刷新多少次、等多少轮评测,列表里始终只有一条 FIRING;把阈值改到不满足,几秒后它变成 RECOVERED 并移出实时列表,历史里仍是同一条记录。

结语

告警指纹的本质,是给"一次故障"下一个可计算的定义:规则 + 数据源 + 标签集。有了它,"是否仍在触发"就变成一个集合差运算,幂等和并发控制也退化成两条唯一索引。代价是一次标签改动会让告警换一条记录,但那是身份真的变了------比起把两段不同的历史缝进同一条事件,这个代价小得多。

相关推荐
鶴哥只手遮天1 小时前
《Redis 8.10.3 深度剖析》系列 ·第 13 章 Set:三种编码、集合运算与随机采样
redis·后端·开源
wyxspark1 小时前
在本地 AI 中实现绝对可复现:一份 12,810 条哈希的硬核工程记录
github
王中阳Go1 小时前
你把重试加到 2 次那天,200 条里成功还是 181 条,你回看过吗?
人工智能·后端·架构
Zan1 小时前
Tauri 2 实战:为什么一个可靠的桌面应用,应该由 Rust Host 管理生命周期?
前端·架构·rust
136096757231 小时前
一个线程只有一个事件循环:"同步入口"全在抢所有权
后端
简创AIGC陶先生1 小时前
【直播切片工作台】第31章:任务接口与任务服务
github
神鹰手哥_起飞版1 小时前
我是怎么管理 Coding Agent 的:把任务交出去,也把结果验回来
人工智能·架构·产品经理
我不会起名字3222 小时前
消费回调固定 return nil:错误传播与 Kafka 位点提交的区别
后端·mongodb·kafka·go·im·故障排查
终码一生3 小时前
开源项目|一款强大的桌面远程控制软件
后端·开源