夜莺告警引擎内核:一个优雅的设计

夜莺告警引擎内核:一个优雅的设计

这是一个从 Prometheus 指标到告警事件的完整引擎。它做的事情很简单:把 Prometheus 里的指标曲线,变成可追踪、可管理的告警事件


一、这个引擎解决什么问题?

它做的事

假设你的系统在 Prometheus 里有这些指标:

  • alarm_log_error_total:错误日志数量
  • alarm_sql_slow_count:慢 SQL 数量
  • alarm_api_timeout_total:API 超时数量

这个引擎的工作就是:

  1. 定时去 Prometheus 查询这些指标
  2. 判断是否异常(比如错误数 > 10)
  3. 确认是真问题还是毛刺(持续一段时间才算)
  4. 生成告警事件,记录下来,必要时发通知
  5. 追踪告警状态:知道哪些问题还在,哪些已经恢复

为什么需要这个引擎?

Prometheus 自己只负责存指标,不管告警。它的原生告警功能 Alertmanager 太重,而且:

  • 无法细粒度控制告警生命周期
  • 难以和业务系统集成
  • 状态管理不够灵活

所以需要自己做一个轻量级的告警引擎,完全控制从"指标异常"到"告警事件"的全过程。


二、整体架构:五个核心组件

整个引擎分成五层,每层干一件事:

flowchart LR A[Prometheus
指标数据源] --> 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 怎么配合工作?

告警引擎不负责采集指标,它假设:

  1. 你的应用已经通过 Exporter 或 SDK 把指标暴露给 Prometheus
  2. Prometheus 已经在定时抓取这些指标
  3. 引擎只需要用 PromQL 去查询当前状态

数据流向:

flowchart LR A[应用/中间件] -->|暴露指标| B[Prometheus Exporter] B -->|scrape| C[Prometheus TSDB] C -->|PromQL查询| D[告警引擎 Evaluator] style A fill:#e1f5ff style C fill:#fff4e1 style D fill:#ffe1f5

这样分工的好处:

  • 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 就是"告警实例"的唯一标识。

评估流程:从指标到事件

每次评估做这几件事:

flowchart LR A[1.执行 PromQL] --> B[2.生成 Fingerprint] B --> C[3.查询当前状态] C --> D{4.是否首次命中?} D -->|是| E[创建 pending] D -->|否| F[更新计数和时间] E --> G[5.判断持续时长] F --> G G --> H[6.推入队列] style A fill:#e1f5ff style C fill:#fff4e1 style G fill:#ffe1f5 style H fill:#e1ffe1

伪代码(思想版):

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 观察期

告警分两个阶段:

flowchart LR A[首次命中] --> B[pending
观察中] 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 直接写数据库:

  • 数据库慢了,评估就慢了
  • 数据库挂了,评估就卡住了
  • 告警量暴增时,数据库压力大

引入队列后:

flowchart LR A[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

队列做了什么:

  1. 评估产生的告警事件先放到内存队列
  2. 多个 Worker 从队列消费,各自独立处理
  3. 队列满了也不会影响评估,最多丢弃部分事件

队列实现(简化版):

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 历史告警:状态迁移

两张表的职责

flowchart LR A[规则命中] --> B[current_alerts
当前告警表] 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

九、整体流程总结

把所有环节串起来:

flowchart TD A[Scheduler: 每15秒触发] --> B[Evaluator: 执行 PromQL] B --> C{规则是否命中?} C -->|否| D[本轮无告警] C -->|是| E[生成 Fingerprint] E --> F{是否首次命中?} F -->|是| G[创建 pending] F -->|否| H[更新计数/时间] G --> I{持续 >= for_duration?} H --> I I -->|否| J[保持 pending] I -->|是| K[转为 firing] J --> L[推入 AlertQueue] K --> L L --> M[Worker: 从队列消费] M --> N{5分钟内重复?} N -->|是| O[跳过] N -->|否| P[写入 current_alerts] P --> Q{下轮还命中?} Q -->|是| A Q -->|否| R[迁移到 history_alerts] style B fill:#e1f5ff style E fill:#fff4e1 style K fill:#ffcccc style M fill:#e1ffe1 style R fill:#cccccc

各阶段职责:

  1. Scheduler:按固定间隔触发评估
  2. Evaluator:查询 Prometheus,计算状态变化
  3. Fingerprint:标识每个告警实例
  4. pending/firing:区分观察期和确认告警
  5. AlertQueue:缓冲事件,削峰填谷
  6. Worker:异步处理,写库、去重、通知
  7. current/history:区分当前问题和历史记录

十、这个设计好在哪里?

1. 职责清晰,分层明确

层级 只做一件事 好处
Prometheus 存指标 专业的事交给专业的工具
Evaluator 纯计算 评估速度快,不被 I/O 拖累
Queue 缓冲 削峰填谷,容错能力强
Worker 重活 异步处理,可以慢慢来
Database 持久化 状态可追溯,支持查询

2. 防抖动做得优雅

两层防抖配合:

  • for_duration:防毛刺,观察期后才确认
  • 5分钟去重:防重复,避免疯狂写库

既不误报,也不扰民。

3. 状态迁移自然

不需要额外的"恢复检测"逻辑:

  • 命中 → 在 current 里
  • 不命中 → 自动迁移到 history

"缺席即恢复"是最简单的设计。

4. 易扩展

想加新功能,只需要:

  • 加规则:在规则表里插一条
  • 加通知:在 Worker 里加逻辑
  • 加静默:在 Worker 里加判断
  • 加多数据源:扩展 Evaluator 支持其他查询接口

核心架构不用动。

5. 性能好

  • 评估是纯内存计算,每秒可以处理成百上千条规则
  • 队列异步处理,数据库慢了也不影响评估
  • Worker 可以水平扩展,告警量大了加机器就行

十一、适用场景与局限

适合什么场景?

  1. 中小规模监控:几百到几千条规则
  2. 对接 Prometheus:已有 Prometheus 作为时序库
  3. 需要精细控制:想自己管理告警生命周期
  4. 要和业务系统集成:比如推送到自己的工单系统

不适合什么场景?

  1. 超大规模 :上万条规则,几十万个告警实例
    • 需要做分布式评估,单机扛不住
  2. 多数据源 :不只是 Prometheus,还有 InfluxDB、MySQL 等
    • 需要扩展 Evaluator 支持多种查询接口
  3. 复杂告警逻辑 :比如多指标关联、机器学习预测
    • 需要更强大的规则引擎

但这个内核设计很灵活,扩展起来不难。


十二、总结

这个告警引擎虽然小,但该有的都有:

核心功能:

  • 从 Prometheus 指标到告警事件的完整流程
  • 防抖动设计(for_duration + 5分钟去重)
  • 告警生命周期管理(pending → firing → resolved)
  • 当前/历史状态分离

设计亮点:

  • 评估和处理分离,队列解耦
  • 状态迁移简单,缺席即恢复
  • 易扩展,加功能不需要改架构

实现简洁:

  • 没有复杂的状态机
  • 没有臃肿的抽象
  • 代码量小,易维护

这就是一个优雅的告警引擎内核该有的样子:简单、清晰、好用

相关推荐
Victor35625 分钟前
MongoDB(52)如何配置分片?
后端
Victor35626 分钟前
MongoDB(53)什么是分片键?
后端
Leinwin7 小时前
OpenClaw 多 Agent 协作框架的并发限制与企业化规避方案痛点直击
java·运维·数据库
2401_865382507 小时前
信息化项目运维与运营的区别
运维·运营·信息化项目·政务信息化
漠北的哈士奇7 小时前
VMware Workstation导入ova文件时出现闪退但是没有报错信息
运维·vmware·虚拟机·闪退·ova
薛定谔的悦7 小时前
MQTT通信协议业务层实现的完整开发流程
java·后端·mqtt·struts
如意.7597 小时前
【Linux开发工具实战】Git、GDB与CGDB从入门到精通
linux·运维·git
enjoy嚣士7 小时前
springboot之Exel工具类
java·spring boot·后端·easyexcel·excel工具类
运维小欣8 小时前
智能体选型实战指南
运维·人工智能
yy55278 小时前
Nginx 性能优化与监控
运维·nginx·性能优化