GoWind Ops 拆解:不引入分布式锁的多副本协调

18 阅读4分钟

GoWind Ops 拆解:不引入分布式锁的多副本协调

本文回答一个问题:core 服务水平扩到多副本之后,定时任务、指令回执、构建触发、告警外呼,靠什么保证不重复执行——而又不引入 Redisson、etcd lock、ZooKeeper 里的任何一个?

GoWind Ops 的 core 是唯一连库的领域服务,天然想水平扩:多副本分摊 MQTT 信令、多副本跑定时调度。但多副本立刻带来一组经典问题——同一分钟的双 cron 触发、同一指令的重复回执、同一配置的并发构建。常见的答案是上一把分布式锁,GoWind Ops 的选择是把协调拆散进它已经在用的三个层:MQTT broker、数据库条件更新、Redis 原子操作,全仓找不到 redsync / etcd concurrency / SELECT FOR UPDATE 抢占式锁服务里的任何一种"锁服务"。

这篇文章先拆防住的机制,再老实交代我们审查时找到的三个真实窗口——以及它们分别是怎么用同一套思路补上的(这部分代码已随本文一并修复合入)。


一、问题设定:多副本到底会重复什么

把 core 复制成两份,逐条业务线过一遍:

业务线多副本下的重复形态
MQTT 上行(心跳/准入/回执)普通通配订阅是广播语义,每条消息每个副本都收
定时构建/脚本/备份30s 扫描 + cron 命中,两副本同一分钟各触发一次
监控检查探测指令双发、防抖计数双加、告警双建双外呼
构建触发check-then-act,并发触发双跑构建
终端输出连接级点对点流,分流会丢流(重复的反面)
令牌轮换并发 refresh 的 TOCTOU 竞态

不同的重复,代价不同:告警双发骚扰人、构建双跑浪费资源还可能打架、回执重复则必然发生(QoS 1 的语义就是至少一次)。所以 GoWind Ops 不是用一种机制防所有重复,而是按重复的代价和必然性分级选机制。


二、第一层:让 broker 分流——$share 共享订阅

上行消息的重复不需要应用层操心,交给 EMQX。core 的准入、心跳、指令回执三条上行订阅全部包上共享订阅(pkg/topic/mqtt.go):

const MqttShareGroupCore = "gwo"
func SharedSubscription(group, filter string) string {
    return "$share/" + group + "/" + filter
}

组内每条消息只投一个实例,broker 天然做了负载均衡。

对照组:终端输出流 ops/agent/+/terminal/+/output 故意不走 $share——注释写得直白:"终端流是连接级点对点,多副本组内分流会丢流"。改为每副本都收全量输出,只有持有对应 gRPC 流的副本按内存路由表(terminalRoutes map[sessionId]chan)消费,其余副本静默丢弃。

这一对组合是整套设计里最值得玩味的对比:防重复的前提是先问"这条消息该单份还是该广播"——指令回执要单份($share),终端流要广播+自过滤(分流即丢流)。用带宽冗余换正确性,取舍写在注释里。

配套还有一个容易踩的暗坑:$share/gwo/ops/... 若按字面做 ACL 匹配会整组拒掉——授权层要先"剥壳"(SplitN(t, "/", 3) 取第三段)再按真实过滤器判定。引入共享订阅后授权静默失效,这是 MQTT 多副本最常见的隐性事故。


三、第二层:数据库条件更新当"抢答器"

重复执行的去重大头在数据库——不是靠 SELECT FOR UPDATE 悲观锁服务,而是靠带条件的 UPDATE 返回的 affected rows 当抢答信号。

3.1 锚点 CAS:定时调度的统一答案

构建/脚本/备份/监控四类定时任务共享同一个模式(build_repo.go,其余三处同款):

// AdvanceCronAnchor 仅当 last_cron_at 仍等于 expected 时更新为 now
Update().Where(IDEQ(id), LastCronAtEQ(expected)).SetLastCronAt(now)
// n>0 的副本才触发

两个副本读到同一个锚点,只有一个 CAS 成功——触发权即锚点所有权。探测指令、防抖计数、告警创建这些下游动作随锚点自然单份。

这里藏着一个非常好的坑:新建任务 last_cron_at 为 NULL,EQ(NULL) 在 SQL 里永远匹配不上——CAS 永不成功、任务永不触发,而且没有任何报错。解法是"首见落锚不补跑"(AdvanceCronAnchorFromNull:WHERE last_cron_at IS NULL 先落当前时间,下个周期起正常调度)。这类 bug 的恶劣之处在于它安静:任务不跑,日志也无话可说。

3.2 占位幂等键 + 抢占认领:分发的两阶段

分发是多目标、多批次、可灰度的,比单点 cron 复杂。设计分两步(distribution_record_repo.go):

  1. 触发时先给全部目标建占位记录,幂等键是 pending:{distId}:{batchNo}:{projectId}——批次视图完整可见,前端能提前看到"第二批还没轮到";
  2. 真正下发前用条件更新认领:
Update().Where(IDEQ(recordID), CommandRequestIDEQ(placeholder), StatusEQ(Pending)).
    SetCommandRequestID(realRequestID)

占位键换真实 UUID,恰一方 n>0 获得投递权。认领失败的副本日志一句 "claim lost (concurrent advance?)" 直接跳过——两阶段设计同时满足了"批次视图完整性"与"只投一次"这两个本来矛盾的要求。

3.3 PENDING-only 条件回填:QoS 1 重复的终极答案

指令回执必然重复(QoS 1 至少一次),解法是把幂等做进终态写入:

// FinalizeByRequestID
Update().Where(RequestIDEQ(requestID), StatusEQ(StatusPending)).SetStatus(terminal)

只有 PENDING 行能被终态化,first-write-wins:重复回执、手工注入、超时扫描与真实回执竞态,全部安全。这一个条件更新模式是全系统幂等的基石——备份记录、分发记录、脚本批次的超时对账全是同款。配套的 30 秒对账循环把超时 PENDING 定案 FAILED,迟到回执由台账消费不丢。

3.4 条件翻转当"发一次"语义

节点离线告警的去重也是同款:MarkOfflineIfStale 是 WHERE id=? AND status=ONLINE AND last_heartbeat_at<cutoff 的条件更新,返回 flipped 布尔——只有翻转成功的那一方发 NODE_OFFLINE 告警。把"状态翻转"本身做成原子抢答,告警自然不重复。

3.5 第三层补位:Redis 原子操作

需要共享可变状态的两处走 Redis:

  • 刷新轮换:Lua 脚本单次完成"验证 RT → 删 RT → 删 AT"(user_token_cache.go),注释点名防 TOCTOU——并发 refresh 只有一个能成功;
  • 登录防爆破:INCR + EXPIRE 计数,5 次锁 15 分钟,天然跨副本共享。

四、诚实的部分:审查发现的三个窗口与修复

以上机制在写代码时就位了,但把全仓按"多副本会重复什么"过一遍时,我们还是找到了三个真实的窗口。这部分最值得写——因为它们的成因都不是"忘了",而是机制在单副本语义下完全正确,扩副本后才暴露。

4.1 构建触发的 check-then-act → FOR UPDATE 锁锚

原 TriggerBuild 的三步——HasActiveHistory 检查 → IncrBuildNo → Create——各自原子,但合起来不是。手动/API/webhook 并发触发(或双副本同分钟 cron 命中——cron 路径有锚点,但 cron 之外的路径没有)都能通过检查,各自创建一条历史并并发跑同一条构建。构建历史表上的 (build_id, build_no) 唯一索引防不了它:并发触发拿到的构建号本来就不同。

修复选择了锁锚事务而不是部分唯一索引(build_history_repo.go 的 CreateExclusive):

tx := client.Tx(ctx)
// 锁锚:构建配置行 FOR UPDATE,并发触发在此排队
cfg := tx.Build.Query().Where(build.IDEQ(buildID)).ForUpdate().First(ctx)
// 锁内复查 → 推进构建号 → 落 PENDING 历史 → commit

两个原因放弃部分唯一索引:MySQL 不支持 partial index(本仓 PostgreSQL/MySQL 双支持),而"配置行 FOR UPDATE + 锁内复查"在两个方言下语义一致。更关键的是,手动/API/webhook/cron 全部触发路径都收敛在 TriggerBuild 一个入口——单入口意味着一处锁锚覆盖所有触发方,这是收敛入口红利。

临界区只有毫秒级的三条 SQL,行锁不跨外呼。

4.2 监控检查的"只读幂等"错觉 → 锚点 CAS 补齐

调度器结构体注释原文写着"监控检查只读幂等,重复无害"。这话不成立:检查会创建告警、外呼 webhook/邮件、推进防抖计数。多副本同分钟双跑的后果:HasUnresolvedAlert 是 check-then-act,两副本同时查都返回 false → 双建告警、双发通知;ConsecutiveHits 是"读出 +1 写回"的读改写,并发下丢失更新,N 周期防抖被两次 tick 抬成 2。

修复三件套(本次一并合入):

  1. 给监控规则加 last_check_at 锚点,检查前 CAS 抢占——与构建/脚本/备份完全同款,包括 AdvanceCheckAnchorFromNull 的 NULL 落锚。CAS 之后同一规则同一周期只有一方执行,探测指令双发、防抖计数双加、告警双建在源头消失。比"Redis SETNX 分钟桶"更优:CAS 比较的是精确的上次锚点值,没有时钟偏移窗口,且不引入新依赖。
  2. 防抖计数改原子自增(IncConsecutiveHits:AddConsecutiveHits(1) 生成 consecutive_hits = consecutive_hits + 1,返回自增后新值)——即使未来出现绕过调度器的触发路径,读改写也不复存在。
  3. 恢复通知改由条件更新的行数裁决:ResolveAllByMonitor 本来就只更新 resolved=false 的行并返回 affected rows,把"先查有没有未恢复 → 恢复 → 发通知"改成"直接恢复,n>0 才发通知"——多副本下恰一方发出恢复通知,顺手消灭了恢复路径上的 check-then-act。

这次修复还有个教训值一句注脚:那句"只读幂等"的注释本身就是 bug 的一部分——注释声称的性质要和代码的真实副作用对得上,否则它会误导下一个维护者跳过并发审查。

4.3 启动清扫的全局误伤 → runner 归属

FinalizeInterrupted 在 core 启动时把全部 PENDING/RUNNING 构建历史定案 FAILED。单副本语义下这是自愈:上次进程被 kill -9,遗留记录必须有人收尸。多副本滚动重启时它变成误伤——重启副本会把其他副本正在跑的构建也标 FAILED(构建 goroutine 在进程内,记录却是全局的)。

修复是给构建历史和流水线 run 加 runner_id(机器指纹,pkg/machine.MachineID()):

  • 启动清扫只清本实例:FinalizeInterruptedByRunner 在原条件上追加 RunnerIDEQ(runnerID)——"我重启,收我自己的尸";
  • 跨实例遗留交给周期兜底:调度器每小时跑一次 FinalizeStaleInterrupted,把 updated_at 停更超过 2 小时的未终态记录定案 FAILED("orphaned: runner lost")。2 小时的依据:构建命令最长 3600s 且执行期间每 5s 刷新日志(updated_at 持续更新),克隆(≤10min)与产物上传亦各有时限——持续 2h 无任何更新即可判定 runner 已消失;
  • 条件更新幂等,多副本并发清扫恰一方生效。

这个修复的模式值得记住:"进程内的执行者"要有全局可见的归属标记,否则任何全局状态的清理动作都会在滚动重启时变成局部灾难。

4.4 仍然留下的窗口(知情保留)

修完三处,还剩几个知情保留的小窗口,写出来是态度也是边界:

  • 证书调度探测没有 CAS,多副本会对同一证书重复做 TLS 探测——只浪费不出错,探测结果幂等回写;
  • 预算告警的 Notified 持久化闩锁在"首穿"时刻有微小窗口(两个副本都读到 false),有未恢复告警去重作二道闸;
  • 内存节流计时器(产物保留清扫、瞬态对象清理、日志快照)每副本各跑一份——清理类幂等无害,日志快照可能重复采集。

它们的共同点是:重复的代价 < 引入协调机制的复杂度。分级处理的另一面是允许已知的小重复存在。


五、方法论:按重复代价分级选机制

把全仓的协调手段收拢成一张决策表:

机制原子性来源适用代价
$share 共享订阅broker 投递语义上行消息分流终端流类连接级会话不适用(改广播+自过滤)
锚点 CASUPDATE ... WHERE anchor=expected定时触发的去重需要一个可比较的锚点列;小心 NULL 锚
占位键两阶段认领条件 UPDATE 换幂等键多目标批量任务多一张记录表
PENDING-only 回填条件 UPDATE 终态化必然重复的回执/对账状态机要有唯一终态入口
条件翻转条件 UPDATE 状态迁移"变化时告警一次"状态列要有明确翻转条件
Redis Lua / INCR单线程原子脚本跨副本共享可变状态引入 Redis 依赖
FOR UPDATE 锁锚行锁串行化临界区check-then-act 且入口收敛锁不跨外呼;方言兼容性需确认

选型的判断顺序:先问重复的必然性(QoS 1 回执是必然,cron 双触发是概率)→ 再问重复的代价(告警双发骚扰人 > 探测双跑浪费)→ 最后看入口是否收敛(单入口一处锁锚;多入口才需要数据层唯一约束兜底)。多数场景根本不需要锁服务——需要的是把"检查"和"行动"压进一条条件 UPDATE 里。


六、验证与结语

多副本行为不好单测,但可以在真机验证:起两个 core 副本(共享 PG/EMQX),配一条每分钟命中的监控规则——告警记录只出现一条;连续并发 POST /admin/v1/builds/{id}/builds:trigger——只有一条历史落 PENDING,其余 409;杀掉其中一个副本,它的在跑构建 2 小时内被孤儿清扫定案,另一个副本的构建全程无感。

GoWind Ops 的这套实践可以浓缩成三句话:

  1. 先分流,后去重——broker 能挡住的重复(MQTT 上行)不劳应用层;
  2. 条件 UPDATE 是最被低估的分布式原语——锚点 CAS、占位认领、PENDING 回填、状态翻转,四种场景一种语法;
  3. 诚实标注剩余窗口——修复了的三处已合入仓库,知情保留的三处写在代码注释里。

觉得有用给个 Star;Issue / PR 一律欢迎。微信交流:yang_lin_bo(备注 go-wind-ops)。