四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列
Alioth 技术笔记 · 第 01 篇 · 架构 / 分布式锁 仓库:github.com/aide-family... | 在线演示:web.alioth.asia/
导语
告警平台天生是"生产者---消费者"结构:调度器发现一条规则该评测了,评测器去跑查询、算级别、写事件,通知器再把事件发出去。绝大多数实现会本能地上一个消息队列。Alioth 没有。它用一张 PostgreSQL 任务表加一把 Redis 锁,把整条链路跑成了多副本无状态服务。这篇文章讲清楚这个选择背后的边界划分、锁协议,以及代码里那些"看起来像细节、实际上是正确性基石"的地方。
一、先把进程切开
Alioth 是 aide-family 自建的运维平台:数据源查询、告警规则评测、通知投递、OnCall 值班在同一条链路上。四个后端进程按北斗星命名,各自只做一件事:
| 进程 | 中文名 | 职责 | 是否写业务库 |
|---|---|---|---|
alioth |
玉衡 | 控制面:身份、租户、告警与通知配置、内部 gRPC | 是,唯一写入方 |
megrez |
天权 | 领取到期规则,查询数据源,完成评测并回写 | 否 |
merak |
天璇 | 领取待发送任务,投递邮件与 Webhook | 否 |
alkaid |
摇光 | 边缘 Agent,出站连接控制面,执行 SSH 与 Web Shell | 否 |
关键约束只有一句:alioth 是业务库的唯一写入方 。megrez 和 merak 不直连 PostgreSQL,也不对外暴露业务 HTTP,它们通过内部 gRPC 向 alioth 领取任务、回写结果。这条规则把"谁是数据的 owner"钉死了,后面所有关于锁的讨论都建立在它之上。
在 Moon(Alioth 的前身)里,marksman 评测完直接把结果通过进程内 channel 递给 rabbit,因为它们是同一个进程里的两个模块。一旦拆成独立进程,channel 就没了------这才是"要不要 MQ"这个问题真正出现的地方。
二、为什么最后没有引入 MQ
三个判断,按权重排列:
第一,任务状态本来就必须落库。 一条规则这次评测有没有跑完、跑失败了、要不要重试,值班的人要能在页面上看到。如果消息在队列里、状态在队列里,运维就得同时理解两套存储;如果状态最终仍要写回 PostgreSQL,那队列就只剩"搬运"作用。
第二,这里需要的是互斥,不是削峰。 告警评测的并发度由"到期规则数 × 数据源"决定,量级可控;真正的问题是同一条规则不能被两个副本同时评测,否则同一个告警指纹会写出两条 FIRING 记录。这个需求用一把带 TTL 的分布式锁表达最直接,用队列反而绕远------你还得解决"消息重复投递"。
第三,组件越少越好。 自建平台通常只有一套 PostgreSQL 和一套 Redis。Redis 在 Alioth 里只做锁,不做缓存、不做会话、不做消息队列。这既是设计约束,也是运维承诺:Redis 挂了,平台降级但不丢数据;业务数据全在 PostgreSQL。
三、锁协议的四个动作
锁的实现集中在 pkg/lock,一共四种操作,全部围绕一个 token 展开。
go
// pkg/lock/lock.go
token := c.instanceID + ":" + randomToken()
res, err := c.rdb.Do(ctx, "SET", key, token, "NX", "PX", ttl.Milliseconds()).Result()
if errors.Is(err, redis.Nil) { return nil, ErrNotAcquired }
- 加锁 :
SET key token NX PX ttl。token 是{instance_id}:{random},后 16 字节随机数保证同一实例的两次加锁也不会混淆。 - 续期 :Lua 比较 value 与 token,一致才
PEXPIRE。 - 释放 :同一套比较,一致才
DEL。 - 续期失败就放弃:持锁进程不再把这次执行报成成功。
第 4 条是整个设计里最容易被忽略的一句。很多人写锁只写加锁和释放,忘了"锁已经不属于我了"这件事必须被当作失败处理。Alioth 在评测链路里把它做成了硬约束(见下一节 finish)。
三把锁的键和 TTL:
| 键 | 持有者 | 用途 | TTL / 续期 |
|---|---|---|---|
alioth:lock:schedule:{name} |
alioth |
短临界区,例如扫描到期规则并写下一次评测时间 | 15s,不续期 |
alioth:lock:eval:{rule_uid}:{datasource_uid} |
megrez |
持有到该(规则, 数据源)本次评测与回写结束 | 30s,每 10s 续期 |
alioth:lock:notify:{task_uid} |
merak |
持有到该通知投递与回写结束 | 60s,每 20s 续期 |
注意锁的粒度是**(规则,数据源)组合**,不是规则。一条规则可以匹配多个启用中的数据源,它们之间互不阻塞,这对多数据源的大型环境很关键。
四、评测进程里的一轮
megrez 的评测 worker 用 2 秒一次的 cron 驱动,外面套了一层 pkg/worker.Gate:
go
// app/megrez/internal/biz/eval/worker.go
const (
evalLockTTL = 30 * time.Second
renewEvery = 10 * time.Second
pollEvery = 2 * time.Second
backoffMax = 30 * time.Second
pollSpec = "*/2 * * * * *"
)
Gate 保证同一进程内同一时刻只有一轮在跑,并且失败后按 2s → 4s → 8s ... 退避到上限 30s。这一步不是必须的------锁已经保证了跨副本互斥------但它保证了单进程内的自我保护:数据源卡住时,不会每 2 秒堆一个新轮次上去。
一轮的结构是:领取 → 逐条加锁 → 评测 → 回写。
go
func (w *Worker) evalOne(ctx context.Context, rule bo.RuleSnapshot) error {
hold, err := w.locks.Acquire(ctx, "alioth:lock:eval:"+rule.UID+":"+rule.DatasourceUID, evalLockTTL)
if err != nil {
if errors.Is(err, lock.ErrNotAcquired) {
return nil // 别的副本正在做,跳过,不记失败
}
return err
}
defer func() { _ = hold.Release(context.Background()) }()
...
return w.finish(ctx, hold, rule)
}
ErrNotAcquired 被显式地翻译成"跳过"。这是一个语义决定:抢锁失败不是错误,是正常的调度结果。如果把它记成失败,指标会被人为污染,值班的人会看到不存在的故障。
五、续期 goroutine 与三种失败
真正体现设计密度的是 finish。它先起一个续期 goroutine,再跑数据源查询:
go
go func() {
timer := time.NewTimer(renewEvery)
for {
select {
case <-ctx.Done(): return
case <-timer.C:
if err := hold.Renew(ctx, evalLockTTL); err != nil {
renewFailed <- err
cancel() // 锁已经不是我的了,立刻停掉查询
return
}
timer.Reset(renewEvery)
}
}
}()
续期失败意味着 TTL 已过、别的副本可能已经接手,这时把上下文取消掉,让上游查询尽快退出,而不是让它跑完再"成功"回写。之后 finish 在回写前还会主动 Renew 一次,作为最后一道确认。
接下来是错误分类,这三类必须分开处理:
| 情况 | 处理 | 原因 |
|---|---|---|
| 轮次被取消(进程退出 / 超时) | 不记账,直接返回 | 不是数据源的问题 |
| 无效表达式等客户端错误 | 不回写批次,下轮继续尝试 | 属于规则配置问题,用户改了规则就能恢复 |
| 连接失败 / 超时 / 上游错误 / 响应无法解析 | 回写 Failed 批次并标记已回写 |
由 alioth 累计数据源可用性 |
第三类里的"标记已回写"很关键。如果不标记,2 秒后的下一轮会再次领到同一条规则,对一个已经挂掉的数据源反复轰炸------既浪费资源,又让上游监控看到一个疯狂的客户端。
六、锁失效窗口由数据库兜底
分布式锁有一个众所周知的弱点:GC 停顿、网络分区、续期失败,都可能让两个进程短暂地同时认为"锁是我的"。Alioth 的答案是用 PostgreSQL 唯一约束托住这个窗口:
- 评测批次:
(rule_uid, datasource_uid, eval_slot)唯一。同一规则、同一数据源、同一时间槽只允许一条批次记录。 - 告警:
(namespace_uid, fingerprint)上最多一条处于 FIRING 的当前记录。 - 通知:同一事件在同一投递任务上只产生一次。
锁负责互斥 (不做重复劳动),约束负责正确性(即使重复了也不会写错数据)。这两层不能互相替代:只有锁,窗口期就丢正确性;只有约束,就得靠捕获冲突错误来重试,性能与代码都更难看。
七、这套方案的代价
任何设计都有账要付,把它说清楚比藏起来好:
- 延迟上限约 2 秒。 轮询驱动的任务领取天然有抖动,对告警评测来说完全够用(评测窗口本身是分钟级),但不适合毫秒级事件流。
- 领取多一次 gRPC 往返。 领取走
alioth的内部接口,比直连数据库多一跳。换来的是写入方唯一,数据边界清晰。 - 扩容是"加副本",不是"加消费者"。 新副本起来后自动参与抢锁,不需要重新分配分区、rebalance 或者迁移队列。这是无状态设计最实际的收益。
- Redis 不可用时 fail-closed。 拿不到锁就拒绝评测和投递,而不是"乐观地继续"。两副本同时评测同一条规则带来的数据污染,比延迟几分钟严重得多。
八、自己跑一遍
bash
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080
需要 Docker 与 Docker Compose。依赖服务、环境变量、镜像构建与部署细节见仓库的 DEVELOP.md;服务边界、数据模型与安全设计见 docs/design.md。
如果只想验证锁行为,pkg/lock 的测试很短,go test ./pkg/lock/ 就能看到"第二个持有者拿到 ErrNotAcquired、token 不匹配时释放被拒绝"这两条最基本的不变量。
结语
如果只记住一句话:Alioth 用 PostgreSQL 存状态,用 Redis 只存锁,用唯一约束兜住锁的失效窗口。 消息队列能解决很多问题,但它不是"多进程要传递任务"的唯一答案。当任务状态本来就要落库、当真正需要的是互斥而不是削峰时,一把带续期和 token 校验的锁,加上一条唯一索引,往往比引入一整套消息基础设施更短、更可运维。