跳到正文

调度核心机制 ​

一句话: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:预读 + 时间环 ​

调度线程不是「每秒扫一次全表然后立刻发」,而是分两段:

  1. 在事务里执行 SELECT * FROM xxl_job_lock WHERE lock_name='schedule_lock' FOR UPDATE。
  2. 查询 trigger_status=1 AND trigger_next_time <= now + 5000ms 的任务,一次预读未来 5 秒。
  3. 对每条任务分三种情况:
    • 已经过期超过 5 秒:按 misfire_strategy 处理,然后算下一次时间。
    • 已到期但过期不超过 5 秒:立即提交触发,再算下一次时间。
    • 还没到期:把 jobId 放进时间环对应的秒槽。
  4. 提交触发只是丢进线程池,HTTP 调用不在行锁事务里;事务提交后锁释放。
  5. 本轮有数据就对齐到下一秒再扫,本轮没数据则睡 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=2

broadcastTotal 就是当前注册实例数,不能单独配成 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_lockSELECT * 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 当配置项写死。

排障路径:

  1. 任务不触发 → 查 SELECT id, trigger_status, trigger_next_time, misfire_strategy FROM xxl_job_info WHERE id=?;trigger_status=0 不会进扫描,trigger_next_time 是历史时间且 DO_NOTHING 时会被当作 misfire 跳过。
  2. 到点但延迟几秒 → 看 Admin 的调度线程是否被 DB 慢查询拖住、触发线程池队列是否满、Executor 是否在 3 秒连接超时内没响应。
  3. 分片少一台 → 查 xxl_job_registry 是否有该地址、xxl_job_group.address_type 是否为 0(手动录入的组不会自动刷新地址)。
  4. 同一时刻出现多条日志 → 先排除失败重试、手动触发、同 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

把一门技术讲透到既懂也会用