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):
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,其余三处同款):
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):
- 触发时先给全部目标建占位记录 ,幂等键是
pending:{distId}:{batchNo}:{projectId}------批次视图完整可见,前端能提前看到"第二批还没轮到"; - 真正下发前用条件更新认领:
go
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 至少一次),解法是把幂等做进终态写入:
go
// 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):
go
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。
修复三件套(本次一并合入):
- 给监控规则加
last_check_at锚点,检查前 CAS 抢占 ------与构建/脚本/备份完全同款,包括AdvanceCheckAnchorFromNull的 NULL 落锚。CAS 之后同一规则同一周期只有一方执行,探测指令双发、防抖计数双加、告警双建在源头消失。比"Redis SETNX 分钟桶"更优:CAS 比较的是精确的上次锚点值,没有时钟偏移窗口,且不引入新依赖。 - 防抖计数改原子自增 (
IncConsecutiveHits:AddConsecutiveHits(1)生成consecutive_hits = consecutive_hits + 1,返回自增后新值)------即使未来出现绕过调度器的触发路径,读改写也不复存在。 - 恢复通知改由条件更新的行数裁决 :
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 投递语义 | 上行消息分流 | 终端流类连接级会话不适用(改广播+自过滤) |
| 锚点 CAS | UPDATE ... 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 的这套实践可以浓缩成三句话:
- 先分流,后去重------broker 能挡住的重复(MQTT 上行)不劳应用层;
- 条件 UPDATE 是最被低估的分布式原语------锚点 CAS、占位认领、PENDING 回填、状态翻转,四种场景一种语法;
- 诚实标注剩余窗口------修复了的三处已合入仓库,知情保留的三处写在代码注释里。
- 仓库:GitHub github.com/tx7do/go-wi... | Gitee gitee.com/tx7do/go-wi...(MIT,本文涉及的修复已包含在最新代码中)
觉得有用给个 Star;Issue / PR 一律欢迎。微信交流:yang_lin_bo(备注 go-wind-ops)。