调度器一挂,全平台定时作业停摆,所以单副本变多副本是绕不过去的一步。而说到「调度器 HA」,多数人的第一反应是条件反射式的:上 ZooKeeper(或 etcd)选个主,leader 干活,follower 待命。「我的数据空间」(datastudiohappy.cn)的作业调度器(cron 触发 DAG 工作流编排)走了另一条路:一行 ZK 代码都没有,整个多副本方案只用两样东西——DB 乐观锁 CAS 保正确性,slot 分片提吞吐。这篇讲清楚它是怎么闭环的。一、多副本到底要保证什么先把「HA」拆成四条可验证的性质:同一次触发,全局只发生一次——双跑轻则浪费资源,重则数据写重(「删旧分区再写入」的 ETL 并发跑两遍);任一副本挂掉,任务不丢不僵——在跑的被别人接管收尾,宕机漏掉的触发按策略补;扩缩容自动再均衡——加副本活儿自动摊过去,减副本活儿自动被捡走,不靠人肉改分片;成员变化的过渡期,以上三条不破——「现在有几个副本」的视图短暂不一致时,系统不出错。第 1 条和第 2 条天然有张力:要「不丢」就得允许接管,要「不双跑」就得防接管和原执行者撞车。怎么解这个张力,是所有方案的分水岭。二、四条路线,一张表说完路线典型代表主要代价ZK/etcd 选主大量自研调度器新增重量级组件且它自己也要 HA;follower 纯热备不出力;脑裂仍要 fencing 补分布式锁Redis/ZK 锁租期两难:短了一次 GC 停顿就双跑,长了故障后干等;多一个故障域DB 悲观行锁Quartz 集群模式所有节点排队过同一把FOR UPDATE,触发频率一高锁竞争即瓶颈DB 乐观锁 CASAirflow 多 scheduler无等待、无租期难题、依赖零新增;需要自己把每个动作设计成条件更新先例值得注意:Airflow 的多 scheduler 完全靠元数据库协调,官方明确不引 Raft/Paxos;DolphinScheduler 的多 master 靠命令表去重 slot 取模分片,注册中心甚至提供纯 JDBC 实现。真正在大规模生产里跑的调度器,协调层早有「往 DB 收敛」的传统,ZK 从来不是标配。选 CAS 的理由说穿了很朴素:调度器的一切状态——作业定义、实例、任务——本来就在 MySQL 里,「谁在跑」的权威在 DB,「谁来跑」的裁决权放 DB 就是最短路径。依赖清单每多一项,可用性是乘法不是加法。并且选主保证的只是「谁干活」,CAS 直接保证「每件事只被干一次」——后者才是真正要的性质,粒度更细,还顺便让 N 个副本能同时干 N 件不同的事。三、四个核心机制触发去重:对「下次触发时间」做 CAS。cron 不放内存定时器(多副本天然 N 份、重启即丢),而是一张触发表 近端扫描:每个副本每几秒扫「即将到期」的行(走索引,代价与作业总量脱钩),抢占是一条带条件的更新:UPDATE触发表SET下次触发时间:按cron算的下一次WHEREid:idAND下次触发时间:本次看到的值;影响行数为 1 即抢到本次触发,为 0 说明别的副本已把时间推进,直接跳过——不加锁、不等待,MySQL 的行级原子性就是裁判。宕机漏掉的触发顺便解决:扫描发现触发时刻已超过宽限期,按 misfire 策略补跑(默认只补最近一次),基线就是表里持久化的触发时间本身。执行去重:状态机 CAS outbox。触发之后任务经消息队列派发,而 MQ 是 at-least-once 的,消息可能重复——去重不指望 MQ,在消费端用同一招:UPDATE任务表SET状态执行中WHEREid:idAND状态已派发;消息重复来十次,十个消费者做同一条 CAS,只有一个翻转成功,其余静默丢弃。「认领」与「发消息」放在同一个 DB 事务里(transactional outbox 模式),杜绝「已认领但消息漏发」的孤儿任务。两层 CAS 各管一段:触发一次靠前者,投递至少一次但执行恰好一次靠后者。僵尸接管:心跳租约 reaper。每个在跑任务记「属主进程 租约到期时间」,属主活着就周期续租(25 秒一续、租约 90 秒,连续错过三次心跳才判死,长作业不会因「跑得久」被误杀);进程死了续租自然停止,任一存活副本上的常驻 reaper 扫「非终态 租约过期」的任务做收尾。两条纪律直接融在这条路径里:收尾的副作用(判失败、杀残留执行体、释放额度、告警)一律挂在 CAS 认领之后,多个副本同时扫到同一具僵尸,只有 CAS 赢家真正执行,否则告警发两遍就是新的双跑;kill 以外部权威收敛——执行体都在 K8s 里,按标签删 pod,任何副本都能杀,不依赖某个副本内存里的执行句柄。slot 分片:从「不出错」到「不白干」。到此正确性已闭环,但 N 个副本扫同一批行、N-1 个在 CAS 上扑空,吞吐不随副本数涨。解法是 DolphinScheduler 式取模分片:成员发现就一张 MySQL 心跳表,每副本周期性 upsert 自己一行,活成员按节点 ID 排序定出自己的 slot,扫描时只捞「id 取模等于自己 slot」的那片。稳态下零重复扫描、零 CAS 争抢,吞吐近似线性;扩缩容是「无状态重算」,十秒级自动再均衡,没有任何迁移动作。成员视图短暂不一致怎么办?不怎么办,CAS 兜底:分片重叠,最坏多一次扑空;分片有缝,视图偏小的副本覆盖面更宽,漏不掉。这是整套架构最关键的性质——分片只是吞吐优化,永远不是正确性边界。心跳表哪怕整个被清空、滞后、彻底不一致,系统也只退化成「全扫 CAS」:慢一点,绝不出错。唯一的悲观锁留给了资源配额准入(共享计数器的「读-算-写」,CAS 不适合),配套一条容易踩空的纪律:锁定读必须是事务里的第一条读,否则 REPEATABLE READ 的快照会让锁内读到过期用量,并发下配额直接超卖。多副本之下,DAG 工作流照常编排、运维侧照常可观测,这些能力在「我的数据空间」(产品介绍)里长这样:四、适用边界需要毫秒级成员感知(在线服务路由级的故障切换)、需要真 fencing token(被抢占者持有的资源无法由 K8s 这类外部权威收敛)、协调事件高频到关系库扛不住,或跨区域部署——这四种场景该老实上 ZK/etcd。反之,已有一个大家都写的关系库、协调决策是秒级低频、执行体可由外部权威收敛,就不需要为 HA 引入任何新组件。五、三句话总结选主解决「谁干活」,CAS 直接解决「每件事只干一次」——后者才是目标性质;正确性与吞吐拆成两层:CAS 保正确,分片只管加速,任何一层糙一点系统都不出错;依赖清单每多一项,可用性是乘法——正确性只押在系统里本来就必须活着的那个组件上。这套调度器是「我的数据空间」的一部分——一套可私有化部署的数据平台,支持 OEM 合作。产品介绍:https://datastudiohappy.cn/。