系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理
- 唯一 ID 中间件 Leaf:号段模式与雪花模式
- RocketMQ 的事务消息与延迟消息
- 分布式任务调度:同一时刻只跑一份(本篇)
多实例任务的防重和分片需求
(分布式锁与唯一 ID)提到云盘定时任务在多实例部署后跑两遍的问题。当时的解法是在任务入口获取分布式锁:获取锁的实例执行,其余实例跳过。统计、对账这类任务通过这把锁减少重复执行,告警消失。那篇讨论的是「防重」,结论是锁只能减少同时段重复,不保证恰好一次,任务本身仍要幂等。
锁解决「同一时刻只跑一份」,但任务数量和数据量增加后,会出现两个问题。任务数量增加后,每个任务各自配置一把锁,锁的获取、续期、释放和监控散落在业务代码中,缺少统一视图;任务是否执行、是否超时、上次是否失败,都需要由业务分别埋点和查日志。单个任务的数据量增加后,一个实例串行执行可能需要几个小时。将大任务拆成多份并行执行可以缩短时间,但当业务侧分别用锁实现分片数量、实例与分片的对应关系以及宕机后的接管逻辑时,很容易出错。
调度中间件处理这两个问题:防重,即同一时刻只运行一份任务;分片,即将大任务拆成多份并行执行。2019 年补调度中间件资料时,我将主流方案归纳为三种架构:Quartz 集群模式用数据库行锁选择执行节点,XXL-JOB 用中心化调度器统一派发,ElasticJob 用 ZooKeeper 协调实例。三者处理防重和分片的方式不同,依赖条件也不同。
资料范围。 本篇涉及的调度方案我没有在生产环境深度使用过。下面三种架构的拆解以官方文档和开源源码阅读为主,配合本地启动服务验证关键行为,未在生产环境确认。实际使用过的调度方案待确认(当时可能用的是公司内部调度平台而非开源组件);使用场景部分基于学习所得的理解。
Quartz 集群模式通过数据库行锁防重
Quartz 是 Java 调度框架,2.x 的集群模式用于多实例防重。集群模式下,多个 Quartz 调度节点共享同一套数据库表,每个节点都能看到所有 trigger 和 job 的定义。trigger 到点时,所有节点都可能尝试触发它,但只有一个节点能真正执行。
防重由 QRTZ_LOCKS 表的行锁实现。这张表每个锁名一行,集群模式用到两类锁:TRIGGER_ACCESS 保护 trigger 的获取与状态变更,STATE_ACCESS 保护 job 数据的读写。节点在触发 trigger 前,先对 TRIGGER_ACCESS 这一行做 SELECT ... FOR UPDATE 加行锁,拿到锁的节点查出到期的 trigger、把状态改为已获取,随后提交事务释放行锁,真正执行 job 发生在行锁释放之后。其他节点在拿锁时阻塞,等拿到锁后再查 trigger,此时到期 trigger 的状态已被改为已获取,不会再被重复触发。行锁覆盖 trigger 的获取与状态变更,不覆盖 job 执行;否则整个集群的 job 执行会被串行化。
数据库行锁决定同一时刻由哪个节点执行。Quartz 本来就需要 DB 保存 job 和 trigger;集群模式让多个节点共享这份元数据,因此不需要引入额外组件。所有节点会在同一时刻竞争同一行 TRIGGER_ACCESS 锁,节点数增加时,获取锁的串行过程可能成为瓶颈。trigger 数量多且触发频繁时,共享 DB 的锁等待和事务开销会上升。Quartz 集群模式的节点数通常控制在个位数,继续增加节点会加剧锁竞争。
Quartz 集群模式只提供防重,不提供分片项分配机制。任务需要拆分并行时,业务侧要定义多个 trigger,或在 job 中按实例数切分数据。
XXL-JOB 由调度中心统一派发
XXL-JOB 2.x 把调度和执行拆成两个角色:调度中心(admin)和执行器(executor)。调度中心持有任务定义和 cron 表达式,到点时决定触发哪个任务、分给哪个执行器;执行器注册到调度中心,被动接收调度中心的 HTTP 触发,执行完回调结果。
调度中心集中决定防重。一个任务到点时,调度中心只向一个执行器发出触发请求(分片广播模式除外),执行器之间不需要自行获取锁。调度中心决定执行器,执行器接收请求后执行任务。调度中心统一处理防重决策,执行器不参与锁竞争。
分片广播是 XXL-JOB 对分片的实现。任务配置为分片广播后,调度中心到点时向所有在线执行器各发一次触发请求,每个执行器收到的请求里带 shardIndex(当前分片序号)和 shardTotal(总分片数)。执行器按分片序号处理对应的数据分片:
// XXL-JOB 分片广播 handler(最小示意,脱敏)
@XxlJob("demoShardJobHandler")
public void demoShardJobHandler() {
int shardIndex = XxlJobHelper.getShardIndex(); // 当前执行器的分片序号
int shardTotal = XxlJobHelper.getShardTotal(); // 总分片数
// 按分片序号切分数据:每个执行器只处理自己那一份
List<Item> items = queryByShard(shardIndex, shardTotal);
for (Item item : items) {
process(item);
}
XxlJobHelper.handleSuccess();
}业务侧拿到分片参数后决定数据切分方式。常见的方式是按取模:where id % shardTotal = shardIndex。调度中心只广播分片参数,不定义切分逻辑。
调度中心是中心化依赖。调度中心宕机后,执行器不能自主触发任务,所有任务停止调度。XXL-JOB 通过多节点部署调度中心应对这一问题:多个调度中心实例共享同一套 DB,通过 DB 行锁保证同一时刻只有一个调度节点触发任务。该行锁机制与 Quartz 集群模式相同;锁竞争只发生在调度中心节点之间,执行器不参与锁竞争。调度中心的高可用仍依赖 DB,DB 的可用性和锁吞吐限制了这套架构的可用性与调度能力。
ElasticJob 通过 ZK 选主并分配分片
ElasticJob 2.x(当时还叫 Elastic-Job,当当开源)没有独立的调度中心角色。每个 ElasticJob 实例既是调度者又是执行者,实例之间通过 ZooKeeper 协调任务调度和分片分配。
防重和分片依赖 ZK。一个任务配置 N 个分片项,ElasticJob 实例启动后注册到 ZK,实例之间选出一个主节点(leader)。主节点除执行自身分片外,还负责将 N 个分片项分配给当前在线的实例。例如,4 个分片项、2 个实例时,主节点分配实例 A 运行分片 0 和 2,实例 B 运行分片 1 和 3。分配结果写入 ZK,所有实例监听自己被分配到的分片项,到点后各自触发对应分片。ZK 上的分片分配状态保证每个分片项在同一时刻只分配给一个实例。
选主依赖 ZK 临时节点。主节点在 ZK 上创建临时节点表示主身份;主节点进程宕机后,临时节点随会话过期消失,其余实例感知到后发起新一轮选主。失效转移(failover)分两层:实例宕机后,其在 ZK 上的分片项归属消失,主节点检测到实例变化后重新分配分片;宕机时正在执行的分片,由存活实例根据 ZK 上的失效转移标记接管并从头执行。ElasticJob 提供分片分配和失效转移,业务侧不需要自行实现这两项机制。
ElasticJob 引入 ZK 作为协调服务,运维上增加了一个需要高可用部署的组件。ZK 会话超时会将实例判定为离线并触发分片重分配。网络波动造成误判时,分片可能在实例间频繁迁移,迁移期间可能重复执行或漏执行。主节点切换期间,分片分配也可能短暂不一致。ElasticJob 的协调职责由 ZK 承担,架构中没有独立的调度中心。ZK 如何保证一致性以及为什么能承担这个角色,见ZooKeeper/etcd:小数据强一致的协调服务。
三种架构的协调方式对照见下图。
三种架构的适用场景
可以根据任务规模和分片要求选择三种架构。
任务数量少、单任务数据量不大且已有 DB 可复用时,可使用 Quartz 集群模式。它需要增加几张表并配置集群模式,防重依赖 DB 行锁,不需要额外组件。节点数和 trigger 密度增加后,锁竞争会限制扩容。
任务数量多、需要统一调度视图和分片广播,并且可以部署高可用调度中心时,可使用 XXL-JOB。调度中心集中提供任务配置、执行日志和失败告警,便于统一查看。分片广播向各执行器提供分片参数,大任务可以据此并行处理;调度中心必须高可用部署。
任务需要动态分片和失效转移,且已有 ZK 或能够承担 ZK 运维时,可使用 ElasticJob。它提供分片项分配和 failover;实例扩缩容时会自动重新分配分片,不需要人工调整。ZK 的运维复杂度,以及会话超时引发的分片迁移,是这项选择的成本。
任务少且没有分片需求时,可以用分布式锁加 @Scheduled。分布式锁与唯一 ID的「入口获取锁,获取后执行」方案不需要引入调度中间件。任务数量增加、需要分片或需要统一运维视图时,再评估调度中间件。
边界清单
- 重复执行仍需通过幂等处理。 机器时钟偏差可能导致 trigger 提前或延后触发;调度中心重试或 failover 转移时分片可能被重复执行;节点宕机恢复后已执行的任务可能被补跑。任务本身必须幂等。调度任务和消息语义:按 at-least-once 设计业务代码中的消息消费,都需要通过幂等处理重复执行。调度系统的防重和分片降低重复执行的概率,不能消除重复执行。
- 业务需要确定 misfire 策略。 任务错过计划执行时间(实例宕机、调度中心停止服务、线程池满)后的处理方式叫 misfire 策略:补跑错过的次数、只跑一次,或放弃。Quartz 有内置的 misfire 策略配置(
MISFIRE_INSTRUCTION_FIRE_NOW等),XXL-JOB 和 ElasticJob 的调度错过处理各有不同。补跑可能导致重复执行,放弃可能导致数据漏处理;应按任务对时效性和重复的容忍度选择。调度框架提供机制,业务方需要定义策略。 - failover 后从头执行分片。 实例宕机后,正在执行的分片会被转移给其他实例重新执行,不从上次中断的位置继续。任务如果不可重入,failover 会导致数据重复处理。分片任务的设计要假设「同一个分片可能被从头执行多次」。
- XXL-JOB 和 ElasticJob 依赖调度中心或 ZK 的可用性。 XXL-JOB 调度中心宕机时,所有任务停止调度;ElasticJob 的 ZK 集群不可用时,选主和分片分配无法进行。调度中间件自身的可用性需要单独保障,可通过多节点部署加共享 DB(XXL-JOB)或 ZK 集群(ElasticJob)实现。
- 分片项数量限制并行度和扩容上限。 分片项数量是任务并行度的上限:4 个分片项最多 4 个实例同时运行。分片项过少时,增加实例不能提高并行度;分片项过多时,每个分片数据量太小,调度开销占比会上升。分片项数量通常按预估数据量和单分片处理时长反推,扩容时可能需要调整分片项数量,而 ElasticJob 的分片项调整会触发全量重分配。
- 执行时间超过调度间隔时需要设置阻塞策略。 cron 设为每 5 分钟一次而任务实际运行 8 分钟时,下一轮触发时上一轮尚未完成。Quartz 集群通过
@DisallowConcurrentExecution注解阻止并发,XXL-JOB 有调度阻塞策略(丢弃后续、覆盖之前、串行),ElasticJob 默认不允许同一分片并发。阻塞策略决定任务是丢弃执行还是延迟执行,应按业务容忍度配置。
理解有限的部分
Quartz 的集群表结构细节(QRTZ_FIRED_TRIGGERS、QRTZ_SCHEDULER_STATE 的具体字段与状态机)我当时只核对到文档层,没有在源码层核对节点故障恢复时这些表如何被清理。XXL-JOB 调度中心多节点去重的 DB 行锁具体实现、ElasticJob 的 ZK 分片分配算法的具体策略也只查到文档描述。本地启动服务验证了单实例触发和分片广播的基本行为,多实例 failover 的边界场景没有逐一覆盖。实际生产使用的调度方案待确认;若后续确认是公司内部调度平台,其内部实现不在本篇范围。
参考资料
- Quartz 官方文档(集群配置、
QRTZ_LOCKS表与TRIGGER_ACCESS/STATE_ACCESS锁,2.x 版本) - XXL-JOB 官方文档(架构设计、分片广播、调度中心高可用,2.x 版本)
- ElasticJob 官方文档(ZK 选主、分片分配、failover,2.x 版本)
- 分布式锁与唯一 ID(2016 系列第 12 篇,定时任务加锁防重,本篇引用不复述)
- 消息语义:按 at-least-once 设计业务代码(任务幂等与消费幂等都用于处理重复执行)
- ZooKeeper/etcd:小数据强一致的协调服务(ZK 选主与会话机制,ElasticJob 协调层衔接)
