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

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):

  1. 触发时先给全部目标建占位记录 ,幂等键是 pending:{distId}:{batchNo}:{projectId}------批次视图完整可见,前端能提前看到"第二批还没轮到";
  2. 真正下发前用条件更新认领:
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。

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

  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 投递语义 上行消息分流 终端流类连接级会话不适用(改广播+自过滤)
锚点 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 的这套实践可以浓缩成三句话:

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

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

相关推荐
迷途小羔羊1 小时前
环境搭建
前端·javascript·后端
孔令飞1 小时前
OneX Harness AI Coding 工具介绍
云原生·go·ai编程
SimonKing1 小时前
DeepSeek 新模型免费白嫖,夯夯夯,赶紧用起来
java·后端·程序员
打工仔折腾 AI1 小时前
介入式脑机接口的跨尺度解码与闭环调控:从算法落地到后端工程实践
人工智能·后端·python·深度学习·算法·ai agent 实战
喵个咪1 小时前
GoWind Ops 深度拆解:纯出站 Agent + MQTT 信令,把「管机器、跑项目、发脚本、构建分发」做扎实
后端·go·自动化运维
鸠摩智首席音效师2 小时前
如何清理 ASP.NET 临时文件 ?
后端·asp.net
铁皮饭盒2 小时前
刷到 FDE 岗位那一刻,我还以为前端又活了
前端·后端
利威尔xu2 小时前
Go GMP 调度器 10 个高频追问:3 个线上故障让 P99 从 80ms 飙到 2s、10 万 goroutine 泄漏
开发语言·后端·面试·性能优化·golang