Loading

从 Leader 选举到故障恢复:全量同步如何决定“哪个 Job、哪台机器来跑”

本文用“全量同步任务”的视角,完整解释一个分布式调度系统:

  • 哪个 Job 此刻应该执行?
  • 哪台机器负责执行?
  • Leader 为什么会切换?会不会脑裂?
  • 任务刚发布到某台机器,那台机器就下线怎么办?
  • 执行到一半机器挂了怎么办?

文中的代码片段均为伪代码,用于说明逻辑,不是可直接运行的源码。


一、先给结论:系统不是“Leader 自己跑任务”

很多人第一次看到 Leader 选举时,会自然理解成:

Leader 被选出来后,由 Leader 自己执行所有全量同步。

这并不准确。

在这个分布式 cron 模型中,职责分为两层:

层次 谁负责 主要工作
调度层(Scheduling) 只有 Leader 判断哪个 Job 到期、将任务发布到 Redis 队列、检测并恢复异常任务
执行层(Execution) 任意存活的 Pod 从自己专属队列消费任务、获取 Job 执行锁、真正执行全量同步
flowchart TB L[👑 Leader Pod<br/>调度者] -->|判断哪些 Job 到期| P[📦 TaskPublisher] P -->|发布到某个存活 Pod 的专属队列| R[(🗄️ Redis)] R -->|BRPOP 消费任务| A[Pod A: TaskSubscriber] R -->|BRPOP 消费任务| B[Pod B: TaskSubscriber] R -->|BRPOP 消费任务| C[Pod C: TaskSubscriber] A --> E[🔐 Job 执行锁] B --> E C --> E E --> J[⚙️ Full Sync Job 执行] classDef leader fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; classDef redis fill:#fff3e0,stroke:#ff9800,color:#e65100; classDef pod fill:#dae8fc,stroke:#4a90e2,color:#1565c0; classDef lock fill:#fff9c4,stroke:#fbc02d,color:#795548; classDef job fill:#f3e5f5,stroke:#9c27b0,color:#6a1b9a; class L leader; class R redis; class A,B,C pod; class E lock; class J job;

因此要理解全量同步,必须同时理解两件事:

  1. Leader Lock:决定谁能“发布任务”;
  2. 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,不需要从零开始建立调度状态。

flowchart LR S[每分钟 schedulerLoop] --> C[读取每个 Job 的最新配置] C --> N[根据 cron 计算 nextExecuteTime] N --> U[所有 Pod 更新本地调度状态] U --> Q{当前 Pod 是 Leader?} Q -- 否 --> W[本轮不发布任务] Q -- 是 --> D[继续判断哪些 Job 到期] classDef normal fill:#dae8fc,stroke:#4a90e2,color:#1565c0; classDef leader fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; classDef muted fill:#f5f5f5,stroke:#b0bec5,color:#607d8b; class S,C,N,U normal; class D leader; class W muted;

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,它做两件事:

  1. 定期在 Redis 中写入“我还活着”的心跳;
  2. 阻塞等待自己专属的 Redis 队列。

概念上,Redis 保存一个 Subscriber 注册表:

distributed_scheduler:subscribers  (ZSET)

Pod A → 最近心跳时间
Pod B → 最近心跳时间
Pod C → 最近心跳时间

系统使用“最近约 30 秒仍有心跳”作为活跃判定窗口。

sequenceDiagram participant A as Pod A Subscriber participant B as Pod B Subscriber participant C as Pod C Subscriber participant R as Redis Subscriber Registry A->>R: 每10秒更新心跳 (ZADD) B->>R: 每10秒更新心跳 (ZADD) C->>R: 每10秒更新心跳 (ZADD) Note over R: 仅最近约30秒仍有心跳的 Pod<br/>会被视为 active subscriber

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 轮询分配

flowchart LR L[👑 Leader 发现 Job 到期] --> G[GetActiveSubscribers] G --> Z[(Redis ZSET<br/>最近30秒心跳)] Z --> R[Round Robin 选择目标] R --> A[Pod A Queue] R --> B[Pod B Queue] R --> C[Pod C Queue] classDef leader fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; classDef redis fill:#fff3e0,stroke:#ff9800,color:#e65100; classDef pod fill:#dae8fc,stroke:#4a90e2,color:#1565c0; class L leader; class G,R leader; class Z redis; class A,B,C pod;

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 用它追踪“已发布但没有被消费”的任务

五、第三问:任务被分配后,目标机器下线怎么办?

这正是分布式调度中最关键的故障场景。需要按故障发生的时间点拆开看。

flowchart LR P[Leader 发布任务] --> S[scheduled_tasks] P --> Q[目标 Pod 专属 Queue] Q --> C[目标 Pod BRPOP 消费] C --> R[running_tasks] R --> X[Job 执行] classDef publish fill:#fff3e0,stroke:#ff9800,color:#e65100; classDef state fill:#f3e5f5,stroke:#9c27b0,color:#6a1b9a; classDef exec fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; class P,Q publish; class S,R state; class C,X exec;

场景 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
sequenceDiagram participant L as 当前 Leader participant R as Redis participant B as Pod B(已下线) participant C as Pod C(存活) L->>R: 发布 task 到 tasks:B + scheduled_tasks Note over B: B 下线,未消费任务 Note over R: task 仍停留 scheduled_tasks L->>R: 定期检查异常任务 R-->>L: task scheduled 超过超时阈值 L->>R: 从活跃 Subscriber 中重新选择 C L->>R: 发布 task 到 tasks:C + scheduled_tasks C->>R: BRPOP tasks:C C->>C: 开始执行任务

重要含义:

任务不是因为被放入死机器的队列就永久丢失。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") → 失败 → 不并发执行
flowchart TB A[旧 Leader 发布 fullSync] --> QA[任务队列] B[新 Leader 发布 fullSync] --> QB[任务队列] QA --> CA[Consumer A] QB --> CB[Consumer B] CA --> L{Lock: fullSync} CB --> L L -->|只有一个成功| E[执行 Full Sync] L -->|另一个失败| S[不并发执行 / 等待恢复机制] classDef leader fill:#ffebee,stroke:#d84315,color:#b71c1c; classDef queue fill:#fff3e0,stroke:#ff9800,color:#e65100; classDef lock fill:#fff9c4,stroke:#fbc02d,color:#795548; classDef execute fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; class A,B leader; class QA,QB queue; class L lock; class E,S execute;

这就是两层锁的意义:

保护的对象 主要防护
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 是否正常

十、最终心智模型

可以把整个系统理解为三层防线:

flowchart TB A[第一层:Leader Election<br/>谁能正常发布任务?] --> B[第二层:Task State<br/>任务处于 scheduled 还是 running?] B --> C[第三层:Job Lock<br/>谁能真正执行同一个 Job?] C --> D[业务层:幂等 / 可恢复的 Full Sync] classDef layer1 fill:#dae8fc,stroke:#4a90e2,color:#1565c0; classDef layer2 fill:#fff3e0,stroke:#ff9800,color:#e65100; classDef layer3 fill:#fff9c4,stroke:#fbc02d,color:#795548; classDef business fill:#e8f5e9,stroke:#4caf50,color:#1b5e20; class A layer1; class B layer2; class C layer3; class D business;

最简短地总结:

Leader 根据 cron 判断哪个 Job 到期;从最近仍有心跳的 Pod 中轮询选择执行机器;任务状态写入 Redis 以支持故障恢复;真正执行前再竞争 Job Lock,避免同一个全量同步并发运行。


本文基于分布式调度、Redis Leader Lock、TaskPublisher、TaskSubscriber、TaskExecutor 与异常任务重发机制整理。所有伪代码仅用于帮助理解机制。

posted @ 2026-08-16 19:31  技术漫游  阅读(3)  评论(0)    收藏  举报