07-告警引擎与状态机

告警引擎与状态机

状态机 v5:触发秒反应、恢复要观察;12 种告警类型、三级阈值、RMW 锁内决策锁外落库;离线巡检走 DB 不走缓存

0. 一句话

告警模块的核心是一个状态机(v5 版本):指标超阈值立刻触发告警,恢复却要连续观察 N 轮正常(默认 6 轮)才 resolve------"抖动"和"真恢复"的区别,只有时间能证明。状态全部放内存(alertCache),DB 只做落盘;决策在锁内完成,DB 副作用在锁外执行;12 种告警类型、三级阈值,双 Master 靠 Nginx 哈希粘性天然隔离。全部逻辑落在 server-master/internal/master/alert/ 的 engine.go 与 offline.go 两个文件里。

1. 问题

告警系统要同时满足两个相反的要求:

  1. 触发必须快:机器 CPU 烧到 95%,不能等两分钟才报
  2. 恢复必须稳:监控数据本身会抖,CPU 95%→94%→95% 的抖动如果每次都触发-恢复-再触发,告警就变成狼来了,运维迟早屏蔽告警中心

还有三个工程问题要一起解决:

  • 状态放哪:Agent 每 10s 上报,每次上报都查 DB 判断"有没有这条告警"存储层扛不住;但状态只放内存,服务重启就丢
  • 并发谁负责:双 Master 都可能看到同一台机器的指标,同一告警不能触发两次
  • 历史要可追溯:什么时候触发、什么时候恢复、恢复后再次触发,时间线要完整

2. 实现

2.1 状态机 v5:触发秒反应,恢复要观察

engine.go 注释把核心原则写得很直白:

触发秒反应(立刻告警),恢复要观察(连续 N 轮正常才 resolve)。 每轮(10s)上报/巡检:正常→CoolDown++,异常→CoolDown 归零。 CoolDown 达到配置的 AlertCoolDownRounds(默认 6 = 60s)时恢复。 级别升降实时处理,不受 CoolDown 影响;级别变更时 CoolDown 归零。

整个状态机可以浓缩成一张状态转移表(代码注释里的状态图 + checkSingleMetric 的实际分支):

当前状态 本轮指标 判定 动作 CoolDown
正常(无缓存) 超阈值 首次触发 triggerAlert 置 0
告警(有缓存) 超阈值,新级别 > 当前 升级 escalateAlert 归零
告警(有缓存) 超阈值,新级别 < 当前 降级 downgradeAlert 归零
告警(有缓存) 超阈值,级别不变 持续异常 仅更新 DB 归零(重新观察)
告警(有缓存) 正常 CoolDown++ UpdateCoolDown 递增
告警(有缓存) 正常且 CoolDown >= 6 稳定恢复 resolveAlert 删除缓存

两个关键的不对称:

  • 触发无观察期:只要超阈值、缓存里没有,立刻告警。误报一次可以接受,漏报不可接受
  • 恢复有观察期:连续 6 轮正常才算恢复。观察期间任何一轮异常,CoolDown 归零重来------抖动一次不算恢复

2.2 内存缓存:三元组键 + 冷启动全量加载

告警状态在内存里的形态:

go 复制代码
type alertFlagKey struct {   // 缓存键
    DeviceUUID string        // 设备指纹
    AlertType  string        // 告警类型(cpu_usage / mem_usage / ...)
    Tag        string        // 告警标签(CPU告警 / 磁盘告警/C: / ...)
}
type alertCacheEntry struct { // 缓存值
    ID       uint64          // 告警记录 ID(操作 DB 时用)
    Level    int             // 当前级别(1 一般 / 2 严重 / 3 紧急)
    CoolDown int             // 连续正常轮次计数
}

键是三元组,注释说明"与 DB 去重逻辑一致"。值里记着告警 ID------恢复、升级时要用它操作 DB 里的那行。

冷启动是全量加载而不是空缓存:NewEngineFindAllActiveAlerts() 把 DB 里所有未恢复告警读进内存缓存,加载失败直接 panic。注释原话:"缓存为空启动会导致所有上报被当作'首次超阈值'重复 INSERT,宁可 panic 让进程重启"。这个 panic 是刻意的:缓存是状态机的决策依据,带病启动会批量产生假告警,比重启代价大得多。

2.3 三级阈值:warn / high / critical

calcAlertLevel 的判定逻辑只有三行,但边界语义值得说清楚:

go 复制代码
func calcAlertLevel(value, warn, high, critical float64) (int, string) {
    _ = warn // 调用方保证 value >= warn,此处仅不参与比较
    if critical > 0 && value >= critical {
        return cm.AlertLevelCritical, cm.AlertLevelText[cm.AlertLevelCritical]
    }
    if high > 0 && value >= high {
        return cm.AlertLevelHigh, cm.AlertLevelText[cm.AlertLevelHigh]
    }
    return cm.AlertLevelWarn, cm.AlertLevelText[cm.AlertLevelWarn]
}

三个设计点:

  • warn 不参与比较 :调用方(checkSingleMetric)先判断 value >= warn 才进来,函数内无需再比
  • > 0 判断 = 阈值 0 是开关:critical=0 时永远不会命中紧急,退到严重;high=0 时直接落一般。想只开两级告警,把某一级配成 0 就行,不用改代码
  • 级别常量放在 common-modelcm.AlertLevelCritical 等由 Master / API / 前端共用同一套定义,不会出现"Master 的 3 和前端显示的 2"这种错位

2.4 checkSingleMetric:RMW 锁内决策,DB 副作用锁外

checkSingleMetric 是所有指标类告警的入口(8 种指标类型共用)。它的核心模式是 RMW(read-modify-write):持有写锁完成"读-决策",解锁后执行 DB 副作用。

go 复制代码
// 原子性 RMW:持有写锁完成"读-决策"周期
e.alertCacheMu.Lock()
entry := e.alertCache[key]
var cached *alertCacheEntry
if entry != nil {
    cached = &alertCacheEntry{ID: entry.ID, Level: entry.Level, CoolDown: entry.CoolDown}
}
// ... 决策变量填充(needTrigger / needEscalate / needDowngrade / needResolve / ...)
e.alertCacheMu.Unlock()

// ── 副作用(DB 操作在锁外执行)──

两个细节值得展开:

锁内复制 entry :锁内拿到的是 entry 指针,先复制一份 cached 供决策使用。因为锁只保护"读-决策"这段,锁外还要用决策结果里的 ID/Level 做 DB 操作------如果直接持有原指针,解锁后它可能被并发修改。复制副本让决策结果与后续副作用解耦。

DB 操作为什么放锁外:锁保护的是内存缓存的读-改-写原子性,DB 写是慢操作(网络+磁盘往返)。如果锁内执行,8004 台 Agent 的上报会被串行化,锁竞争直接拖垮整个 Master。决策在锁内、执行在锁外,每个决策结果对应一次确定的 DB 动作,正确性不受影响。

2.5 四个动作:trigger / escalate / downgrade / resolve

triggerAlert(首次触发) :构造 AlertContent 结构体 → JSON 序列化进 Content 字段 → CreateAlert 写库 → GetAlertByKey 拿 ID → 写缓存。有个 forceLevel 参数:0 表示按 value 和三级阈值自动计算级别,>0 表示强制指定(离线告警用 Critical)。超出百分比 overPercent = (value - threshold) / threshold * 100,相对一般阈值计算。

escalateAlert / downgradeAlert(升级/降级)EscalateAlertLevel UPDATE DB 的 level + level_text + content,缓存里的 Level 同步更新,CoolDown 归零。注意降级也走 EscalateAlertLevel 这个 DAO 方法------名字叫 Escalate 但逻辑是通用的级别更新。

resolveAlert(恢复) :这里有个重要的双重校验。恢复流程是先 UPDATE DB status=2,再删缓存:

go 复制代码
// 恢复 DB 失败时,只有 key 已被并发删除才回写旧 entry
if err := e.alertDao.ResolveAlert(entry.ID); err != nil {
    ...
    if _, exists := e.alertCache[key]; !exists {
        e.alertCache[key] = entry
    }
    return
}
// 恢复成功后从缓存删除,但仅当缓存中仍是本条告警时才删
e.alertCacheMu.Lock()
if cached, ok := e.alertCache[key]; ok && cached.ID == entry.ID {
    delete(e.alertCache, key)
}

第一重:恢复 DB 期间,同 key 可能被并发 triggerAlert 写入新告警(状态机里触发和恢复在时间上可以交错)。删缓存前校验 cached.ID == entry.ID------缓存里的 ID 已经不是本条告警了,说明有新的告警诞生,不能误删。第二重:恢复 DB 失败时,只有 key 已被并发删除才回写旧 entry,保持缓存状态不丢。

2.6 12 种告警类型

server-master/model/alert.go 定义了 12 种类型常量。类型用英文小写字符串而非数字,注释原话:"看日志里写着 cpu_usage 比看一个数字 1 容易理解一万倍",SELECT * FROM alert WHERE alert_type='cpu_usage' 不用翻代码查映射。

类型 含义 触发链路 Tag
cpu_usage CPU 使用率 指标检查 CPU告警
cpu_load CPU 15 分钟负载 指标检查 CPU告警
cpu_iowait CPU IO 等待 指标检查 CPU告警
mem_usage 内存使用率 指标检查 内存告警
disk_usage 磁盘分区使用率 指标检查(按挂载点分 tag) 磁盘告警/C:
net_bandwidth 网卡带宽 指标检查(按网卡分 tag) 网卡告警/eth0
net_error 网卡错误包 指标检查 网卡告警/eth0
diskio_await 磁盘 IO 延迟 指标检查 磁盘IO告警/sda
agent_offline Agent 离线 离线巡检协程 主机告警
task_check 任务检测失败 任务模块 任务告警/任务ID
tcp_time_wait TIME_WAIT 连接数 已定义,未接线 TCP连接告警
tcp_close_wait CLOSE_WAIT 连接泄漏 已定义,未接线 TCP连接告警

几个细节:

  • 类型和标签分离:类型是给程序用的(英文下划线),标签是给人看的(中文),前端拿到告警列表直接用 Tag 字段展示,不用做映射翻译
  • 磁盘/网卡/IO 的 tag 动态拼"磁盘告警/" + mount_point"网卡告警/" + iface_name,所以这三个类型不在 AlertTypeTag 映射表里(映射表只放固定 tag 的)
  • tcp_established 有内容生成无类型定义buildAlertContentText 里 case 了 tcp_established,测试也覆盖了,但常量里没有 AlertTypeTcpEstablished------TCP 告警的内容生成先行,类型定义滞后
  • Content 存 JSON:不同告警类型需要不同信息(磁盘要 mount_point、网卡要 iface_name、离线要 offline_seconds),JSON 灵活,加新告警类型不用 ALTER TABLE

2.7 CheckResourceAlerts:8 个检查块,四重开关

CheckResourceAlerts 是资源告警总入口,每次 Agent 上报调用一次。第一件事是刷新设备最后在线时间(UpsertLastOnline,记录不存在自动重建)------这一步是离线判定的数据来源,然后依次执行检查:

  1. CPU 使用率(EnableCpuCollect && EnableCpuAlert && CpuWarn > 0 && Usage > 0
  2. CPU 负载(EnableCpuCollect && EnableCpuLoadAlert && CpuLoadWarn > 0 && Load15 > 0
  3. CPU IO 等待(EnableCpuCollect && EnableCpuIowaitAlert && CpuIowaitWarn > 0 && Iowait > 0
  4. 内存(EnableMemCollect && EnableMemAlert && MemWarn > 0 && UsedPercent > 0
  5. 磁盘分区(EnableDiskCollect && EnableDiskAlert && DiskWarn > 0
  6. 网卡带宽(EnableNetCollect && EnableNetAlert && NetBwWarn > 0
  7. 网卡错误包(EnableNetCollect && EnableNetErrorAlert && NetErrWarn > 0
  8. 磁盘 IO 延迟(EnableDiskioCollect && EnableDiskioAlert && DiskioWarn > 0

每个检查块都是四重开关:采集开关 && 告警开关 && 阈值 > 0 && 指标值 > 0。四层全关就是纯采集不上告警。阈值 > 0 这层和 2.3 的边界语义呼应------阈值配 0 等于关闭该告警。

网卡带宽的换算 值得单独说:bwMbps := float64(nic.BytesRecv+nic.BytesSent) * 8 / 1048576------采集器给的是字节数(自上次采样以来的增量),换算成 Mbps 要 ×8(位)再 ÷1048576(1M = 1024²)。这个换算在阈值配置时用 Mbps 做单位,两边必须一致,否则告警阈值差 8 倍。

磁盘白名单清理 :磁盘告警按 DiskAllowMount 白名单过滤挂载点。用户把某分区移出采集范围后,白名单外的旧磁盘告警不会永远挂着------每次检查把"该设备 + 磁盘告警 + 不在 allowedTags 里"的缓存项 resolve 掉,日志打"【告警恢复-过滤】"。

2.8 离线告警:独立巡检协程,查 DB 不查缓存

指标告警靠上报触发,机器挂了没人上报,所以离线检测是独立协程(offline.go):

go 复制代码
offlineThreshold := cfg.AgentOfflineSeconds   // 默认 120s
if offlineThreshold <= 0 { offlineThreshold = 120 }
checkInterval := cfg.OfflineCheckInterval     // 默认 30s
if checkInterval <= 0 { checkInterval = 30 }
ticker := time.NewTicker(time.Duration(checkInterval) * time.Second)

流程:每 30s 扫一遍 agent_infoListAllOnlineForOfflineCheck),now - dev.LastOnlineAt > offlineThreshold(120s)判离线。

离线分支

  • FindActiveAlert 查 DB(不是查缓存)→ 无 → triggerAlertforceLevel=Critical 强制紧急级别------离线是最严重的事件,不给三级阈值留余地
  • 已有活跃告警 → 缓存和 DB 的 CoolDown 都归零(持续离线 = 持续异常)

在线分支(设备回来了):

  • FindActiveAlert 查 DB → 有活跃告警 → 缓存同步并 CoolDown+1 → 满 6 轮 resolve;缓存没有则从 DB 的 CoolDown 基础上 +1 重建缓存(兼容缓存丢失场景)
  • DB 查询失败但设备在线 → 走 ResolveAllOfflineAlertsByDevice 批量恢复兜底(无告警时是 no-op)+ 清缓存

为什么离线巡检查 DB 不查缓存 :指标检查走内存缓存是因为每 10s 高频、缓存是权威;离线巡检 30s 一次、低频,且设备列表本身来自 DB(agent_info 表),以 DB 为准能兜底缓存与 DB 的不一致------比如 Master 重启后缓存重建失败、或双 Master 的缓存不同步。低频路径查 DB,代价可接受,换来的是双 Master 视角一致。

协程生命周期 :启动时立即执行一次巡检(不等第一个 tick,避免"刚启动的 30s 内离线设备不告警");每轮巡检重新读热配置(config.GetRuntime(),改阈值不用重启);双层 defer recover(协程级 + 单次巡检级),单次巡检 panic 不能带崩整个 Master;closeCh 优雅退出。

2.9 僵尸告警回收

task_check 告警有特殊性(recoverStaleTaskAlerts):任务被用户关闭(enabled=0)或 Agent 执行完就挂了,任务不再上报,告警永远挂起没人恢复。离线巡检时顺带扫活跃告警:

  • 任务已删除(GetTaskByID 返回 nil)→ 恢复告警
  • 任务已关闭(task.Enabled != 1)→ 恢复告警
  • 查任务 DB 失败 → 本轮跳过。注释原话:"不能因为查不到就误判为任务已删除,否则 DB 故障期间告警会反复恢复-重新出现"------DB 异常和"任务真删了"必须区分,否则 DB 抖动一次,一堆告警先被恢复、恢复后条件仍在又重建,来回横跳

Agent 离线场景由 checkOfflineDevicesResolveAllOfflineAlertsByDevice 覆盖,僵尸回收不重复处理。

任务告警的 tag 用任务 ID(任务告警/%d)而非任务名(server-master/internal/master/task/service.go),历史教训写死在注释里:"任务改名后按新名恢复匹配不到旧 tag,告警永远挂起"。恢复时按 task_id 批量恢复(RecoverAlertByTaskID),顺带覆盖存量按旧任务名拼 tag 的历史数据------用关联 ID 而不是展示用字符串,匹配关系才稳定。

3. 取舍

  • 触发快、恢复慢,不对称是故意的:告警宁可误报一次,不可漏报一次;但恢复噪音会毁掉告警可信度。60s 观察期是成本和体验的折中
  • 缓存为主、DB 为从:决策永远看内存缓存(高频),DB 只是落盘和跨重启的持久化。代价是缓存和 DB 可能短暂不一致(比如 DB 写失败但缓存已改),靠下次决策和启动全量加载自愈
  • 指标走缓存、离线走 DB:高频路径用缓存保性能,低频路径查 DB 保一致性。两条路径的判断依据不同,但都正确
  • Content 存 JSON 而非独立字段 :各类告警字段不统一(挂载点/网卡名/离线秒数),JSON 灵活免 ALTER TABLE,代价是前端要自己解析(formatSummary
  • 类型字符串 vs 数字:存储略大,换日志和排查的自解释性

4. 验证

状态机的正确性主要靠 engine_test.go 的集成测试兜底------alertOps 接口 + memoryAlertStore 内存 mock,DAO 与引擎解耦,测试不依赖真实 DB。测试用例覆盖了状态机的每条路径:

测试 验证点
TestCalcAlertLevel_Warn/High/Critical/CriticalZero/HighZero 三级阈值判定 + 阈值 0 = 关闭该级别的边界
TestCheckSingleMetric_FirstTrigger 首次超阈值 → INSERT 一条 + 写缓存
TestCheckSingleMetric_NoAlert 低于阈值不触发
TestCheckSingleMetric_WarnZero warn=0 直接跳过检查
TestCheckSingleMetric_DuplicateSkip 持续超阈值、级别不变 → 不重复 INSERT
TestCheckSingleMetric_Escalate 级别上升 → UPDATE 升级,不新建记录
TestCheckSingleMetric_CoolDownRecover 1~5 轮正常不恢复,第 6 轮才 resolve
TestCheckSingleMetric_CoolDownReset 2 轮正常后突然异常 → CoolDown 归零
TestCheckSingleMetric_RecoverThenRetrigger 恢复后再次超阈值 → 重新触发
TestCheckSingleMetric_Concurrent 20 goroutine 并发写不 panic
TestBuildAlertContentText_* 各类告警内容文本生成

最关键的测试是 CoolDownRecover:5 轮正常缓存还在、DB 还是活跃,第 6 轮才 resolve------防抖语义被测试钉死了。RecoverThenRetrigger 验证恢复和触发能交错(对应 resolveAlert 的双重 ID 校验场景)。Concurrent 用 20 个 goroutine 打同一 key,验证 RMW 锁不 panic。

冷启动一致性(缓存加载失败 panic)和离线巡检协程的 recover 兜底是运行时保证,没有单测------这部分靠代码注释写清意图,属于"不可测试就只能靠设计"的范畴。

5. 回看

级别和恢复是两套节奏,这是 v5 才想明白的。早期版本把"恢复"也做成即时判断,抖动告警刷屏。改成 CoolDown 后,级别升降依然实时(紧急就是紧急,不等观察),只有解除才观察------快用在触发,稳用在恢复,各拿各的好处。回头看,这个不对称是整个状态机设计的灵魂,如果一开始就写清楚"触发和恢复的不对称性",能少走一版弯路。

tcp_time_wait / tcp_close_wait 是定义了没接线的类型 。12 种类型里 10 种有真实触发链路,两类 TCP 只有常量、标签映射和 Content 构造逻辑,没接到检查流程。更微妙的是 tcp_established 反过来了:内容生成有、测试有,常量定义反而没有。这类"预留"容易被人遗忘,如果有文档标记接线状态,重构时不会误删。

注释与实现有出入 。engine.go 状态机注释写"每轮(10s)上报/巡检...离线告警统一使用此规则,巡检间隔 10s 与指标上报对齐",但 offline.go 实际默认 OfflineCheckInterval=30sAgentOfflineSeconds=120s------离线恢复观察实际是 30s×6=180s,不是注释暗示的 60s。指标类 10s×6=60s 没错,但"统一使用此规则"对离线巡检来说,规则是统一的(6 轮),节奏不统一(30s vs 10s)。注释想表达的语义没错,但具体数字会误导人,已按实际代码为准。

状态机与索引演进的相互作用 (呼应第六章):CreateAlert 的 UPSERT 语义(INSERT ... ON DUPLICATE KEY UPDATEalert_dao.go)依赖唯一索引 (device_uuid, alert_type, tag) 检测冲突。但 v2.6 把唯一索引删了------同一份引擎代码,在全新安装的库(init.sql 仍带索引)和升级安装的库(索引已删)上,ON DUPLICATE KEY UPDATE 的行为不一样。换句话说,告警去重现在实际由业务层承担:指标类靠内存缓存判断、离线/任务类靠 FindActiveAlert 查 DB。引擎的正确性依赖的不只是自己的逻辑,还有存储层给的承诺------这个承诺变了,引擎的行为跟着变,但代码注释没跟着改。

相关推荐
NutShell Wang4 小时前
Wails v3 Beta 实战:用显式对象模型重写你的第一个 Go 桌面应用
前端·人工智能·go·vibe coding
SoStraw7 小时前
Go + webrpc 实战:人在外面,远程查家里 NAS 磁盘和目录
go·p2p·nas·cgo·json-rpc·webrpc·无公网ip
newerp1 天前
Go net/http 标准库基础
后端·程序员·go
2651940511264801 天前
01-整体架构与高可用
go
程序员爱钓鱼1 天前
Go if 判断详解
前端·后端·go
名字还没想好☜2 天前
Go 的 unsafe.Pointer 实战:零拷贝 []byte↔string 转换与三条铁律
开发语言·后端·golang·go·unsafe
阿里云云原生2 天前
阿里云联合 Datadog,补齐 Go 可观测性最后短板
云原生·go
可观测性用观测云2 天前
零码改造!Go 语言应用上报观测云完整最佳实践
go
斐波那契数列2 天前
参考react 实现一个golang gui
go