告警引擎与状态机
状态机 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. 问题
告警系统要同时满足两个相反的要求:
- 触发必须快:机器 CPU 烧到 95%,不能等两分钟才报
- 恢复必须稳:监控数据本身会抖,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 里的那行。
冷启动是全量加载而不是空缓存:NewEngine 里 FindAllActiveAlerts() 把 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-model :
cm.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,记录不存在自动重建)------这一步是离线判定的数据来源,然后依次执行检查:
- CPU 使用率(
EnableCpuCollect && EnableCpuAlert && CpuWarn > 0 && Usage > 0) - CPU 负载(
EnableCpuCollect && EnableCpuLoadAlert && CpuLoadWarn > 0 && Load15 > 0) - CPU IO 等待(
EnableCpuCollect && EnableCpuIowaitAlert && CpuIowaitWarn > 0 && Iowait > 0) - 内存(
EnableMemCollect && EnableMemAlert && MemWarn > 0 && UsedPercent > 0) - 磁盘分区(
EnableDiskCollect && EnableDiskAlert && DiskWarn > 0) - 网卡带宽(
EnableNetCollect && EnableNetAlert && NetBwWarn > 0) - 网卡错误包(
EnableNetCollect && EnableNetErrorAlert && NetErrWarn > 0) - 磁盘 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_info(ListAllOnlineForOfflineCheck),now - dev.LastOnlineAt > offlineThreshold(120s)判离线。
离线分支:
FindActiveAlert查 DB(不是查缓存)→ 无 →triggerAlert,forceLevel=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 离线场景由 checkOfflineDevices 的 ResolveAllOfflineAlertsByDevice 覆盖,僵尸回收不重复处理。
任务告警的 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=30s、AgentOfflineSeconds=120s------离线恢复观察实际是 30s×6=180s,不是注释暗示的 60s。指标类 10s×6=60s 没错,但"统一使用此规则"对离线巡检来说,规则是统一的(6 轮),节奏不统一(30s vs 10s)。注释想表达的语义没错,但具体数字会误导人,已按实际代码为准。
状态机与索引演进的相互作用 (呼应第六章):CreateAlert 的 UPSERT 语义(INSERT ... ON DUPLICATE KEY UPDATE,alert_dao.go)依赖唯一索引 (device_uuid, alert_type, tag) 检测冲突。但 v2.6 把唯一索引删了------同一份引擎代码,在全新安装的库(init.sql 仍带索引)和升级安装的库(索引已删)上,ON DUPLICATE KEY UPDATE 的行为不一样。换句话说,告警去重现在实际由业务层承担:指标类靠内存缓存判断、离线/任务类靠 FindActiveAlert 查 DB。引擎的正确性依赖的不只是自己的逻辑,还有存储层给的承诺------这个承诺变了,引擎的行为跟着变,但代码注释没跟着改。