816 words
4 minutes
RL-Scheduler 中的 Master-Worker 机制可用性分析

Master-Worker 可用性评估(修正版)#


一、3 个 Redis Key 的作用#

任务分派时,Master 通过 tryDispatchQueuedTaskToWorker() 依次设置:

┌───────────────────────────────┬────────────────────────────┬──────┬─────────────────┐
│ Key │ 设置时机 │ TTL │ 作用 │
├───────────────────────────────┼────────────────────────────┼──────┼─────────────────┤
│ worker:{id}:task=taskI │ tryPreemptWorker() Lua │ 120s │ Worker │
│ │ 脚本原子写入 │ │ 正在处理哪个任务 │
├───────────────────────────────┼────────────────────────────┼──────┼──────────────────┤
│task:{taskId}:workerId=workerId│ registerTaskOwner() │ 120s │ 任务被哪个 Worker │
│ │ │ │ 认领 │
├───────────────────────────────┼────────────────────────────┼──────┼──────────────────┤
│ worker:{id}:hb │ Worker 心跳时续期 │ 30s │ Worker 是否存活 │
└───────────────────────────────┴────────────────────────────┴──────┴──────────────────┘

二、分派时序(正常流程)#

1. tryPreemptWorker() → 原子写入 worker:{id}:task = taskId (TTL 120s)
2. registerTaskOwner() → 写入 task:{taskId}:workerId = workerId (TTL 120s)
3. dispatchTask() → 通过 Netty 发给 Worker
4. DB status → PENDING 更新为 RUNNING

三、Worker 宕机场景#

Worker 崩溃
→ 不再发送心跳
→ worker:{id}:hb 在 30s 后过期
→ RunningTaskRecovery (每5s) 发现:
- task:{taskId}:workerId 存在 ✓ (任务曾被分派)
- worker:{id}:hb 不存在 → Worker 已死
- worker:{id}:task 存在 → 任务孤立
→ 标记 PENDING + releaseTaskOwner()
→ PendingTaskReconciler (每2s) 发现 idle worker → 重新分发
恢复延迟:30s (心跳TTL) + 5s (扫描间隔) ≈ 35s

四、Master 宕机场景(核心)#

场景 A:Master 在步骤 1-2 之间宕机(未设置 taskOwnerKey)#

宕机前只设置了 worker:{id}:task
DB 状态 = RUNNING(已在 dispatchTask 前更新)
重启后 reconstructWorkerTasksFromRedis():
- 发现 worker:{id}:task = taskId
- 发现 task:{taskId}:workerId 不存在或不匹配
→ 标记 PENDING + 入队

场景 B:Master 在步骤 2-3 之间宕机(已设置 taskOwnerKey 但未收到 Worker 响应)#

宕机前设置了 task:{taskId}:workerId + worker:{id}:task
但 DB status = RUNNING 未变(dispatchTask 发送失败或未处理)
重启后:
- worker:{id}:task 存在
- task:{taskId}:workerId 存在且匹配
- RunningTaskRecovery: hb 存在则跳过(Worker 还在跑或刚完成)
- PendingTaskReconciler: worker:{id}:task 存在则不视为 idle
最终由 Worker 心跳或 checkAndFixStaleRunningTasks() 清理

场景 C:Master 在设置 taskOwnerKey 之前宕机(只设置了 Lua 脚本的 worker:{id}#

宕机前只设置了 worker:{id}:task
task:{taskId}:workerId 不存在
DB status = RUNNING(dispatchTask 前已更新)
重启后 reconstructWorkerTasksFromRedis():
- worker:{id}:task 存在
- task:{taskId}:workerId 不存在或不匹配
→ 标记 PENDING + 入队

五、关键机制总结#

┌─────────────────────────────────┬──────────────────────────────────────────┬────────────────────┐
│ 组件 │ 职责 │ 扫描频率 │
├─────────────────────────────────┼──────────────────────────────────────────┼────────────────────┤
│ RunningTaskRecovery │ 检测 Worker 宕机导致的任务孤立 │ 每 5s │
├─────────────────────────────────┼──────────────────────────────────────────┼────────────────────┤
│ reconstructWorkerTasksFromRedis │ Master 重启时恢复被中断的分派 │ @PostConstruct │
│ │ │ 一次 │
├─────────────────────────────────┼──────────────────────────────────────────┼────────────────────┤
│ PendingTaskReconciler │ 将 PENDING 任务重新入队给 idle worker │ 每 2s │
├─────────────────────────────────┼──────────────────────────────────────────┼────────────────────┤
│ checkAndFixStaleRunningTasks │ 心跳时检测 通知丢失导致的任务卡住 │ 每次心跳 │
├─────────────────────────────────┼──────────────────────────────────────────┼────────────────────┤
│ 心跳续期 │ renewTaskOwnerTtl() 保持 taskOwnerKey │ 每次心跳 │
│ │ 不过期 │ │
└─────────────────────────────────┴──────────────────────────────────────────┴────────────────────┘

六、3 Key 的最终一致性保证#

无论 Worker 宕机还是 Master 宕机:

Worker 宕机:
worker:{id}:hb 过期 → RunningTaskRecovery 检测 → PENDING + 重新入队
Master 宕机(未收到完成通知):
1. 重启时 reconstructWorkerTasksFromRedis() 修正不一致状态
2. 或者 Worker 心跳时 checkAndFixStaleRunningTasks() 发现 taskKey 没了但 ownerKey 还在 → 标记
COMPLETED

七、当前设计的限制#

  1. 故障检测延迟:Worker 宕机后约 35s 才能发现(30s TTL + 5s 扫描)
  2. Master 重启恢复:依赖 @PostConstruct 一次性重建,不保证 Master 运行时实时修复所有不一致
  3. channelInactive() 有 TODO 未完成:MasterHandler.java:59-63 未触发恢复逻辑,但依赖心跳 TTL 兜底
  4. 无真正零宕机切换:Master 宕机期间任务状态更新请求会丢失,依赖 Worker 侧重试或最终一致性
RL-Scheduler 中的 Master-Worker 机制可用性分析
https://sgjki547.top/posts/master-worker-avaliable/
Author
SGJki
Published at
2026-04-11
License
CC BY-NC-SA 4.0