主题
调度核心机制
一句话:XXL-JOB 把 MySQL 表当成调度队列——
JobScheduleHelper每轮预读未来 5 秒的任务,放进 60 槽的秒级时间环,再用xxl_job_lock的行锁让多个 Admin 串行认领;CronExpression 只负责算下一次时间,不负责触发。
适用版本:XXL-JOB 2.3.x / 2.4.0。SQL 以 MySQL 5.7/8.0 + 默认 schema 为准。
为什么需要
调度器要同时解决三个问题:按时触发、断电后不丢任务、多实例不重复触发。常见做法是引入 Quartz 集群或 ZooKeeper 选主,但那会多一个需要运维的组件。
XXL-JOB 的选择是复用已有的 MySQL:任务的下次触发时间存在 xxl_job_info.trigger_next_time,调度实例通过行锁互斥,触发记录写 xxl_job_log。代价是 Admin 强依赖 DB,收益是部署时只需要一个数据库。
核心机制
JobScheduleHelper:预读 + 时间环
调度线程不是「每秒扫一次全表然后立刻发」,而是分两段:
- 在事务里执行
SELECT * FROM xxl_job_lock WHERE lock_name='schedule_lock' FOR UPDATE。 - 查询
trigger_status=1 AND trigger_next_time <= now + 5000ms的任务,一次预读未来 5 秒。 - 对每条任务分三种情况:
- 已经过期超过 5 秒:按
misfire_strategy处理,然后算下一次时间。 - 已到期但过期不超过 5 秒:立即提交触发,再算下一次时间。
- 还没到期:把 jobId 放进时间环对应的秒槽。
- 已经过期超过 5 秒:按
- 提交触发只是丢进线程池,HTTP 调用不在行锁事务里;事务提交后锁释放。
- 本轮有数据就对齐到下一秒再扫,本轮没数据则睡 5 秒,降低空转。
时间环是一个 Map<Integer, List<Integer>>,key 是 0–59 的秒。环线程对齐到整秒后,检查当前秒并回看前 2 秒(防止上一轮处理慢了跨秒),把到点的 jobId 提交给触发线程池。
所以「5 秒」是预读窗口,不是扫描周期;「秒级」是时间环的精度。Admin 里并没有 Quartz 的 Scheduler 在每秒扫描。
多 Admin 怎么不重复触发
多个 Admin 同时运行,每轮都去抢 xxl_job_lock 同一行的排他锁:
- 抢到的进入临界区,完成「查任务 → 提交触发 → 更新
trigger_next_time→ 提交事务」。 - 没抢到的阻塞在
FOR UPDATE,等前者提交后再读,看到的是已经推进过的trigger_next_time,不会重复认领。
这是行锁互斥,不是选主,也不是租约。锁的持有时间只有一轮扫表事务那么长;Admin 进程挂了,连接断开、事务回滚,锁立即释放。没有「30 秒租约到期,另一个 Admin 接管」的机制。
一个已知边界:任务被预读进某个 Admin 的内存时间环后,如果这个 Admin 在触发前宕机,这一轮就不会触发;下一个 cron 周期会重新预读。这不是脑裂,是内存态丢失。
路由策略:Admin 选哪个地址
| 策略 | 实际行为 |
|---|---|
FIRST / LAST | 取地址列表的第一个 / 最后一个 |
ROUND | 轮询计数器取模 |
RANDOM | 随机取一个 |
CONSISTENT_HASH | 按 jobId 在地址哈希环上取节点;增删实例只影响部分任务 |
LFU / LRU | 按 Admin 侧维护的调用频次 / 最近使用情况选地址;统计口径随版本变化,以官方文档或对应版本源码为准 |
FAILOVER | 按顺序对地址调 /beat,选第一个存活实例;不是「先发第一个,失败再发第二个」 |
BUSYOVER | 按顺序调 /idleBeat(带 jobId),选第一个空闲实例 |
SHARDING_BROADCAST | 给该执行器组下所有注册实例各发一次,附带分片下标和总数 |
2.x 内置的就是这些,没有「分片广播故障转移」这个独立策略。执行失败后的重试由 executor_fail_retry_count 控制,和路由策略是两件事。
分片广播:shardTotal 等于实例数
分片广播时,Admin 遍历 xxl_job_group.address_list:
实例列表 [A, B] → A: broadcastIndex=0, broadcastTotal=2
B: broadcastIndex=1, broadcastTotal=2broadcastTotal 就是当前注册实例数,不能单独配成 60。业务侧用:
java
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
// 例:WHERE MOD(id, #{total}) = #{index}扩容后 total 自动变成新实例数,但数据切分规则也变了;框架不会帮你迁移已处理的数据,幂等和断点续跑要自己保证。如果确实需要 60 个逻辑分片,只能在业务里再做一层映射:logicalShard = id % 60,再按 shardIndex/shardTotal 决定本实例负责哪些逻辑分片。
misfire:错过了就按策略处理
misfire_strategy 只有两种:
DO_NOTHING(默认):过期超过 5 秒的任务不补跑,直接算下一次时间。FIRE_ONCE_NOW:补触发一次,多次错过也只补一次。
它不会按错过次数逐次补齐。需要「每一次都不能少」的场景,应该用消息队列或业务补偿,不是调大 misfire。
触发线程池与日志索引
JobTriggerPoolHelper 有快、慢两个池:核心线程都是 10,最大线程默认 200 / 100,队列 1000 / 2000。某个 jobId 在 1 分钟内出现超过 10 次「/run 耗时 > 500ms」,后续触发切到慢池。这里的 500ms 是 Admin 发请求的耗时,不是任务执行耗时。
xxl_job_log 默认没有 (job_id, trigger_time) 联合索引;2.4.0 建表脚本里主要是 I_trigger_time(trigger_time) 和 I_handle_code(handle_code)(不同分支可能额外有 job_id 索引,以实际 DDL 为准)。默认也没有分区,清理是 DELETE 分批删除,xxl.job.logretentiondays 默认 30 天。
实战场景
一、Admin 触发前宕机,那一轮真的没跑
现象:调度中心部署了 2 个 Admin(认为“多实例等于高可用”)。某晚 01:30 的日切任务没有触发,日志里连 xxl_job_log 记录都没有;下一个周期的任务正常。运维查了 Executor、网络、DB,最后发现那台负责本轮预读的 Admin 在 01:29 被驱逐重建(节点内存压力)。
排查:多 Admin 是行锁互斥,不是选主;预读进内存时间环的任务属于内存态。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 本轮有没有日志行 | xxl_job_log 在该触发时间是否有记录 | 无记录说明还没进入“插入日志”这一步 |
| 谁持有 schedule_lock | SELECT * FROM xxl_job_lock WHERE lock_name='schedule_lock' 的阻塞会话 | 抢到锁的那个 Admin 负责本轮 |
| 任务是否已被预读 | 触发时间在 now + 5000ms 窗口内的任务被放进该 Admin 的内存时间环 | 预读后宕机即这一轮丢失 |
| 是否重复触发 | 同 jobId 同一周期是否只有一条日志 | 行锁保证不重复,内存态导致可能漏 |
| 下一个周期是否正常 | trigger_next_time 是否已推进 | 推进即下轮重新预读,不会永久卡住 |
这不是脑裂,是内存态丢失:Admin 进程挂掉后连接断开、事务回滚、锁立即释放,没有“30 秒租约到期接管”的机制。
对症做法:
text
1. 接受边界:XXL-JOB 的调度是“至少不重复”,不是“绝不漏触发”。
关键任务必须有业务侧的“该做没做”检测,而不是依赖调度中心保证执行。
2. 补跑判据放在业务态而不是调度态:
每天检查“昨日账期是否已生成结果”,未生成则立即执行一次(幂等写入)。
3. Admin 的稳定性按有状态服务对待:
预读窗口 5 秒内的任务在内存里,滚动发布要选在无任务窗口,
并确认发布后下一轮预读恢复正常。
4. 数据库行锁是单点互斥:DB 抖动会让所有 Admin 一起阻塞在 FOR UPDATE,
表现为“全平台调度停摆”,此时看 DB 而不是看 Admin。java
// 补跑检测:挂在同一个任务上,每次执行都检查上一周期是否漏了
public void dailyCut() {
LocalDate due = LocalDate.now().minusDays(1);
if (!resultRepo.exists(due)) {
log.warn("missed schedule detected, backfill date={}", due);
metrics.missedSchedule("daily-cut");
doCut(due); // 写入按 (date, account) UPSERT,重复执行安全
}
}复盘要点:判据是“关键任务的业务结果完整性检查独立于调度日志”——每天核对“应产出的账期是否都存在”。把 missed_schedule 做成指标,它比“任务失败”更能反映调度中心的真实可靠性;容量与部署结论也要相应修正:多 Admin 解决的是单点故障与重复触发,不解决“预读后宕机丢一轮”。
二、分片广播的 shardTotal 等于实例数,扩容当天数据重复
现象:对账任务用分片广播(SHARDING_BROADCAST),Handler 里用 MOD(order_id, shardTotal) == shardIndex 取数。某天把执行器从 4 个实例扩到 6 个,当天出现部分订单被两个实例各处理一次(重复 40 余笔),另一部分完全没处理(经验值,需校准)。
排查:分片广播的 shardTotal 是本次触发的存活实例数,它随注册表变化。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 本次 shardTotal | 各实例日志里 XxlJobHelper.getShardTotal() 的值 | 6 而不是原来的 4,取数路由整个变了 |
| 实例数是否稳定 | 注册表里该 appname 的存活地址数 | 扩缩容、被剔除实例都会改变它 |
| 是否有实例中途掉线 | 心跳剔除时间点与触发时间是否重合 | 部分实例没参与本轮 → 覆盖出现缺口 |
| 地址列表来源 | xxl_job_group.address_list 是否被聚合刷新 | 调度读的是聚合后的地址列表 |
| 幂等是否存在 | 同一订单被两次处理的结果 | 无唯一键则重复直接生效 |
shardTotal=4:item0 取 mod=0,1,2,3 中的 0 号类
shardTotal=6:同一个 order_id 的 MOD 结果变化,归属整体重排对症做法:
java
// 1. 把“逻辑分片数”与“实例数”解耦:固定 N 个逻辑分片,实例只承担其中一部分
int logicalTotal = 32; // 配置项,不随实例数变化
for (int item = shardIndex; item < logicalTotal; item += shardTotal) {
process(item, logicalTotal); // MOD(id, 32) == item
}
// 2. 或彻底按主键区间切分,与实例数无关
repo.fetchByIdRange(itemStart(item, logicalTotal), itemEnd(item, logicalTotal));text
3. 变更实例数时选在“无触发窗口”,并确认下一次触发的 shardTotal 一致。
4. 取数逻辑与幂等键同时具备:
(order_id, settle_date) 唯一索引,重复处理只生效一次。
5. 实例被剔除时的缺口要有兜底:
任务末尾统计“本次覆盖的逻辑分片集合”,与预期全集做差,缺失即告警。复盘要点:判据是“每次执行覆盖的逻辑分片集合等于预期全集”,这条断言可以写在 Handler 末尾并发指标。用分片广播时必须把“实例数变化会改变取数路由”写进变更评审——它与 ElasticJob 的差别(那里 shardingTotalCount 是配置、在线实例数只换宿主)正是选型对比里最容易看错的一行,见 设计取舍与对比。
三、路由到没有本地缓存的实例,冷启动把任务拖成超时
现象:字典刷新任务用 ROUND 轮询路由,5 个执行器实例轮流跑。某天起该任务从 40 秒涨到 6 分钟并触发超时告警;只有落在“刚发布的新实例”上的那几次慢。日志显示该实例每次都要全量拉字典(8 万条)再构建内存索引。
排查:有状态 Handler 与无状态路由策略不匹配。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 路由策略 | 该任务的路由配置(ROUND/RANDOM/FIRST/LAST/CONSISTENT_HASH) | 轮询与随机都会换实例 |
| 执行分布 | 按实例统计同一 jobId 的执行次数与耗时 | 只有新实例慢即命中冷启动 |
| 实例是否有状态 | 本地缓存/索引/连接的构建成本 | 每次重建即为有状态 Handler |
| 发布时间相关性 | 慢执行时间点是否紧跟发布 | 新实例缓存为空 |
| 一致性哈希参数 | 改用 CONSISTENT_HASH 时以 jobId 为哈希键 | 能稳定落同一实例,但实例增减会漂移 |
对症做法:
java
// 1. 优先把 Handler 改成无状态:缓存预热由应用启动完成,而不是靠任务命中某台
@EventListener(ApplicationReadyEvent.class)
void warmUp() { dictService.reload(); } // 每台实例本地都有,谁执行都快
// 2. 确实需要固定实例时,用一致性哈希路由;并接受实例增减时的漂移
// 任务配置:routingAlgorithm = CONSISTENT_HASH(以 jobId 为哈希键)
// 3. 任务内部按“是否已就绪”决定行为,而不是假设某台机器有数据
if (!dictService.isReady()) dictService.reload(); // 冷实例也能自己补齐text
4. FIRST/LAST 策略要避免:地址列表顺序会随注册与剔除变化,
“固定第一台”并不等于“同一台”,还让这台实例长期承担全部任务。
5. 有状态 + 高吞吐的组合要么去掉状态,要么改用分片广播让每台各跑一片,
不要指望路由策略替你保存状态。复盘要点:判据是“同一任务在不同实例上的耗时差异”,按实例维度看 P99 就能暴露;执行器要按“可任意替换”的标准设计,这也是一致性哈希只是缓解而不是解法的原因。触发线程池与日志索引的容量口径见 心跳与故障剔除,故障处置见 生产踩坑与运维兜底。
最小上手
准备两个 Executor 实例,appname 必须相同,端口错开:
bash
java -jar demo-executor.jar --server.port=8081 --xxl.job.executor.port=9998
java -jar demo-executor.jar --server.port=8082 --xxl.job.executor.port=9999在 Admin 的「执行器管理」里确认该 appname 下有两个在线地址,然后建一个路由为 SHARDING_BROADCAST 的任务:
java
@XxlJob("shardDemo")
public void shardDemo() {
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
XxlJobHelper.log("shardIndex={}, shardTotal={}", index, total);
XxlJobHelper.handleSuccess();
}点「执行一次」,然后查:
sql
SELECT id, executor_address, executor_sharding_param,
trigger_code, handle_code, trigger_time
FROM xxl_job_log
WHERE job_id = 1
ORDER BY id DESC
LIMIT 10;应看到两行,executor_sharding_param 分别是 0/2 和 1/2。在线实例数可用 SELECT registry_key, COUNT(*) FROM xxl_job_registry WHERE registry_group='EXECUTOR' GROUP BY registry_key; 确认。如果只有一行日志,先查两个实例的 appname 是否一致、xxl_job_group.address_list 是否有两个地址、两个 xxl.job.executor.port 是否冲突。
生产动作
关键默认值(2.4.0):xxl.job.triggerpool.fast.max=200、xxl.job.triggerpool.slow.max=100、xxl.job.logretentiondays=30、misfire_strategy=DO_NOTHING。schedule_type 支持 CRON 和 FIX_RATE;cron 是 Quartz 格式,秒在最前,日和周互斥时用 ?。
分片容量规划:分片数 = 注册实例数,所以实例数就是并行度。扩容会改变每个实例负责的数据范围,任务必须能按 MOD(id, total) 重新切分,并且对重复处理幂等。不要把 broadcastTotal 当配置项写死。
排障路径:
- 任务不触发 → 查
SELECT id, trigger_status, trigger_next_time, misfire_strategy FROM xxl_job_info WHERE id=?;trigger_status=0不会进扫描,trigger_next_time是历史时间且DO_NOTHING时会被当作 misfire 跳过。 - 到点但延迟几秒 → 看 Admin 的调度线程是否被 DB 慢查询拖住、触发线程池队列是否满、Executor 是否在 3 秒连接超时内没响应。
- 分片少一台 → 查
xxl_job_registry是否有该地址、xxl_job_group.address_type是否为 0(手动录入的组不会自动刷新地址)。 - 同一时刻出现多条日志 → 先排除失败重试、手动触发、同 appname 多环境共用一个 Admin;行锁本身只保证「同一轮扫描不重复认领」,不提供业务级恰好一次。
边界
- 默认没有分区表。
xxl_job_log只有自增主键id,按trigger_time做 RANGE 分区时,MySQL 要求所有唯一键都包含分区列,所以主键得改成(id, trigger_time);这是自定义改造,不是默认运维手段,也不能直接DROP PARTITION。 - 不是高频调度器。扫表 + 秒级时间环适合秒级到天级的任务;亚秒级、十万级任务量不适合。
- Admin 宕机会丢本轮内存态。预读进时间环但还没触发的任务,要等下一个 cron 周期。
- 分片不自动均衡数据。它只负责把「第 i 个实例」这个身份传下去,数据怎么切、怎么补跑由业务决定。LFU/LRU 的统计窗口也不是稳定契约,不要依赖精确的「最近 24 小时」之类说法。
讲清楚
XXL-JOB 的调度核心是「用数据库表当队列」。
JobScheduleHelper在一个事务里拿到xxl_job_lock的行锁,预读未来 5 秒要触发的任务:过期超过 5 秒的按 misfire 策略处理,已经到期的立即提交,还没到期的放进 60 槽的秒级时间环;环线程每秒把到点的 jobId 交给触发线程池。多个 Admin 靠同一行锁串行化,谁抢到谁认领,提交事务后锁就释放,没有租约。路由策略里FAILOVER是用/beat探活后选第一个存活地址,BUSYOVER是用/idleBeat选空闲地址;分片广播的shardTotal就是注册实例数,不能独立配置。默认日志表没有分区,清理靠分批 DELETE。它适合秒级到天级、任务量中等的场景,不适合亚秒级高频调度。
适用 XXL-JOB 2.3.x / 2.4.0 | 初稿 2026-10