08-告警并发与多Master同步

告警并发与多 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 各管各的缓存,会出现两个具体问题:

  1. 重复触发:Master A 看到 CPU 95% 触发告警写 DB,Master B 的缓存里没有这条记录,下一轮上报被 B 处理时它也判定"首次超阈值",再次触发------DB 里出现两条相同设备、相同类型、相同 tag 的活跃告警
  2. 恢复不同步: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),比对的是缓存值里的 IDLevel

2.4 为什么 CoolDown 不需要同步

规则表的最后一行是刻意的设计:CoolDown 不参与同步。"CoolDown 计数已在 DB 中,无需通过缓存同步"。

展开说:恢复判断依赖的 CoolDown,状态机每轮都会 UpdateCoolDown 写回 DB(第 07 章的 DB 副作用)。但同步时缓存条目只带 IDLevel不带 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. 验证

同步逻辑没有独立的单测文件,靠的是三层防御在长稳压测里被验证:

  1. 冷启动 NewEngine 全量加载失败直接 panic(宁可重启不空缓存启动),sync 协程的 sleep 2s 和 recover 是运行期兜底
  2. 8004 台 Agent 的 8 小时长稳压测里,agent_offline 这类高并发触发的告警没有出现重复条目(DB 侧 FindActiveAlert 去重 + 缓存侧 sync 替换共同保证),第 06 章的压测记录可对照
  3. 注释即契约:四条同步规则与实现逐条对应,代码评审时核对过注释和分支一致

一个可以验证的推论:把 AlertSyncInterval 调成 60s,双 Master 下告警恢复的延迟会明显拉长(B 侧要等 60s 才知道 A 恢复了什么)------这个参数本身就能当同步时效的实验开关。

5. 回看

多写了一个缓存同步,但真正的事实源一直是 DB------缓存只是让状态判断变快,同步只是让两份缓存尽量一致。想通了这一点,就不用为"缓存不一致"焦虑:DB 对,缓存迟早会对。

相关推荐
程序员爱钓鱼5 小时前
Go for 循环详解
后端·面试·go
程序员爱钓鱼21 小时前
Go switch 详解
后端·面试·go
lisanmengmeng1 天前
飞书 API使用
飞书·监控·日志·日志监控·监控及日志
三维地图技术社区1 天前
24小时降雨316.1毫米:城市内涝是怎么发生的?排水防涝体系与应急管理一次讲透
监控·图新说·城市内涝·排水防涝·应急管理·三维地下管网·应急预案三维可视化
2651940511264801 天前
07-告警引擎与状态机
go·监控
NutShell Wang1 天前
Wails v3 Beta 实战:用显式对象模型重写你的第一个 Go 桌面应用
前端·人工智能·go·vibe coding
SoStraw1 天前
Go + webrpc 实战:人在外面,远程查家里 NAS 磁盘和目录
go·p2p·nas·cgo·json-rpc·webrpc·无公网ip
newerp2 天前
Go net/http 标准库基础
后端·程序员·go
2651940511264802 天前
01-整体架构与高可用
go