夜莺告警引擎内核:一个优雅的设计
这是一个从 Prometheus 指标到告警事件的完整引擎。它做的事情很简单:把 Prometheus 里的指标曲线,变成可追踪、可管理的告警事件。
一、这个引擎解决什么问题?
它做的事
假设你的系统在 Prometheus 里有这些指标:
alarm_log_error_total:错误日志数量alarm_sql_slow_count:慢 SQL 数量alarm_api_timeout_total:API 超时数量
这个引擎的工作就是:
- 定时去 Prometheus 查询这些指标
- 判断是否异常(比如错误数 > 10)
- 确认是真问题还是毛刺(持续一段时间才算)
- 生成告警事件,记录下来,必要时发通知
- 追踪告警状态:知道哪些问题还在,哪些已经恢复
为什么需要这个引擎?
Prometheus 自己只负责存指标,不管告警。它的原生告警功能 Alertmanager 太重,而且:
- 无法细粒度控制告警生命周期
- 难以和业务系统集成
- 状态管理不够灵活
所以需要自己做一个轻量级的告警引擎,完全控制从"指标异常"到"告警事件"的全过程。
二、整体架构:五个核心组件
整个引擎分成五层,每层干一件事:
指标数据源] --> B[Scheduler
定时调度器] B --> C[Evaluator
规则评估器] C --> D[AlertQueue
事件队列] D --> E[Workers
消费处理] E --> F[Database
当前/历史告警] style A fill:#e1f5ff style C fill:#fff4e1 style D fill:#ffe1f5 style E fill:#e1ffe1 style F fill:#f5e1ff
各层职责:
| 组件 | 功能 | 为什么这样设计 |
|---|---|---|
| Prometheus | 存储所有业务指标 | 作为唯一的事实来源,告警引擎只读不写 |
| Scheduler | 每隔 N 秒触发一次评估 | 定时轮询,简单可靠 |
| Evaluator | 执行 PromQL 判断是否异常 | 纯计算逻辑,不做 I/O,保证评估速度 |
| AlertQueue | 缓冲告警事件 | 解耦评估和处理,削峰填谷 |
| Workers | 写库、发通知、去重 | 异步处理重活,不拖慢评估 |
核心设计思想:评估和处理分离
Evaluator 只管"算",不管"写"。所有状态变化都变成事件,丢给队列慢慢处理。这样:
- 评估永远很快,不会因为数据库慢而卡住
- 告警量暴增时,队列可以缓冲
- 后续要加通知、静默等功能,直接在 Worker 里加逻辑即可
三、告警规则:定义"什么算异常"
规则长什么样
每条规则包含三个核心字段:
go
type AlertRule struct {
ID uint64 // 规则ID
Name string // 规则名称,如"错误日志告警"
Expr string // PromQL 表达式
ForDuration uint // 持续时间(秒),如 60 表示持续1分钟
Enabled bool // 是否启用
}
示例规则:
yaml
规则1: 错误日志告警
Expr: increase(alarm_log_error_total[1m]) > 10
ForDuration: 60秒
规则2: SQL异常告警
Expr: alarm_sql_abnormal == 1
ForDuration: 0秒(立即告警)
Prometheus 怎么配合工作?
告警引擎不负责采集指标,它假设:
- 你的应用已经通过 Exporter 或 SDK 把指标暴露给 Prometheus
- Prometheus 已经在定时抓取这些指标
- 引擎只需要用 PromQL 去查询当前状态
数据流向:
这样分工的好处:
- Prometheus 专注采集和存储:它做自己最擅长的事
- 告警引擎专注判断和通知:不用管数据从哪来
- 两者松耦合:换个时序库也不影响告警逻辑
四、告警事件是怎么产生的?
核心概念:Fingerprint
一条告警对应的是"规则 + 特定标签组合"。比如:
text
规则: 错误日志告警
标签: {service="order", host="server-01"}
生成 Fingerprint: hash(rule_id + labels) = "abc123"
为什么需要 Fingerprint?
同一个规则可能命中多个实例:
{service="order", host="server-01"}在告警{service="order", host="server-02"}也在告警
每个实例是独立的告警,需要单独追踪。Fingerprint 就是"告警实例"的唯一标识。
评估流程:从指标到事件
每次评估做这几件事:
伪代码(思想版):
go
func evaluateRule(rule AlertRule) {
// 1. 查询 Prometheus,得到一批 time series
seriesList := queryPrometheus(rule.Expr)
currentFingerprints := []string{} // 本轮命中的实例
for _, series := range seriesList {
// 2. 生成唯一标识
fp := hash(rule.ID + series.Labels)
currentFingerprints.append(fp)
// 3. 加载或创建告警状态
alert := loadOrCreate(fp)
alert.TriggerCount++
alert.LastTriggerAt = now()
// 4. 判断是否达到 for_duration
elapsed := now() - alert.FirstTriggerAt
if elapsed >= rule.ForDuration && alert.Status == "pending" {
alert.Status = "firing" // 从观察期转为真正告警
}
// 5. 推入队列,交给 Worker 处理
queue.Push(alert)
}
// 6. 恢复检测:本轮没命中的,视为已恢复
for _, old := range getCurrentAlerts(rule.ID) {
if !currentFingerprints.contains(old.Fingerprint) {
moveToHistory(old) // 从 current 删除,写入 history
}
}
}
关键点:
- Evaluator 不直接写数据库,只生成事件
- 所有 I/O 操作都在 Worker 里异步完成
- 评估速度快,不会被拖慢
五、防抖动设计:如何避免误报?
问题:网络抖动、偶发错误不该告警
假设 API 偶尔超时一次,或者网络瞬间抖动,这种情况不该打扰人。需要一个机制来判断:这是真问题,还是毛刺?
解决方案:for_duration 观察期
告警分两个阶段:
观察中] B -->|持续 >= for_duration| C[firing
确认告警] C -->|不再命中| D[resolved
已恢复] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#ffcccc style D fill:#ccffcc
状态定义:
| 状态 | 含义 | 是否通知 |
|---|---|---|
| pending | 观察中,可能是毛刺 | 否 |
| firing | 确认告警,真出问题了 | 是 |
| resolved | 已恢复 | 是(恢复通知) |
实现细节:
go
type CurrentAlert struct {
Fingerprint string // 唯一标识
Status string // pending / firing
FirstTriggerAt time.Time // 第一次命中时间
LastTriggerAt time.Time // 最近一次命中时间
TriggerCount int // 总共命中几次
}
// 判断是否该从 pending 转 firing
elapsed := now() - alert.FirstTriggerAt
if elapsed >= rule.ForDuration && alert.Status == "pending" {
alert.Status = "firing"
}
举例:
text
规则: increase(error_log[1m]) > 10
ForDuration: 60秒
时间轴:
10:00:00 - 命中,创建 pending
10:00:15 - 命中,仍是 pending
10:00:30 - 命中,仍是 pending
10:01:00 - 命中,持续60秒 → 转为 firing,开始通知
10:01:15 - 命中,保持 firing
10:01:30 - 不再命中 → 转为 resolved,写入历史
这样就过滤掉了短暂的抖动,只有持续异常才会真正告警。
六、队列与消费:评估和处理的分离
为什么要队列?
如果 Evaluator 直接写数据库:
- 数据库慢了,评估就慢了
- 数据库挂了,评估就卡住了
- 告警量暴增时,数据库压力大
引入队列后:
快速计算] --> B[AlertQueue
缓冲区] B --> C[Worker #1
写库/通知] B --> D[Worker #2
写库/通知] B --> E[Worker #3
写库/通知] style A fill:#e1f5ff style B fill:#ffe1f5 style C fill:#e1ffe1 style D fill:#e1ffe1 style E fill:#e1ffe1
队列做了什么:
- 评估产生的告警事件先放到内存队列
- 多个 Worker 从队列消费,各自独立处理
- 队列满了也不会影响评估,最多丢弃部分事件
队列实现(简化版):
go
type AlertQueue struct {
ch chan *CurrentAlert // Go 的 channel 天然支持队列
}
func (q *AlertQueue) Push(alert *CurrentAlert) {
select {
case q.ch <- alert:
// 推入成功
default:
// 队列满了,丢弃或记录日志
}
}
func (q *AlertQueue) Pop() <-chan *CurrentAlert {
return q.ch // Worker 直接从 channel 读取
}
Worker 做什么?
Worker 是消费者,负责所有"重活":
go
type Worker struct {
queue *AlertQueue
db *Database
dedup *DedupCache // 5分钟去重缓存
}
func (w *Worker) Start() {
for alert := range w.queue.Pop() {
// 1. 检查是否在去重窗口内
if w.dedup.Contains(alert.Fingerprint) {
continue // 5分钟内重复的,直接跳过
}
// 2. 写入或更新 current_alerts 表
w.db.UpsertCurrentAlert(alert)
// 3. 如果是 firing,可以在这里发通知
if alert.Status == "firing" {
sendNotification(alert)
}
// 4. 加入去重缓存,5分钟内不再处理
w.dedup.Add(alert.Fingerprint)
}
}
为什么这样设计好?
| 好处 | 说明 |
|---|---|
| 解耦 | 评估和处理互不影响 |
| 削峰 | 告警量暴增时队列缓冲 |
| 可扩展 | Worker 数量可以动态调整 |
| 容错 | 某个 Worker 挂了不影响其他 Worker |
七、5分钟去重:避免重复处理
问题:高频指标会疯狂触发
假设某个指标每 15 秒评估一次,一旦进入 firing 状态,每次评估都会命中。这意味着:
- 每 15 秒就会推一次队列
- 每 15 秒就会写一次数据库
- 每 15 秒就会发一次通知
这完全没必要,浪费资源还烦人。
解决方案:时间窗口去重
在 Worker 层加一个简单的缓存:
go
type DedupCache struct {
m map[string]time.Time // fingerprint -> 过期时间
mu sync.RWMutex
ttl time.Duration // 5分钟
}
func (c *DedupCache) Contains(fp string) bool {
c.mu.RLock()
expireAt, exists := c.m[fp]
c.mu.RUnlock()
if !exists {
return false
}
return time.Now().Before(expireAt) // 还没过期
}
func (c *DedupCache) Add(fp string) {
c.mu.Lock()
c.m[fp] = time.Now().Add(c.ttl) // 当前时间 + 5分钟
c.mu.Unlock()
}
工作流程:
text
10:00:00 - alert_abc 首次进入 Worker
检查缓存:不存在
处理:写库、发通知
加入缓存:过期时间 = 10:05:00
10:00:15 - alert_abc 再次进入 Worker
检查缓存:存在,且未过期
跳过处理
10:00:30 - alert_abc 再次进入 Worker
检查缓存:存在,且未过期
跳过处理
... (中间所有重复都被跳过)
10:05:01 - alert_abc 再次进入 Worker
检查缓存:已过期
处理:写库、更新时间
刷新缓存:过期时间 = 10:10:01
两层防抖的配合:
| 机制 | 作用阶段 | 解决的问题 |
|---|---|---|
| for_duration | 评估阶段 | 防止毛刺,pending → firing 需要持续时间 |
| 5分钟去重 | 消费阶段 | 防止重复,firing 状态下避免频繁写库/通知 |
八、当前告警 vs 历史告警:状态迁移
两张表的职责
当前告警表] B --> C{下轮还命中?} C -->|是| B C -->|否| D[history_alerts
历史告警表] style B fill:#ffcccc style D fill:#cccccc
| 表名 | 存什么 | 什么时候写入 |
|---|---|---|
| current_alerts | 正在发生的告警 | 规则命中时创建/更新 |
| history_alerts | 已结束的告警 | 不再命中时从 current 迁移过来 |
current_alerts 的记录:
go
type CurrentAlert struct {
Fingerprint string // 唯一标识
RuleID uint64 // 属于哪条规则
Status string // pending / firing
FirstTriggerAt time.Time // 首次触发时间
LastTriggerAt time.Time // 最近触发时间
TriggerCount int // 触发次数
Labels map[string]string // 标签
Value float64 // 当前值
}
history_alerts 的记录:
go
type HistoryAlert struct {
Fingerprint string
RuleID uint64
StartAt time.Time // 开始时间(= FirstTriggerAt)
EndAt time.Time // 结束时间(= 最后评估时间)
Duration int // 持续时长(秒)
TriggerCount int // 总共触发几次
Labels map[string]string
}
状态迁移:如何判断"已恢复"?
每次评估后,引擎会做恢复检测:
go
func evaluateRule(rule AlertRule) {
// ... 前面的评估逻辑
// 收集本轮命中的所有 fingerprint
currentFPs := []string{}
for _, series := range seriesList {
fp := hash(rule.ID + series.Labels)
currentFPs.append(fp)
// ... 处理这条告警
}
// 遍历该规则下所有当前告警
for _, existing := range db.GetCurrentAlerts(rule.ID) {
// 如果某个 fingerprint 在本轮没出现,说明已恢复
if !currentFPs.contains(existing.Fingerprint) {
// 1. 写入 history_alerts
db.InsertHistory(HistoryAlert{
Fingerprint: existing.Fingerprint,
RuleID: existing.RuleID,
StartAt: existing.FirstTriggerAt,
EndAt: now(),
Duration: now() - existing.FirstTriggerAt,
TriggerCount: existing.TriggerCount,
Labels: existing.Labels,
})
// 2. 从 current_alerts 删除
db.DeleteCurrent(existing.Fingerprint)
}
}
}
核心思想:缺席即恢复
不需要额外判断"恢复条件",只要本轮评估中某个告警实例没出现,就认为已经恢复了。这种设计:
- 简单直接,不需要额外状态机
- 自动处理恢复,不需要定时扫描
- 和评估逻辑天然融合
举例说明:
text
时间线:
10:00 - 规则命中 {host="server-01"}
创建 current_alerts 记录
10:01 - 规则命中 {host="server-01"}
更新 current_alerts(TriggerCount++)
10:02 - 规则命中 {host="server-01"}
更新 current_alerts
10:03 - 规则不再命中(指标恢复正常)
本轮 fingerprints = [](空)
发现 {host="server-01"} 缺席
→ 写入 history_alerts
→ 删除 current_alerts
九、整体流程总结
把所有环节串起来:
各阶段职责:
- Scheduler:按固定间隔触发评估
- Evaluator:查询 Prometheus,计算状态变化
- Fingerprint:标识每个告警实例
- pending/firing:区分观察期和确认告警
- AlertQueue:缓冲事件,削峰填谷
- Worker:异步处理,写库、去重、通知
- current/history:区分当前问题和历史记录
十、这个设计好在哪里?
1. 职责清晰,分层明确
| 层级 | 只做一件事 | 好处 |
|---|---|---|
| Prometheus | 存指标 | 专业的事交给专业的工具 |
| Evaluator | 纯计算 | 评估速度快,不被 I/O 拖累 |
| Queue | 缓冲 | 削峰填谷,容错能力强 |
| Worker | 重活 | 异步处理,可以慢慢来 |
| Database | 持久化 | 状态可追溯,支持查询 |
2. 防抖动做得优雅
两层防抖配合:
- for_duration:防毛刺,观察期后才确认
- 5分钟去重:防重复,避免疯狂写库
既不误报,也不扰民。
3. 状态迁移自然
不需要额外的"恢复检测"逻辑:
- 命中 → 在 current 里
- 不命中 → 自动迁移到 history
"缺席即恢复"是最简单的设计。
4. 易扩展
想加新功能,只需要:
- 加规则:在规则表里插一条
- 加通知:在 Worker 里加逻辑
- 加静默:在 Worker 里加判断
- 加多数据源:扩展 Evaluator 支持其他查询接口
核心架构不用动。
5. 性能好
- 评估是纯内存计算,每秒可以处理成百上千条规则
- 队列异步处理,数据库慢了也不影响评估
- Worker 可以水平扩展,告警量大了加机器就行
十一、适用场景与局限
适合什么场景?
- 中小规模监控:几百到几千条规则
- 对接 Prometheus:已有 Prometheus 作为时序库
- 需要精细控制:想自己管理告警生命周期
- 要和业务系统集成:比如推送到自己的工单系统
不适合什么场景?
- 超大规模 :上万条规则,几十万个告警实例
- 需要做分布式评估,单机扛不住
- 多数据源 :不只是 Prometheus,还有 InfluxDB、MySQL 等
- 需要扩展 Evaluator 支持多种查询接口
- 复杂告警逻辑 :比如多指标关联、机器学习预测
- 需要更强大的规则引擎
但这个内核设计很灵活,扩展起来不难。
十二、总结
这个告警引擎虽然小,但该有的都有:
核心功能:
- 从 Prometheus 指标到告警事件的完整流程
- 防抖动设计(for_duration + 5分钟去重)
- 告警生命周期管理(pending → firing → resolved)
- 当前/历史状态分离
设计亮点:
- 评估和处理分离,队列解耦
- 状态迁移简单,缺席即恢复
- 易扩展,加功能不需要改架构
实现简洁:
- 没有复杂的状态机
- 没有臃肿的抽象
- 代码量小,易维护
这就是一个优雅的告警引擎内核该有的样子:简单、清晰、好用。