四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列

四个进程,一把锁: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 }
  1. 加锁 :SET key token NX PX ttl。token 是 {instance_id}:{random},后 16 字节随机数保证同一实例的两次加锁也不会混淆。
  2. 续期 :Lua 比较 value 与 token,一致才 PEXPIRE。
  3. 释放 :同一套比较,一致才 DEL。
  4. 续期失败就放弃:持锁进程不再把这次执行报成成功。

第 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 的当前记录。
  • 通知:同一事件在同一投递任务上只产生一次。

锁负责互斥 (不做重复劳动),约束负责正确性(即使重复了也不会写错数据)。这两层不能互相替代:只有锁,窗口期就丢正确性;只有约束,就得靠捕获冲突错误来重试,性能与代码都更难看。

七、这套方案的代价

任何设计都有账要付,把它说清楚比藏起来好:

  1. 延迟上限约 2 秒。 轮询驱动的任务领取天然有抖动,对告警评测来说完全够用(评测窗口本身是分钟级),但不适合毫秒级事件流。
  2. 领取多一次 gRPC 往返。 领取走 alioth 的内部接口,比直连数据库多一跳。换来的是写入方唯一,数据边界清晰。
  3. 扩容是"加副本",不是"加消费者"。 新副本起来后自动参与抢锁,不需要重新分配分区、rebalance 或者迁移队列。这是无状态设计最实际的收益。
  4. 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 校验的锁,加上一条唯一索引,往往比引入一整套消息基础设施更短、更可运维。

相关推荐
用户19961435599641 分钟前
插件是怎么被安全装进系统的
后端
得物技术1 小时前
多 Agent 架构的三次反转:从中心调度到自驱协作|得物技术
程序员·架构·llm
愚农搬码1 小时前
自研表单引擎、开源组件二开和商业产品,企业应该如何选择?
后端
用户298698530141 小时前
Python 自动化处理 Word 页眉页脚的几种典型场景
后端·python·api
愚农搬码2 小时前
一款企业级电子表单系统应该具备哪些功能?
后端
Rain的Java大神之路2 小时前
国家数据集团校招、社招都开了:总部雄安,这些技术岗值得认真看看
java·后端·面试
XW2 小时前
Agent 上下文管理实现详解:从窗口工程到长期记忆
后端
Moment2 小时前
为什么这么多 Agent Harness 都选择 TypeScript
前端·后端·面试
软件技术NINI3 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设