历史告警归档:为什么"删掉主库行"是最后一步
Alioth 技术笔记 · 第 10 篇 · 存储 / 告警历史 仓库:github.com/aide-family... | 在线演示:web.alioth.asia/
导语
告警历史是个很别扭的数据:它写入量大、查询频率低、但一条都不能丢------事后复盘、责任界定、阈值调优全靠它。最常见的归档实现是"复制到历史表,然后删掉原表",简单直接,但只要"复制"和"删除"之间有一步失败,数据就没了。Alioth 把删除放在整个流程的最后一步,而且每一步失败都只记日志、不阻断,换来的是"归档出问题也不会丢事件"。这篇讲这个顺序,以及读路径怎么把两个库拼成一张列表。
一、归档到哪:租户库 + 周表
历史存储不是一个表,而是每个租户一个数据库、库内按周一张表:
go
// historyStore 是历史告警的分库分表存储。
// 每个租户一个数据库(前缀 + 租户标识),库内按周一张表(前缀 + 周一 YYYYMMDD)。
// 主库只保留 alert_history_events 路由索引和仍在触发的事件。
主库只留两样东西:仍在触发的事件,以及一张 alert_history_events 路由索引表。索引记的是 事件 UID → (数据库, 表, 周),所以查一条事件详情不需要扫所有周表:
go
index, err := r.client.AlertHistoryEvent.Query().Where(...EventUIDEQ(uid)).Only(ctx)
item, err := r.history.get(ctx, namespaceUID, index.DatabaseName, index.TableName, uid)
按周切表的好处很直接:清理是 DROP TABLE 而不是 DELETE ... WHERE,不会产生大量死元组;坏处是查询要枚举表------这一点后面会变成一条硬限制。
二、写路径的时序:删是最后一步
go
database, table, week, err := history.archive(ctx, item)
if err != nil {
log.Error("archive alert event " + item.UID + ": " + err.Error())
continue // 主库行还在,事件不丢
}
if err := recordHistoryIndex(ctx, client, eventNamespace, item.UID, database, table, week); err != nil {
log.Error("index alert event " + item.UID + ": " + err.Error())
continue // 同上
}
if _, err := client.AlertEvent.Delete().Where(...UIDEQ(item.UID)).Exec(ctx); err != nil {
log.Error("delete archived alert event " + item.UID + ": " + err.Error())
}
if _, err := client.AlertEventFiring.Delete().Where(...EventUIDEQ(item.UID)).Exec(ctx); err != nil {
log.Error("delete archived firing " + item.UID + ": " + err.Error())
}
四个动作的语义是"至少一次":写入历史 → 写索引 → 删主库行 → 删占位。前两步失败就 continue,主库行原样保留,下次恢复流程或回填任务会重试;第三步失败时历史里已经有了,读路径的合并逻辑会把主库那份当作最新版本(见下节)。
history.archive 的注释写了幂等保证:重复归档按 uid 覆盖。所以重试不会产生两条历史记录,这让"至少一次"不必升级成"恰好一次"------后者需要分布式事务,代价太大。
三、读路径:主库优先,归档补齐
历史列表不是从历史库读,而是两边都读再合并:
go
rows := 主库中 (status <> FIRING) OR 仍在抑制中 的事件
items := toEvents(rows)
archived, err := r.history.list(ctx, namespaceUID)
if err != nil {
// 分库不可用时退回主库历史行,页面仍能展示升级前和归档失败的数据。
return filterHistoryItems(items, filter)
}
// 主库行优先:归档成功但删除主库失败时,主库状态仍是最新的。
for _, item := range archived {
if 主库已有同 UID { continue }
items = append(items, item)
}
这段代码解决的是"归档故障时页面不能空白":分库连不上,用户仍然能看到主库里那些"恢复但没删成功"的事件;正常情况下,主库行覆盖归档副本,因为主库的状态永远更新(比如事件恢复后又被人改了抑制状态)。
注意第一行那个条件:status <> FIRING OR 仍在抑制中。抑制中的事件也算历史 ------它不在实时列表里,如果历史里也查不到,就成了谁都看不见的幽灵事件。这个谓词和数据库侧的 suppressedPredicate 是同一个语义(第 08 篇讲过),两处必须保持一致。
四、代价:为什么单次只让查 30 天
历史读取的实现是"列出该租户库所有周表,逐表查询,汇总后在 Go 里过滤、分页":
go
// 示意:实际实现用参数化查询,表名前缀来自配置
tables := SELECT tablename FROM pg_tables WHERE tablename LIKE 'alert_events_%'
for _, table := range tables {
part, err := s.queryTable(ctx, conn, table, namespaceUID)
items = append(items, part...)
}
没有 SQL 级分页,也没有跨表索引------因为表名是按周动态生成的,SQL 无法预知。于是查询成本与"扫过多少张周表"成正比。这就是 30 天上限的来源:
go
// 前端只允许选 30 天,这里必须再挡一次:手工拼 URL 或第三方调用
// 都能绕过界面,而一次长区间查询会扫过所有归档周表。
if query.StartsAtTo.Sub(*query.StartsAtFrom) > MaxHistoryRangeDays*24*time.Hour {
return merr.Invalid("alert event time range must be within 30 days")
}
前端限制 + 服务端二次校验,两道都在。这是一个很典型的取舍:用"查询时长受限"换取"归档结构简单、清理零成本"。要支持任意长区间,就得引入时间分区表 + 全局索引,那是另一种量级的复杂度。
五、几个真实的坑
- 回填要按事件自己的租户写。
archiveRecoveredItems的注释:回填会一次处理多个租户,必须用item.NamespaceUID写索引和删主库行,否则会把 A 租户的事件塞进 B 租户的库。 - 周表 DDL 要幂等。
CREATE TABLE IF NOT EXISTS由恢复写路径和归档调用,重复执行不能报错------归档本身就在重试链路上。 - 删占位不能忘。 归档完事件行却不删
alert_event_firings,就留下孤儿占位,第 06 篇说过:这条规则之后的每轮评测都会卡在"占位对应的事件不存在"。代码里两个删除是成对的。 - 归档失败要能观测。 每一步失败都写
log.Error,但不返回错误------归档不是告警链路的必要环节,它失败不该让"告警恢复"这个动作失败。
六、怎么验证
app/alioth/internal/data/history_store_test.go 覆盖了周表命名(TestHistoryShardNames)、前缀长度限制与 DSN 拼接;maintenance_archive_integration_test.go 用真实数据库跑归档的导入导出。手工验证可以这样:
bash
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080
触发一条告警再让它恢复,几秒后查历史列表应该能同时看到"还在主库的恢复行"和"已归档的周表行",且只出现一次。把历史库停掉再触发一次恢复,页面不会报错,事件仍留在主库、历史里可见------这就是"归档失败不丢事件"的实际表现。
结语
归档的正确性不取决于分库分表做得多漂亮,而取决于删除被放在最后、并且删除失败也不影响数据可用 。Alioth 的写法是:写入幂等、索引可查、删除兜底、读取合并;每一步都能单独失败,但没有一种失败会让事件消失。历史数据的价值在于"事后还能查",而这套流程保证的最重要的一件事就是------归档不是一次性的搬运,而是一条可以重试的链路。