从 Leader 选举到故障恢复:全量同步如何决定“哪个 Job、哪台机器来跑”
本文用“全量同步任务”的视角,完整解释一个分布式调度系统:
- 哪个 Job 此刻应该执行?
- 哪台机器负责执行?
- Leader 为什么会切换?会不会脑裂?
- 任务刚发布到某台机器,那台机器就下线怎么办?
- 执行到一半机器挂了怎么办?
文中的代码片段均为伪代码,用于说明逻辑,不是可直接运行的源码。
一、先给结论:系统不是“Leader 自己跑任务”
很多人第一次看到 Leader 选举时,会自然理解成:
Leader 被选出来后,由 Leader 自己执行所有全量同步。
这并不准确。
在这个分布式 cron 模型中,职责分为两层:
| 层次 | 谁负责 | 主要工作 |
|---|---|---|
| 调度层(Scheduling) | 只有 Leader | 判断哪个 Job 到期、将任务发布到 Redis 队列、检测并恢复异常任务 |
| 执行层(Execution) | 任意存活的 Pod | 从自己专属队列消费任务、获取 Job 执行锁、真正执行全量同步 |
因此要理解全量同步,必须同时理解两件事:
- Leader Lock:决定谁能“发布任务”;
- Job Lock:决定同一个 Job 谁能“真正开始执行”。
二、系统中的关键对象
先建立一张“名词地图”。
| 对象 | 作用 | 典型状态 / 内容 |
|---|---|---|
Leader Lock |
分布式选举,确保正常情况下只有一个调度发布者 | Redis key 的 value 是当前 Leader Pod 地址 |
SchedulerElector |
每个 Pod 都运行,用于参与 Leader 选举 | 本地 isLeader、当前 Redis 锁状态 |
schedulerLoop |
定期扫描 Job 的调度循环 | 默认约每分钟执行一次 |
ScheduleConfig |
每个 Job 的调度配置 | 是否启用、cron、是否可恢复、是否自动重跑等 |
TaskPublisher |
Leader 将待执行 Job 封装成任务并投递 | 选择活跃 Subscriber、写入 Redis |
TaskSubscriber |
每个 Pod 上的消费者 | 写心跳、阻塞消费自己的任务队列 |
TaskExecutor |
真正执行任务的组件 | 获取 Job Lock、调用 Job、清理状态 |
scheduled_tasks |
已发布但尚未被拿走的任务记录 | 用于发现“发给死机器后无人消费” |
running_tasks |
已被消费者拿走、正在执行的任务记录 | 用于发现“执行机器中途挂掉” |
三、第一问:怎么判断哪个 Job 要执行?
3.1 每个 Job 都有调度配置
每个被注册的 Job 都有配置,概念上可以理解为:
ScheduleConfig
├── Enabled # 是否启用
├── Cron # 何时运行
├── Resumable # 是否支持断点续跑
├── AutoRerun # 异常后是否允许自动重跑
└── Exclusive # 是否需要独占执行环境
例如一个全量同步 Job 可以被理解为:
Job: merchant-full-sync
Enabled: true
Cron: 每天凌晨 01:00
Resumable: true
AutoRerun: true
3.2 所有 Pod 都会计算“下次应该执行的时间”
这里有一个容易忽略的点:不是只有 Leader 读取 Job 配置。
每一轮调度循环中,所有 Pod 都会做以下事情:
对每一个已注册 Job:
读取最新 ScheduleConfig
根据 Cron 计算 nextExecuteTime
更新本地记录
这样即使某台机器当前是 Follower,它也拥有最新的 Job 调度视图;一旦它成为新的 Leader,不需要从零开始建立调度状态。
3.3 只有 Leader 可以把“到期 Job”变成实际任务
Leader 会对每个 Job 进行类似下面的判断:
如果 Job 未启用:
跳过
如果当前时间还没到 Job 的 nextExecuteTime:
跳过
如果错过执行时间过久:
记录异常,避免无控制地补跑很久以前的任务
如果当前时间已到执行时间:
构造 ScheduledTask
发布到一个可用执行机器的队列
伪代码如下:
for each job:
config = loadLatestConfig(job)
previousNextTime = job.nextExecuteTime
job.nextExecuteTime = calculateNextTime(now, config)
if currentPodIsNotLeader:
continue
if config.disabled:
continue
if now has reached previousNextTime:
publish(job)
所以,“哪个 Job 要执行”的根本判断不是随机的,而是:
Leader 根据 Job 的配置和上一次计算出的调度时间,找出当前已经到期的 Job。
四、第二问:哪台机器来执行?
4.1 每个 Pod 都是 Subscriber
每个 Pod 都启动一个 TaskSubscriber,它做两件事:
- 定期在 Redis 中写入“我还活着”的心跳;
- 阻塞等待自己专属的 Redis 队列。
概念上,Redis 保存一个 Subscriber 注册表:
distributed_scheduler:subscribers (ZSET)
Pod A → 最近心跳时间
Pod B → 最近心跳时间
Pod C → 最近心跳时间
系统使用“最近约 30 秒仍有心跳”作为活跃判定窗口。
4.2 Leader 从活跃机器中 Round Robin 选择目标
当 Leader 判断某个 Job 已到期,会执行:
activeSubscribers = GetActiveSubscribers()
selected = RoundRobin(activeSubscribers)
queue = tasks:<selected-pod-address>
例如:
活跃 Subscriber = [Pod A, Pod B, Pod C]
第 1 个待执行任务 → Pod A
第 2 个待执行任务 → Pod B
第 3 个待执行任务 → Pod C
第 4 个待执行任务 → Pod A
这不是根据 CPU、内存、任务耗时做智能调度,而是一个相对简单的 Round Robin 轮询分配。
4.3 发布是两个 Redis 写入的原子组合
选中目标 Pod 后,Leader 并不是只把任务写到 Queue。它通过 Lua 原子地完成两个动作:
动作 1:把任务推入目标机器的专属 Queue
动作 2:在 scheduled_tasks 记录该任务
伪代码:
atomic publish(task, targetPod):
push task into queue[targetPod]
record task in scheduled_tasks[taskID]
这两个状态分别解决不同问题:
| 状态 | 用途 |
|---|---|
tasks:<pod> |
该 Pod 的 Subscriber 用它实际接收任务 |
scheduled_tasks |
Leader 用它追踪“已发布但没有被消费”的任务 |
五、第三问:任务被分配后,目标机器下线怎么办?
这正是分布式调度中最关键的故障场景。需要按故障发生的时间点拆开看。
场景 A:目标机器在发布前就下线
假设 Pod B 早已下线:
Pod B 停止写心跳
→ 一段时间后不再满足“最近30秒仍有心跳”
→ GetActiveSubscribers() 不再返回 Pod B
→ Leader 不会将新任务分配给 Pod B
如果当前没有任何活跃 Subscriber:
GetActiveSubscribers() = []
→ 无法选择目标 Queue
→ 发布失败并记录错误
→ 不会把任务降级写入一个没有消费者的默认 Queue
这避免了“发布成功但永远没有人消费”的静默丢任务。
场景 B:任务刚发布到 Pod B,Pod B 在消费前下线
这是更有代表性的情况:
T0 Leader 认为 Pod B 活跃
T1 Leader 将 fullSync 发布到 Pod B 的 Queue
T2 同时,在 scheduled_tasks 中记录 taskID
T3 Pod B 下线,来不及 BRPOP 取走任务
T4 任务残留在 Pod B 专属 Queue,并仍显示为 scheduled
T5 Leader 的异常扫描发现该任务 scheduled 太久
T6 Leader 重新选择当前活跃 Pod,例如 Pod C
T7 任务被重新发布给 Pod C
关键恢复机制:ScheduledTooLong
Leader 每轮调度后都会做异常扫描。
对于 scheduled_tasks 中长期没有被消费的任务:
如果任务处于 scheduled 状态超过约 5 分钟:
原因 = ScheduledTooLong
动作 = 重新发布到当前活跃 Subscriber
重要含义:
任务不是因为被放入死机器的队列就永久丢失。
scheduled_tasks是它的“可恢复登记册”。
不过恢复并非立即发生:它需要等待任务超过 scheduled 超时阈值,再等 Leader 下一次调度检查。
场景 C:目标机器已取到任务,执行到一半下线
任务一旦被 Subscriber 消费,会做一次原子状态迁移:
scheduled_tasks
↓
running_tasks
然后执行器会获取一个按 Job 名称命名的 Redis 锁:
jobName lock = fullSync
此时假设执行机器崩溃:
Pod B 已经把任务放进 running_tasks
→ Pod B 开始执行 fullSync
→ Pod B 崩溃
→ 心跳停止
→ jobName lock 的续约停止
→ 锁最终过期
→ running_tasks 仍然显示任务未完成
Leader 检测到这个不一致:
任务还在 running_tasks
但 jobName lock 已不存在
可理解为:
LockMissingButInRunning
此时系统不会无条件马上重跑。只有满足类似以下条件,才会自动重新发布:
AutoRerun = true
并且
该 Job 的历史平均成功执行时间足够短
为什么要加这个限制?
因为一个大型全量同步可能本来就跑很久。如果仅仅因为短时间观察不到锁就立刻重跑,可能导致更严重的重复写入风险。
六、脑裂:为什么 Leader Lock 不是绝对零窗口?
Leader 使用 Redis 的带 TTL 分布式锁。概念上:
尝试成为 Leader:
SET leader-key pod-address EX 60 NX
其中:
| 条件 | 含义 |
|---|---|
NX |
仅在 key 不存在时写入,保证只有一个竞争者成功 |
EX 60 |
锁有过期时间,避免 Pod 崩溃后永不释放 |
value=pod-address |
锁中记录当前 Leader 地址,用于后续验证所有权 |
脑裂窗口的真实时序
| 时间 | Pod A(旧 Leader) | Redis Leader Key | Pod B(新 Leader) |
|---|---|---|---|
| T-1 正常 | isLeader=true |
value=A, TTL>0 |
Follower |
| T0 TTL 到期 | 本地仍可能是 isLeader=true |
key 被 Redis 删除 | Follower |
| T1 B 抢锁 | A 尚未来得及检查 | value=B, TTL=60s |
isLeader=true |
| 窗口内 | A 可能继续发布任务 | Redis 实际认可 B | B 也可能发布任务 |
| T2 A 下次检查 | GetString()=B,A 退位 |
value=B |
继续是 Leader |
因此更严谨的表述是:
GetString()能发现 Leader 所有权已改变,并让旧 Leader 在下一次检查时退位;它缩短并收敛脑裂窗口,但不能使脑裂窗口严格为零。
默认选举检查间隔约为 5 秒,所以在 Pod 没有长时间卡顿的前提下,窗口通常在下一次检查后收敛。
七、为什么短暂脑裂不会轻易让同一个 Full Sync 并发执行?
脑裂窗口中的主要风险是:
旧 Leader 发布 fullSync
新 Leader 也发布 fullSync
→ 同一个 Job 出现重复投递
但真正执行前,多个消费者会竞争 Job Lock:
Consumer A: Lock("fullSync") → 成功 → 执行
Consumer B: Lock("fullSync") → 失败 → 不并发执行
这就是两层锁的意义:
| 锁 | 保护的对象 | 主要防护 |
|---|---|---|
| Leader Lock | 调度发布权 | 正常情况下避免多 Pod 同时发布任务 |
| Job Lock | 某个 Job 的业务执行权 | 避免多个消费者同时执行同一个 Job |
但也要注意:这更接近 at-least-once(至少一次)投递 / 恢复 的分布式语义,而不是严格的 exactly-once。
因此全量同步 Job 本身仍应尽量具备:
- 幂等性;
- 可中断 / 可取消能力;
- 断点续传能力;
- 可恢复的状态机;
- 对重复触发的容忍能力。
八、一个完整例子:凌晨 1 点的 merchant full sync
假设配置:
Job: merchant-full-sync
Cron: 每天凌晨 01:00
Enabled: true
Resumable: true
AutoRerun: true
完整路径如下:
00:59
所有 Pod 读取配置、更新 nextExecuteTime=01:00
01:00
当前 Leader 发现 now >= nextExecuteTime
→ 构造 ScheduledTask(job=merchant-full-sync)
01:00:01
Leader 查询活跃 Subscriber=[Pod A, Pod B, Pod C]
→ Round Robin 选中 Pod B
→ 原子写入 tasks:PodB 和 scheduled_tasks
01:00:02
Pod B 的 Subscriber BRPOP 收到任务
→ 原子地从 scheduled_tasks 移到 running_tasks
→ 尝试获取 Lock("merchant-full-sync")
01:00:03
Pod B 获取锁成功
→ 执行 FullSyncStateMachine
→ 扫描源数据、写入目标存储、校验、切换状态
执行完成
→ 释放 Job Lock
→ 清理 running_tasks
→ 写入 execution log
如果 Pod B 在 01:00:01 后下线但还没消费:
任务留在 scheduled_tasks
→ 超时后当前 Leader 判定 ScheduledTooLong
→ 重新从存活 Pod 中选择目标
→ 例如发布给 Pod C
如果 Pod B 已开始执行但在中途下线:
任务留在 running_tasks
→ Job Lock 过期
→ Leader 检测到 LockMissingButInRunning
→ 满足 AutoRerun 条件时重新发布
九、排障时该看什么?
9.1 想知道当前谁是 Leader
查看 Redis leader key:
distributed_scheduler:leader
它的 value 是当前 Leader 的实例地址。
9.2 想知道任务分给了谁
查看任务数据中的:
SubscriberQueue
它指向目标机器的专属队列:
{distributed_scheduler}:tasks:<pod-address>
9.3 想知道任务卡在哪个阶段
| 位置 | 代表含义 |
|---|---|
| 目标 Pod Queue | 已发布,但还未被消费者拿走 |
scheduled_tasks |
已发布、等待消费;长期存在需要关注 ScheduledTooLong |
running_tasks |
已经被消费,正在执行或执行机异常退出 |
| execution log | 已完成、失败或最终状态 |
9.4 常见症状与解释
| 症状 | 可能原因 | 重点检查 |
|---|---|---|
| Job 到点未执行 | 没有 Leader、Job 未启用、没有活跃 Subscriber | leader key、ScheduleConfig、Subscriber ZSET |
| 任务发给下线 Pod | 发布时它仍在活跃心跳窗口内 | scheduled_tasks 是否超过超时、后续是否已 republish |
scheduled_tasks 长期堆积 |
Subscriber 没消费或消费流程异常 | 目标 Queue、目标 Pod、BRPOP / Subscriber 日志 |
running_tasks 长期存在 |
执行很慢、Job 卡住、执行机崩溃 | jobName lock、AutoRerun、execution log |
| 同一个 Job 被重复发布 | Leader 切换 / 脑裂窗口 / 异常重发 | Leader 日志、taskID、Job Lock 是否正常 |
十、最终心智模型
可以把整个系统理解为三层防线:
最简短地总结:
Leader 根据 cron 判断哪个 Job 到期;从最近仍有心跳的 Pod 中轮询选择执行机器;任务状态写入 Redis 以支持故障恢复;真正执行前再竞争 Job Lock,避免同一个全量同步并发运行。
本文基于分布式调度、Redis Leader Lock、TaskPublisher、TaskSubscriber、TaskExecutor 与异常任务重发机制整理。所有伪代码仅用于帮助理解机制。

浙公网安备 33010602011771号