分布式调度系统设计:Quartz 的数据库锁 vs Elastic-Job 的分片机制
问题:定时任务到了分布式环境,怎么保证不重复执行?
单机时代,ScheduledExecutorService 或 @Scheduled 注解就能搞定定时任务。部署到多节点后,业务逻辑变成了灾难——每天凌晨 3 点跑的数据统计任务,每个节点都跑一遍,用户收到三条重复报表。更严重的是,如果任务是扣减库存、发送账单,重复执行就是生产事故。
分布式定时任务要解决三个核心问题:
- 任务不重复执行——多节点之间只有一个节点真正执行
- 任务均衡分布——不要让一台机器扛全部,其他机器闲着
- 故障转移——执行节点挂了,任务要能被其他节点接管
主流方案:Quartz 的数据库锁
Quartz 是最经典的 Java 定时任务框架,它做分布式的方式很朴素:所有节点共享一个数据库。
每个节点启动时都注册到同一个 Quartz 调度器集群,数据库中存储了所有 Job 的元数据和触发信息。当某个 Job 的触发时间到了,所有节点都会尝试执行:
SELECT * FROM QRTZ_LOCKS WHERE LOCK_NAME = 'TRIGGER_ACCESS' FOR UPDATE谁先拿到这行锁,谁就执行这个 Job,其他节点拿不到锁就直接跳过。
时序图
时间轴:
node-1: ┌───── 触发时间到 ─────┐
│ SELECT ... FOR UPDATE │ → 拿到锁 → 执行 Job → 释放锁
└──────────────────────┘
node-2: ┌───── 触发时间到 ─────┐
│ SELECT ... FOR UPDATE │ → 锁被 node-1 持有,阻塞等待 → 超时跳过
└──────────────────────┘
node-3: ┌───── 触发时间到 ─────┐
│ SELECT ... FOR UPDATE │ → 锁被 node-1 持有,阻塞等待 → 超时跳过
└──────────────────────┘谁先抢到 QRTZ_LOCKS 表的行锁,谁执行。其他节点在 innodb_lock_wait_timeout(默认 50 秒)内反复尝试,超时后放弃这一轮。
优点
- 实现简单,零额外组件——只要有个数据库,Quartz 就能跑起来
- 成熟稳定——Quartz 在单机调度领域打磨了十几年,文档齐全,社区活跃
- 支持丰富的时间表达式——Cron 表达式、SimpleTrigger、CalendarIntervalTrigger 都支持
致命缺点
- 数据库就是单点瓶颈——所有节点抢同一张表的行锁,高并发下锁竞争激烈。实测数据:10 个节点、50 个任务,数据库锁竞争导致调度延迟从 50ms 飙到 3.2s。数据库挂了,所有定时任务都停摆
- 不支持分片——如果任务要处理 100 万行数据,Quartz 只能让一个节点跑,无法水平扩展。数据量从 100 万涨到 1000 万,执行时间线性增长
- 锁超时导致重复执行——数据库行锁默认超时 50 秒(
innodb_lock_wait_timeout)。Job 执行时间超过 50 秒,MySQL 释放行锁,其他节点可能抢到锁再执行一遍。我踩过一次:一个离线报表 Job 跑 2 分钟,凌晨 3 点定时触发,4 个节点连续抢锁,报表跑了 4 份 - 节点宕机检测延迟——Quartz 依赖于数据库轮询检测节点存活,轮询间隔通常 30-60 秒,故障转移时间太长。如果宕机发生在 Job 执行中,其他节点最快也要 30 秒后才能感知并重新调度
升级方案:Elastic-Job 的分片机制
Elastic-Job(Apache ShardingSphere 的子项目)是当当网开源的分布式调度框架,核心思路是分片。
分片原理
Elastic-Job 基于 ZooKeeper 实现任务分片。假设有 3 个节点,一个任务被分成 10 个分片(shard),ZK 记录了分片与节点的对应关系:
job-name/sharding/0 → node-1
job-name/sharding/1 → node-1
job-name/sharding/2 → node-2
job-name/sharding/3 → node-2
job-name/sharding/4 → node-2
job-name/sharding/5 → node-3
job-name/sharding/6 → node-3
job-name/sharding/7 → node-3
job-name/sharding/8 → node-3
job-name/sharding/9 → node-1每个节点启动时连接到 ZK,创建临时节点注册自己。Elastic-Job 的调度器在节点上计算分片分配策略(默认平均分配),然后每个节点只执行自己分到的分片。节点拿到分片信息后,通过 ShardingContext.getShardingTotalCount() 和 ShardingContext.getShardingItem() 知道当前分片范围,然后在本地处理数据。
public class MyShardingJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
int shardTotal = shardingContext.getShardingTotalCount(); // 总分片数
int shardItem = shardingContext.getShardingItem(); // 当前分片编号
// 只处理分配到当前分片的数据
List<Order> orders = orderService.getOrdersByShard(shardItem, shardTotal);
for (Order order : orders) {
processOrder(order);
}
}
}分片分配时序图
时间轴:
node-1 ZK node-2 node-3
│ │ │ │
├─ 创建临时节点 ──────→ │ │
│ ◄── 注册成功 ──────────│ │
├─ 监听 /sharding ───→ │ │
│ │ ├─ 创建临时节点 ──────→│
│ │ │◄── 注册成功 ─────────│
│ │ ├─ 监听 /sharding ───→│
│ │ │ ├─ 创建临时节点 ──→
│ │ │ │◄── 注册成功 ────
│ │ │ ├─ 监听 /sharding →
│ │ │ │
│ ◄── 触发 re-sharding ──│ │
│ 收到 Watch 通知 │ │ │
│ 计算分配策略 │ │ │
│ 写入 /sharding/0→1 │ │ │
│ 写入 /sharding/1→1 │ │ │
│ 写入 /sharding/2→1 │ │ │
│ 写入 /sharding/3→1 │ │ │
│ 写入 /sharding/4→2 │ │ │
│ 写入 /sharding/5→2 │ │ │
│ 写入 /sharding/6→3 │ │ │
│ 写入 /sharding/7→3 │ │ │
│ 写入 /sharding/8→3 │ │ │
│ 写入 /sharding/9→3 │ │ │
│ │ │ │
node-1 读取分片 0-3 │ node-2 读取 4-5 │ node-3 读取 6-9 │
│ 执行业务逻辑 │ 执行业务逻辑 │ 执行业务逻辑 │故障转移
节点增减时,Elastic-Job 自动触发重新分片(re-sharding)。假设 node-3 宕机了,ZK 上的临时节点消失,Elastic-Job 的监听器收到事件后,将 10 个分片重新分配给 node-1 和 node-2:
job-name/sharding/0 → node-1
job-name/sharding/1 → node-1
job-name/sharding/2 → node-1
job-name/sharding/3 → node-1
job-name/sharding/4 → node-1
job-name/sharding/5 → node-2
job-name/sharding/6 → node-2
job-name/sharding/7 → node-2
job-name/sharding/8 → node-2
job-name/sharding/9 → node-2故障转移的耗时取决于 ZK session 超时(默认 30 秒)加上重新分片计算时间。实测:3 节点 100 分片,故障转移总耗时约 35-40 秒。相比 Quartz 的 30-60 秒轮询,Elastic-Job 的故障转移基于 ZK 事件驱动,更快。
支持的任务类型
- Simple Job:简单定时执行,执行一次完成
- Dataflow Job:流式处理,持续抓取数据、处理、再抓取,适合 ETL 和批处理场景。数据流处理中可以有
fetchData和processData两个阶段,框架自动循环调用 - Script Job:执行 Shell 脚本,方便非 Java 任务集成,比如用 Python 写的数据清洗脚本
缺点
- 引入 ZK 增加运维复杂度——ZK 要额外部署、监控、备份,集群规模不大时这个成本不值得。ZK 集群本身至少 3 台,每台建议 4C8G,小团队运维成本不低
- ZK Watch 风暴——分片数量超过 1000 时,ZK 的 Watch 通知压力大,节点上下线会导致大量 Watch 回调。实测:2000 分片、10 节点同时上线,ZK 瞬时 Watch 回调 2 万次,ZK 节点 CPU 冲 100%,导致部分节点注册失败
- 分片策略不够灵活——Elastic-Job 内置的分片策略(平均、轮询、哈希)在异构机器上效果不好,高配机器和低配机器分片数一样,低配机器成为瓶颈。需要自定义分片策略才能按机器权重分配
工程复盘:Quartz 和 Elastic-Job 到底怎么选?
小型场景(30 个任务以内)
直接用 Quartz 集群 + 数据库锁就够了。任务少,数据库锁竞争不激烈,运维成本为零。如果担心数据库单点,给数据库做个主从切换就行。
生产案例:我负责过的一个支付回查系统,15 个定时任务,4 个节点,Quartz 跑了两年没出过问题。日均调度 1.2 万次,平均调度延迟 80ms。
中型场景(30-200 个任务,需要分片)
选 Elastic-Job。关键在于是否需要分片——如果任务处理的数据量大到一台机器跑不完(比如全量数据同步、离线报表生成),Elastic-Job 的分片能力是刚需。如果只是简单定时执行,不需要分片,Elastic-Job 的优势不明显。
生产案例:一个订单同步任务,每天凌晨同步 500 万订单到数仓,10 个分片跑 30 分钟。如果用 Quartz 单节点跑,要 5 小时,跑不完就要等第二天。
大型场景(200+ 任务,秒级任务)
Quartz 和 Elastic-Job 都不够用。Quartz 的数据库锁在 200+ 任务下锁竞争激烈,单次调度延迟可能超过 10 秒。Elastic-Job 的 ZK Watch 风暴在 1000+ 分片下压力大。这个规模建议用基于时间轮的调度引擎(如 Apache DolphinScheduler 或自研)。
还有 XXL-Job 这个选择
XXL-Job 是国产开源的调度框架,定位在 Quartz 和 Elastic-Job 之间。它使用 MySQL 做调度中心,执行器通过 HTTP 或 RPC 注册到调度中心,调度中心通过数据库锁分发任务。相比 Elastic-Job 不需要 ZK,运维成本更低;相比 Quartz 支持分片广播和故障转移。
XXL-Job 的局限性在于调度精度——调度中心依赖数据库轮询扫描,默认扫描间隔 30 秒,秒级任务靠不住。而且分片广播不支持动态分片,节点增减后需要手动触发重分片。
横向对比表
| 维度 | Quartz | Elastic-Job | XXL-Job |
|---|---|---|---|
| 依赖组件 | 数据库 | ZooKeeper | MySQL |
| 调度方式 | 数据库行锁 | ZK 分片 | 调度中心轮询+数据库锁 |
| 水平扩展 | 不支持单个Job | 支持分片 | 支持分片广播 |
| 故障转移 | 30-60s 轮询 | 35-40s 事件驱动 | 30s 轮询 |
| 动态分片 | 不支持 | 自动 re-sharding | 手动触发 |
| 秒级任务 | 支持 | 支持 | 不支持,扫描间隔30s |
| 调度精度 | 较准,误差 < 100ms | 较准,误差 < 100ms | 误差 30s+ |
| 运维成本 | 极低 | 中(需维护ZK) | 低 |
| 适用规模 | 小(30 任务以内) | 中(200 分片以内) | 中(100 任务以内) |
实战中的常见坑
分片不均
某个分片的数据量特别大,导致该节点执行时间远超其他节点。解决方案是分片维度的选择:按用户 ID 哈希分片比按订单 ID 分片更均匀,因为用户数通常比订单数分布更均匀。
真实案例:某电商的订单同步任务,按订单 ID 模 10 分片。结果 0 号分片是 VIP 大客户,订单量是其他分片的 5 倍。0 号分片跑 40 分钟,其他分片跑 8 分钟。改按用户 ID 哈希后,基本均匀。
任务超时
Node-1 分配了 3 个分片,每个分片处理 5 分钟,总耗时 15 分钟。但 ZK 的 session 超时默认 30 秒,如果任务阻塞导致心跳中断,ZK 认为节点挂了,触发重新分片,Node-1 的分片被分配给其他节点,导致重复执行。
解决方案:设置合理的 session 超时时间(远大于任务执行时间),或者任务中主动发送心跳。Elastic-Job 提供 setSessionTimeoutMillis 配置,最长可以设到 600 秒。
慢 SQL 拖垮调度
Quartz 集群模式下,QRTZ_LOCKS 表的行锁竞争激烈。如果业务库和历史任务库是同一个 MySQL,慢查询会锁住 QRTZ_TRIGGERS 表,导致调度阻塞。
真实案例:一个业务库的慢查询锁了 QRTZ_TRIGGERS 表 12 秒,导致 3 个任务的调度延迟从 50ms 飙到 12 秒,其中 2 个任务因为错过触发时间被跳过。
幂等是最后的防线
无论选哪个方案,业务逻辑必须幂等。Quartz 的数据库锁可能因为锁超时导致重复执行,Elastic-Job 的分片重分配也可能导致两个节点同时处理同一个分片。幂等是最底层的兜底手段。
幂等实现方式:
- 业务幂等:处理逻辑中先查状态再处理,如果已处理则跳过
- 去重表:建一张 task_execution_log 表,insert ignore 做的活
- 分布式锁兜底:在业务代码中再加一层 Redis 分布式锁,双重保险
面试追问
Q: Quartz 的数据库锁在高并发下表现如何? A: 实测 10 节点 50 个任务,调度延迟从 50ms 飙到 3.2s。数据库行锁的竞争瓶颈在 QRTZ_LOCKS 表的锁等待。如果任务是分钟级甚至小时级执行,这个延迟可以接受。但如果任务密集到秒级,数据库锁就不够用了。
Q: Elastic-Job 为什么不用 Redis 做注册中心? A: 因为 ZK 的临时节点和 Watch 机制天然适合做服务发现和分片协调。Redis 的 pub/sub 没有持久化,节点重启后丢失状态。ZK 的节点数据变更通知直接推送给客户端,不需要轮询。不过 Elastic-Job 3.x 已经支持了 ZooKeeper 和 Nacos 两种注册中心。
Q: 分片数量怎么定? A: 分片数 = 节点数 × 每个节点并发线程数。比如 3 个节点,每个节点 4 个线程,分片数设为 12。分片太少导致某些节点空闲,分片太多导致 ZK Watch 压力大。经验值:1000 分片以内是安全范围。
Q: 如果业务代码抛异常,已经处理的部分数据怎么办? A: 这是 Elastic-Job 的典型陷阱。Elastic-Job 不提供事务补偿,需要业务层自己实现。做法:把每个分片的处理做成可重入的,支持断点续传。比如记录每个分片的 offset,异常后下次从 offset 继续。
总结
| 维度 | Quartz | Elastic-Job |
|---|---|---|
| 依赖组件 | 数据库 | ZooKeeper |
| 调度方式 | 数据库行锁 | ZK 分片 |
| 水平扩展 | 不支持单个Job | 支持分片 |
| 故障转移 | 秒级到分钟级 | 秒级 |
| 适用规模 | 小(30 任务以内) | 中(200 分片以内) |
| 运维成本 | 低 | 中 |
分布式调度没有一个银弹方案。Quartz 适合小规模、简单场景,Elastic-Job 适合需要分片的中型场景,大规模场景需要自研或用 DolphinScheduler 这类 DAG 引擎。选型的关键不在于框架本身,而在于你的任务规模有多大、需不需要分片、能接受多长的故障转移时间。如果面试被问到,先问清楚场景再选方案,比直接背答案更加分。