告警并发与多 Master 同步
双 Master 每 5s 从 DB 全量同步活跃告警;IP 一致性哈希把 Agent 尽量粘在同一 Master,一致性由 DB 同步兜底;不用 Redis、不用 leader 选举;DB 是唯一事实源,内存缓存只是投影
0. 一句话
告警状态机跑在内存缓存里,但系统是双 Master------两台机器各有一份缓存,得对齐。Agent 上报走 IP 一致性哈希,同一 Agent 大概率粘在同一 Master,但粘性不保证,所以每 5 秒从 DB 全量拉一次活跃告警,按四条规则比对:有新加载、有变同步、ID 变替换、消失删除。整条链路没有 Redis、没有 leader 选举,DB 就是唯一的事实源。
1. 问题
双 Master 挂在 Nginx 后面,Agent 上报走四层 stream 一致性哈希,同一台 Agent 的请求正常情况下会粘到同一台 Master。但"粘性"是尽力而为的:
- Keepalived 故障切换时 VIP 漂移,已建立的连接被重置,Agent 重连后哈希落点可能换到另一台 Master
- Nginx reload、Master 重启,哈希环变化,落点同样会变
- 一台 Master 单机运行时,它触发的告警只在自己缓存里,另一台 Master 上线后缓存是空的
如果两台 Master 各管各的缓存,会出现两个具体问题:
- 重复触发:Master A 看到 CPU 95% 触发告警写 DB,Master B 的缓存里没有这条记录,下一轮上报被 B 处理时它也判定"首次超阈值",再次触发------DB 里出现两条相同设备、相同类型、相同 tag 的活跃告警
- 恢复不同步:A 观察了 6 轮正常把告警恢复了,B 的缓存还挂着这条,继续显示告警中
不加 Redis 的取舍,所以同步不能靠 Redis 发布订阅;也不能假设"只有一台 Master 在干活"(那就回到单点)。约束是:只依赖已有的 MySQL,两台 Master 靠它对齐。
2. 实现
全部逻辑在 server-master 服务的 [包] alert 下,源文件 sync.go 一个文件,两个符号:[方法] StartSyncLoop(同步协程生命周期)和 [方法] SyncFromDB(单次同步)。代码顶部的注释把设计意图写得很明确:
每台 Master 定时从 DB 全量加载活跃告警到内存,实现多 Master 之间告警状态的自动同步------不需要 Redis、不需要 ip_hash、不需要 leader 选举。
2.1 同步协程生命周期
[方法] StartSyncLoop 干四件事:
go
func (e *Engine) StartSyncLoop() {
cfg := config.GetRuntime()
if !cfg.AlertEnable {
return // 告警总开关关着,同步协程根本不启动
}
intervalSec := cfg.AlertSyncInterval
if intervalSec <= 0 {
intervalSec = 5 // 默认 5 秒,与 Agent 上报间隔对齐
}
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
go func() {
defer ticker.Stop()
// 启动后稍等片刻再首次同步(避免在 DB 连接初始化前执行)
time.Sleep(2 * time.Second)
e.SyncFromDB()
for {
select {
case <-e.closeCh:
return // 优雅退出
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("同步协程单次同步 panic(已自动恢复): %v", r)
}
}()
e.SyncFromDB()
}()
}
}
}()
}
三个细节值得说:
- 默认 5s,与 Agent 上报间隔对齐:上报是 10s 一轮,同步是 5s,一个上报周期内至少能同步两次。同步慢半拍没关系,只要不比上报慢一个数量级,缓存就是新鲜的
- 启动前 sleep 2s :避免 DB 连接还没初始化就执行首次同步。第 07 章讲过冷启动时
NewEngine会从 DB 全量加载活跃告警进缓存(加载失败直接 panic),那是启动期的硬保证;这里的 sleep 只是让同步协程别抢跑,两者不冲突 - 单次同步包 recover:同步失败不能杀掉协程。SyncFromDB 内部出错的路径都只 log 返回,panic 是最后一层兜底------同步协程是常驻的,死了就没有任何机制把两台 Master 重新对齐
2.2 单次同步:四步走
[方法] SyncFromDB 的流程:
go
func (e *Engine) SyncFromDB() {
alerts, err := e.alertDao.FindAllActiveAlerts() // 步 1:全量加载活跃告警
if err != nil {
logger.Error("同步活跃告警失败: %v", err)
return
}
// 步 2:阈值已禁用的告警类型 → 自动恢复
for _, a := range alerts {
if e.isAlertTypeDisabled(a.AlertType) {
e.alertDao.ResolveAlert(a.ID) // 直接把 DB 里的活跃告警关掉
}
}
alerts, err = e.alertDao.FindAllActiveAlerts() // 步 3:重新加载(步 2 改了 DB)
if err != nil {
return
}
e.alertCacheMu.Lock() // 步 4:锁内比对
defer e.alertCacheMu.Unlock()
// ... 比对与增删
}
步 2 是容易被忽略的一个环节:同步不仅是搬运,还会做清理 。isAlertTypeDisabled 判断"这个告警类型现在是不是被禁用了",判定条件有三层(第 09 章会展开):告警总开关 AlertEnable 关了、该类型开关(如 EnableCpuAlert)关了、该类型 warn 阈值被调成 ≤0。任一命中,就认为"用户不再需要这类告警",把 DB 里还挂着的活跃告警直接 ResolveAlert 恢复掉。注释原话:
阈值已禁用的告警类型 → 自动恢复(用户把阈值调成 0 或关了该类型总开关,说明不再需要此类型告警,已存在的活跃告警应当清理掉)
这样"关告警"这个动作才是闭环的:关开关不仅阻止新告警,还清掉存量活跃告警。如果只阻止不清理,界面上会永远挂着一批永远不会恢复的僵尸告警。
2.3 四条同步规则
步 4 的锁内比对是核心,代码把规则写成了注释,一比一对应实现:
go
// 同步规则:
// - DB 中有、缓存中也有的(同 ID)→ 同步级别
// - DB 中有、缓存中没有的 → 新加载
// - DB 中有、缓存中也有但 ID 不同 → 说明被另一台 Master 重建了,更新 ID
// - DB 中没有、缓存中有的 → 从缓存删除(已恢复,但 sync 还没跑到)
// - CoolDown 计数已在 DB 中,无需通过缓存同步
对应实现:
go
dbKeys := make(map[alertFlagKey]alertCacheEntry, len(alerts))
for _, a := range alerts {
key := alertFlagKey{DeviceUUID: a.DeviceUUID, AlertType: a.AlertType, Tag: a.Tag}
dbKeys[key] = alertCacheEntry{ID: a.ID, Level: a.Level}
}
// 清理缓存中 DB 已不存在的条目(已恢复的告警)
for key := range e.alertCache {
if _, ok := dbKeys[key]; !ok {
delete(e.alertCache, key)
}
}
// 同步 DB 条目到缓存
for key, dbEntry := range dbKeys {
cached, ok := e.alertCache[key]
if !ok {
e.alertCache[key] = &alertCacheEntry{ID: dbEntry.ID, Level: dbEntry.Level} // 新加载
} else if cached.ID == dbEntry.ID {
cached.Level = dbEntry.Level // 同 ID:同步级别
} else {
e.alertCache[key] = &alertCacheEntry{ID: dbEntry.ID, Level: dbEntry.Level} // ID 不同:另一台重建,替换
}
}
整理成规则表:
| DB 中 | 缓存中 | 判定 | 动作 |
|---|---|---|---|
| 有 | 无 | 另一台 Master 先触发了新告警 | 加载进缓存 |
| 有 | 有,同 ID | 同一告警,级别可能被改过 | 同步 Level |
| 有 | 有,ID 不同 | 告警被恢复后重建,ID 变了 | 替换整个条目 |
| 无 | 有 | 告警已恢复,但上一轮 sync 没跑到 | 从缓存删除 |
键就是三元组(DeviceUUID + AlertType + Tag),比对的是缓存值里的 ID 和 Level。
2.4 为什么 CoolDown 不需要同步
规则表的最后一行是刻意的设计:CoolDown 不参与同步。"CoolDown 计数已在 DB 中,无需通过缓存同步"。
展开说:恢复判断依赖的 CoolDown,状态机每轮都会 UpdateCoolDown 写回 DB(第 07 章的 DB 副作用)。但同步时缓存条目只带 ID 和 Level,不带 CoolDown------也就是说,流量切到另一台 Master 后,它的缓存里 CoolDown 是 0,恢复观察从第一轮重新数。
这个设计偏保守:宁可多观察几轮才恢复,也不冒"假恢复"的风险。如果同步把 A 的 CoolDown=5 搬给 B,B 下一轮正常就恢复告警了------可 B 根本没观察过那台机器的 5 轮数据,这个"正常"是没根据的。恢复错了比恢复慢了严重。
2.5 与唯一索引演进的关联
告警表唯一索引的演变:v2.1 加了唯一索引,v2.6 又删了。现在 DB 层不靠唯一索引去重,靠的是 FindActiveAlert(按三元组查)在应用层拦截重复创建。这条同步规则第三条"ID 不同 → 替换"正好补上应用层去重的边界:
- A 触发告警,DB 里插入一条 ID=100
- 告警恢复后再次触发,CreateAlert 走 UPSERT 语义,新记录 ID=200
- B 的缓存还挂着旧条目(ID=100),下一轮 sync 发现 DB 里同三元组的 ID 是 200 → 替换
没有这条规则的话,B 会拿着过期的 ID=100 去操作 DB(恢复、升级时按 ID 定位),恢复的是已经恢复过的那行,或者直接找不到目标。规则三就是为了消灭这种"缓存 ID 与 DB 实际 ID 脱节"的状态。
3. 取舍
为什么不用 Redis 发布订阅:整体不上 Redis 的理由,具体到告警同步也一样------多一个组件就多一个故障点,而 MySQL 是部署时必有的。发布订阅是"事件驱动、毫秒级",但告警状态的时效要求是秒级(上报 10s 一轮),5s 轮询全量拉取完全够用。活跃告警的量级远小于指标数据(8004 台 Agent 正常时活跃告警几十到几百条),全量比对的开销可以忽略。
为什么不用 leader 选举 :告警写入天然可以"谁处理谁写",因为 DB 层有 FindActiveAlert 去重兜底,写重复了也不会产生两条。选主的收益是"唯一写者",但这里不需要唯一写者------DB 就是仲裁者。反而选主会引入新复杂度:租约、脑裂、故障转移延迟,且选主本身在 Keepalived 双机场景下还要再定义"谁是主"。
为什么不把一致性押在哈希粘性上:Agent 上报走 Nginx stream 一致性哈希,它确实让同一 Agent 的请求大概率落到同一台 Master------但这是性能优化,不是一致性机制。一致性哈希从设计上就不承诺强粘性:Keepalived 切换、Nginx reload、Agent 重连都会打破它,切走之后本机缓存就是旧的。所以正确性从来不押在哈希上,而是押在 DB 同步机制上:哈希失效最坏的结果是"跨 Master 的同步次数变多",不是"状态错了"。哈希负责在正常运转时候命中本机缓存、减少跨 Master 同步,sync 负责兜底一致性,两者职责不同,不是二选一,后续可能还会针对这方面进行优化。
代价:5s 窗口内两台 Master 看到的状态可能不一致(A 已触发、B 还不知道,B 这轮可能重复判定但被 DB 去重挡住);CoolDown 不跨 Master 接力,切换后恢复观察重新计,恢复会慢一点。这两点都是可接受的:前者有 DB 去重兜底,后者偏保守方向错也错在"更安全"一侧。
4. 验证
同步逻辑没有独立的单测文件,靠的是三层防御在长稳压测里被验证:
- 冷启动
NewEngine全量加载失败直接 panic(宁可重启不空缓存启动),sync 协程的 sleep 2s 和 recover 是运行期兜底 - 8004 台 Agent 的 8 小时长稳压测里,
agent_offline这类高并发触发的告警没有出现重复条目(DB 侧FindActiveAlert去重 + 缓存侧 sync 替换共同保证),第 06 章的压测记录可对照 - 注释即契约:四条同步规则与实现逐条对应,代码评审时核对过注释和分支一致
一个可以验证的推论:把 AlertSyncInterval 调成 60s,双 Master 下告警恢复的延迟会明显拉长(B 侧要等 60s 才知道 A 恢复了什么)------这个参数本身就能当同步时效的实验开关。
5. 回看
多写了一个缓存同步,但真正的事实源一直是 DB------缓存只是让状态判断变快,同步只是让两份缓存尽量一致。想通了这一点,就不用为"缓存不一致"焦虑:DB 对,缓存迟早会对。