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

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

这是一个从 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)
  • 当前/历史状态分离

设计亮点:

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

实现简洁:

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

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

相关推荐
小小荧2 小时前
Hono与Honox一次尝试
前端·后端
爱吃生蚝的于勒2 小时前
【Linux】深入理解软硬链接
linux·运维·服务器·c语言·数据结构·c++·算法
a努力。2 小时前
京东Java面试:如何设计一个分布式ID生成器
java·分布式·后端·面试
程序终结者2 小时前
CDH6.3.2集群docker容器化离线部署客户端parcel+配置全流程详解
运维·docker·容器
superman超哥2 小时前
Rust 复合类型:元组与数组的内存布局与性能优化
开发语言·后端·性能优化·rust·内存布局·rust复合类型·元组与数组
Chasing__Dreams2 小时前
Go--2--垃圾回收
go
全栈工程师修炼指南2 小时前
Nginx | HTTP 反向代理:当缓存失效时如何减轻后端(上游)服务压力?
运维·网络协议·nginx·http·缓存
prettyxian2 小时前
【Linux】内核编织术:task_struct的动态网络
linux·运维·服务器
Danileaf_Guo2 小时前
OSPF路由引入的陷阱:为何Ubuntu上静态路由神秘消失?深挖FRR路由分类机制
linux·运维·网络·ubuntu·智能路由器