DAG调度与持久恢复:xxl-job 3.5.0 的扫描、执行器与故障边界
从 @Scheduled 走出单进程之后,业务面对的是另一组问题:任务定义放在哪里、哪台机器负责"到点",哪台机器负责"执行",执行器崩了谁来判定、重试算不算重复执行、停机与重启之间错过的时间窗口补不补。本页以固定版本的开源调度器 xxl-job 3.5.0 为主线,用真实 admin、真实执行器、真实 MySQL 跑一组有界任务,逐项观察调度、执行、回调、重试、过期、注册、崩溃与恢复。
本页是 U85(#122)的 D-DATA 方向权威正文。单机定时器的语义(fixedRate 追赶、@Scheduled 关闭、JMS 边界)仍归调度执行:@Scheduled 语义、异常吞噬、关闭与 JMS 集成边界;持久任务状态机(due/state/claim/lease/attempt)与"本地定时器不能代替持久任务状态机"归分布式锁、分布式 ID 与租约;重试与死信的通用模型归重试、死信、延迟消息与消息积压;外部效果的幂等归重复消费、幂等与顺序消息。执行引擎接入(Spark/Flink/Hive 提交监控、Notebook kernel/session)归 U86;租户配额、所有权代际与主节点租约归 U87。
问题、术语与输入输出
调度器要回答的不是"这段代码怎么跑",而是四个分离的问题:
- 任务定义存在哪里,谁可以被多台机器共享地看到?(持久化)
- 谁决定"现在应该触发",以及同一时刻是否只触发一次?(调度与互斥)
- 谁来真正执行,执行器不在线怎么办?(执行与路由)
- 执行结果如何回到调度侧,丢回调、崩溃、停机之后怎样收敛?(回调与恢复)
对应到本页固定版本的真实表:
| 表 | 一行代表 | 关键列 |
|---|---|---|
xxl_job_info | 一个任务定义("调度状态机") | schedule_type、schedule_conf、misfire_strategy、trigger_status、trigger_last_time、trigger_next_time、executor_handler、executor_fail_retry_count、child_jobid |
xxl_job_log | 一次调度尝试(调度结果 + 执行结果) | trigger_time、trigger_code、handle_code、handle_msg、executor_address、executor_fail_retry_count、alarm_status |
xxl_job_registry | 一个在线执行器地址(心跳行) | registry_group、registry_key(appname)、registry_value(http 地址)、update_time |
xxl_job_lock | 一把调度互斥锁的行 | lock_name='schedule_lock',SQL 里用 FOR UPDATE 锁住 |
术语按本页用法固定下来,避免日常口语混淆:
| 术语 | 本页定义 | 容易混淆的说法 |
|---|---|---|
| 调度(schedule) | admin 决定某任务"到点",把 jobId 推进触发池或时间轮 | 不是"执行" |
| 触发(trigger) | admin 选地址、写一条 xxl_job_log、HTTP POST /trigger 到执行器 | 触发成功不代表执行 |
| 执行(handle) | 执行器 JobThread 从内存队列取出请求、调用 handler | 执行结果靠回调传回 |
| 回调(callback) | 执行器把 handle_code/handle_msg 推回 admin 更新同一行日志 | 回调失败有落盘重试 |
| misfire | 扫描时发现 next_time 已过期超过预读窗口 | 不是"重试",是"过期处理" |
| DAG | 3.5.0 中仅由 child_jobid 表达的"父任务成功后触发子任务" | 不是通用工作流:无 join、无依赖图、无回填窗口 |
一个必须先说清的边界:xxl-job 的官方定位是"分布式任务调度平台",3.5.0 的 child_jobid 只有一层"成功后触发子任务"的语义——父任务 handle_code == 200 时,JobCompleter 对每个子任务 ID 调用一次触发池。源码中没有依赖图结构、没有多父 join、没有"补跑某一业务日期分区"的数据结构(本课程按固定源码审读得出的结论,不是厂商文档原话)。需要真正 DAG(扇入扇出、补数回填、跨天依赖)时应选 Airflow、DolphinScheduler 一类工作流调度器;本页的 child_jobid 只作为"最小依赖表达"的对照基线。同理,"调度成功"与"业务完成"也不是一回事:trigger_code=200 只说明请求送达执行器并被接受进入队列。
版本、工程与观察入口
固定 xxl-job 3.5.0(上游提交 3bebfc1609b95413581b11e270e5670dce679813,2026-10-09 发布)。admin 由本机 JDK 21 从该提交构建,xxl-job-admin-3.5.0.jar 的 SHA-256 与执行器 jar、官方建表 SQL 的哈希都写在运行证据里。运行环境:BellSoft Liberica JDK 21.0.11、Maven 3.9.9、macOS 27.2 arm64、MySQL 8.4.7(DBngin 本机实例)。3.5.0 的 admin 依赖 Spring Boot 4.1.1、MyBatis 3.5.19、mysql-connector-j 26.7.0;执行器 xxl-job-core 的编译依赖是 Netty 4.2.19、Gson 2.14.0、xxl-tool 2.5.2 与 Groovy 6.0.0(GLUE 脚本模式用),Spring 只是 provided 且仅 Spring 专用子类需要——这一点是"frameless 执行器"能只用 XxlJobSimpleExecutor 跑起来的原因。
公开入口:
python3 labs/data-scheduler/run.py脚本每次运行创建独有的 MySQL 库 study_u85_xxl_<runid>、独有的 appname、随机端口的 admin 与执行器、缓存目录下的独立日志与 marker 目录;结束时停进程、DROP DATABASE,不触碰用户既有库与配置。完整命令、退出码、场景观察、清理结果与文件 SHA-256 见运行证据,工程边界见实验说明。
源码依据为固定 tag 3.5.0 的连续片段,元数据含整文件 SHA-256 与行范围,由 scripts/check_source_excerpts.py 联网回源比对。本页只引用一次完整的成功运行(exit 0,7 组场景);开发过程中失败的多轮运行与修复记录保存在仓库外缓存 ~/.cache/software-engineering-study/java-depth-impl/u85/runtime/(证据 JSON 的 rawLogs 字段指向本次运行目录),不以失败运行的数字冒充结论。
数据模型:一行 info 就是一个持久调度状态机
调度器的"持久"体现在两个地方:任务定义与下次触发时间在同一张表里,重启后新进程依然从这张表继续;每次调度尝试也落一行日志,事后可追溯。这意味着调度状态不在内存里,进程重启不丢任务——这与 @Scheduled 的内存周期任务形成鲜明对照。
schedule_type 决定"下一次"怎么算,3.5.0 只有三种:NONE(不调度)、CRON、FIX_RATE(固定秒数)。两种算法的实现都很短:
public class FixRateScheduleType extends ScheduleType {
@Override
public Date generateNextTriggerTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
// generate next trigger time, fix rate delay
Date nextTriggerTime = new Date(fromTime.getTime() + Long.parseLong(jobInfo.getScheduleConf()) * 1000L);
// assign second:
if (nextTriggerTime.getTime() % 1000 != 0) {
nextTriggerTime = DateTool.addSeconds(DateTool.setMilliseconds(nextTriggerTime, 0), 1);
}
return nextTriggerTime;
}
}public class CronScheduleType extends ScheduleType {
@Override
public Date generateNextTriggerTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
// generate next trigger time, with cron
return new CronExpression(jobInfo.getScheduleConf()).getNextValidTimeAfter(fromTime);
}
}FIX_RATE 从"参照时间"直接加秒数,并把毫秒归零后向上取整到秒;CRON 交给内置 CronExpression.getNextValidTimeAfter(fromTime)。两者都从"调用时传入的 fromTime"推进,而不是从"上一次真正执行结束"推进——所以 xxl-job 的 FIX_RATE 是按计划节拍推进的(与 @Scheduled(fixedRate) 的到期点语义同类),不是 fixedDelay 的"结束再等"。start 接口传入的参照时间是 now + PRE_READ_MS(5 秒后生效),这是为了避开预读窗口、避免刚启动就立刻被判定为过期。
trigger_status 是任务是否参与调度的开关(0 停止 / 1 运行),trigger_last_time、trigger_next_time 是 epoch 毫秒。扫描线程只看 trigger_status = 1 AND trigger_next_time <= now + 5s 的行——所以停止一个任务只需要把它置 0,重启后也不会"复活";反过来,任何绕过 start 接口直接改库的操作,都要自己维护这两个时间戳,否则任务会以奇怪的时间点被触发(本实验的 misfire 场景正是这样构造的)。
xxl_job_log 一行日志跨越了"调度"和"执行"两个阶段:trigger_code 由 admin 写,handle_code 初值为 0(运行中),由执行器回调更新为 200/500/502。这就带来一个必须理解的事实:"运行中"与"丢失"在数据库里长得一样,只能靠时间阈值和执行器在线状态区分(见后文"丢失任务")。
扫描与时间轮:调度线程到底做了什么
admin 的调度线程 JobScheduleHelper#scheduleThread 按整秒对齐轮询,但不是无条件的"每秒一次":扫描结果为空(preReadSuc=false)时这一轮睡满约 5 秒(PRE_READ_MS)再扫,有任务或者抛异常时只睡到下一个整秒;单轮耗时超过 1 秒时不睡、直接进入下一轮。每轮结构是"开事务 → 抢行锁 → 查一批到期任务 → 分三种情况处理 → 提交事务":
try {
transactionStatus = XxlJobAdminBootstrap.getInstance().getTransactionManager().getTransaction(new DefaultTransactionDefinition());
// 1、job lock
String lockedRecord = XxlJobAdminBootstrap.getInstance().getXxlJobLockMapper().scheduleLock();
long nowTime = System.currentTimeMillis();
// scan and process job
List<XxlJobInfo> scheduleList = XxlJobAdminBootstrap.getInstance().getXxlJobInfoMapper().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
if (CollectionTool.isNotEmpty(scheduleList)) {
// 2、push time-ring
for (XxlJobInfo jobInfo: scheduleList) {
// time-ring jump
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
// 1、misfire handle
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
misfireStrategyEnum.getMisfireHandler().handle(jobInfo.getId());
// 2、fresh next
refreshNextTriggerTime(jobInfo, new Date());
} else if (nowTime >= jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、trigger direct
XxlJobAdminBootstrap.getInstance().getJobTriggerPoolHelper().trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule expire, direct trigger : jobId = " + jobInfo.getId() );
// 2、fresh next
refreshNextTriggerTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again
if (jobInfo.getTriggerStatus()== TriggerStatus.RUNNING.getValue() && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring (pre read)
pushTimeRing(ringSecond, jobInfo.getId());
logger.debug(">>>>>>>>>>> xxl-job, schedule pre-read, push trigger : jobId = " + jobInfo.getId() );
// 3、fresh next
refreshNextTriggerTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
} else {
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
logger.debug(">>>>>>>>>>> xxl-job, schedule normal, push trigger : jobId = " + jobInfo.getId() );
// 3、fresh next
refreshNextTriggerTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}三个分支的分界线是 nowTime 与 trigger_next_time + 5000ms 的关系:
| 分支 | 条件 | 动作 | 含义 |
|---|---|---|---|
| 2.1 过期过久 | now > next_time + 5s | 按 misfire 策略处理 + 推进 next_time | 停机/卡顿期间错过的点 |
| 2.2 刚好过期 | now >= next_time 且未超过 5s | 立即触发 + 推进 next_time | 正常到点,直接进触发池 |
| 2.3 预读 | next_time 在未来 5s 内 | 推进秒级时间轮 | 提前 5 秒挂到对应"秒格"上 |
秒级时间轮是第二个线程 ringThread:它每秒醒来,把当前秒以及往前两格的 jobId 取出,去重后逐个触发。往前的两格是为了"处理耗时跨过刻度"时不漏;去重是因为同一 jobId 可能被重复推进同一个格子(注释里写得很直白:"避免调度重复:重复推送时间轮刻度,去重只保留一个"):
while (!ringThreadToStop) {
// align second
try {
TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
} catch (Throwable e) {
if (!ringThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
// second data
List<Integer> ringItemData = new ArrayList<>();
// collect rind data, by second
int nowSecond = Calendar.getInstance().get(Calendar.SECOND);
for (int i = 0; i <= 2; i++) { // 避免调度遗漏:处理耗时太长、跨过刻度,除当前刻度外 + 向前校验2个刻度;
List<Integer> ringItemList = ringData.remove( (nowSecond+60-i)%60 );
if (CollectionTool.isNotEmpty(ringItemList)) {
// distinct for each second
List<Integer> ringItemListDistinct = ringItemList.stream().distinct().toList(); // 避免调度重复:重复推送时间轮刻度,去重只保留一个;;
if (ringItemListDistinct.size() < ringItemList.size()) {
logger.warn(">>>>>>>>>>> xxl-job, time-ring found job repeat beat : " + nowSecond + " = " + ringItemData);
}
// collect ring item
ringItemData.addAll(ringItemListDistinct);
}
}由此可以得到调度侧的三个可观察量:
- 单 admin、每 2 秒一次的 cron,14 秒窗口内应看到 5 次左右(实测 5 次)。少太多说明扫描线程卡住或锁竞争;多出来说明时间轮或 next_time 推进有重复。
- 有任务或刚抛异常时按整秒轮询、空扫描退避约 5 秒,所以秒级 cron(
0/2 * * * * ?)可以如实调度(预读分支覆盖未来 5 秒内的点);但亚秒精度不在承诺内。 - 预读窗口
PRE_READ_MS=5000在 3.5.0 中是JobScheduleHelper的public static final常量(源码第 30 行,非application.properties项);这意味着任何"未来 5 秒内到点"的任务都会提前推进秒级时间轮。进程在这 5 秒内退出,已推进时间轮但还没触发的 jobId 会随进程消失;而trigger_next_time在推进时就已写库,所以重启后扫描看到的是"还没到点",这些点不会被补触发(即使 misfire 设为 FIRE_ONCE_NOW 也一样——因为扫描时next_time尚未过期,根本不走 misfire 分支)。本实验未构造这一窗口,结论以源码与refreshNextTriggerTime的推进顺序为准。
批大小 preReadCount = (fastMax + slowMax) * 10,默认 (300+200)*10 = 5000 条;每批 SQL 带 LIMIT 和 ORDER BY id。行锁 schedule_lock FOR UPDATE 在事务里拿住,直到提交,因此同一时刻只有一个 admin 能扫描——这是后文"重复调度"的第一层防线。锁的粒度是整库单行:调度吞吐上限本质上被"一次扫描的耗时"限制,任务数量级很大的时候应关注扫描耗时(本实验任务数量少,未做压测)。
调度与执行分离:admin、执行器与回调
一条完整链路是这样的(每一跳都能在日志里找到对应字段):
admin 调度线程 → JobTriggerPoolHelper 池
→ JobTrigger.processTrigger:写 xxl_job_log 一行(trigger_code 未定)
→ 路由选地址(默认 FIRST)→ HTTP POST /trigger 到执行器
→ 执行器 EmbedServer(Netty) → ExecutorBizImpl.trigger → JobThread.pushTriggerQueue
→ JobThread 取队列 → handler.execute() → XxlJobHelper.handleSuccess/Fail
→ TriggerCallbackThread → HTTP POST /api/callback 到 admin
→ JobCompleter.complete → 更新同一行日志 handle_code/handle_msgJobTrigger 的关键顺序是"先写日志行、再选地址、再调远程",日志行 ID 就是后续回调的关联键:
// 1、save log-id
XxlJobLog jobLog = new XxlJobLog();
jobLog.setJobGroup(jobInfo.getJobGroup());
jobLog.setJobId(jobInfo.getId());
jobLog.setTriggerTime(triggerTime);
xxlJobLogMapper.save(jobLog);
logger.debug(">>>>>>>>>>> xxl-job trigger start, jobId:{}", jobLog.getJobId());
// 2、init trigger-param
TriggerRequest triggerParam = new TriggerRequest();
triggerParam.setJobId(jobInfo.getId());
triggerParam.setExecutorHandler(jobInfo.getExecutorHandler());
triggerParam.setExecutorParams(jobInfo.getExecutorParam());
triggerParam.setExecutorBlockStrategy(jobInfo.getExecutorBlockStrategy());
triggerParam.setExecutorTimeout(jobInfo.getExecutorTimeout());
triggerParam.setLogId(jobLog.getId());
triggerParam.setLogDateTime(jobLog.getTriggerTime().getTime());
triggerParam.setGlueType(jobInfo.getGlueType());
triggerParam.setGlueSource(jobInfo.getGlueSource());
triggerParam.setGlueUpdatetime(jobInfo.getGlueUpdatetime().getTime());
triggerParam.setBroadcastIndex(index);
triggerParam.setBroadcastTotal(total);
// 3、init address
String address = null;
Response<String> routeAddressResult = null;
if (group.getRegistryList()!=null && !group.getRegistryList().isEmpty()) {
if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) {
if (index < group.getRegistryList().size()) {
address = group.getRegistryList().get(index);
} else {
address = group.getRegistryList().get(0);
}
} else {
routeAddressResult = executorRouteStrategyEnum.getRouter().route(triggerParam, group);
if (routeAddressResult.isSuccess()) {
address = routeAddressResult.getData();
}
}
} else {
routeAddressResult = Response.of(XxlJobContext.HANDLE_CODE_FAIL, I18nUtil.getString("jobconf_trigger_address_empty"));
}
// 4、trigger remote executor
Response<String> triggerResult = null;
if (address != null) {
triggerResult = doTrigger(triggerParam, address, group);
} else {
triggerResult = Response.of(XxlJobContext.HANDLE_CODE_FAIL, "Address Router Fail.");
}这一段解释了三个现象:
- 日志行在远程调用之前写入,所以"触发失败"也会有日志行(
trigger_code != 200),可在运营报表里看到失败次数; jobLog.id被塞进TriggerRequest.logId,执行器回调时原样带回——logId 是调度侧与执行侧的关联键,不是业务 ID;- 地址为空时写
Address Router Fail,地址列表为空时写"执行器地址为空",两种失败在trigger_msg里可直接区分。
执行器侧对同一个 jobId 只维护一个 JobThread,pushTriggerQueue 用 triggerLogIdSet 防重复,队列是 LinkedBlockingQueue:
public Response<String> pushTriggerQueue(TriggerRequest triggerParam) {
// avoid repeat
if (!triggerLogIdSet.add(triggerParam.getLogId())) {
logger.info(">>>>>>>>>>> repeat trigger job, logId:{}", triggerParam.getLogId());
return Response.of(XxlJobContext.HANDLE_CODE_FAIL, "repeat trigger job, logId:" + triggerParam.getLogId());
}
// push trigger queue
triggerQueue.add(triggerParam);
return Response.ofSuccess();
}由此得到执行器的基本模型:一个 jobId 对应一个执行线程 + 一个内存队列;同一 jobId 的多次触发在队列里排队(SERIAL_EXECUTION),不同 jobId 之间并行。队列在 JVM 内存里,进程死了队列就没了——这正是后面崩溃与停机场景中"排队任务以 not executed 失败回调"的由来。
执行器侧:三种阻塞策略
当同一任务上一次还没跑完、下一次触发又到了,执行器按 executor_block_strategy 三种方式处理:
// executor block strategy
if (jobThread != null) {
ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(triggerRequest.getExecutorBlockStrategy(), null);
if (ExecutorBlockStrategyEnum.DISCARD_LATER == blockStrategy) {
// discard when running
if (jobThread.isRunningOrHasQueue()) {
return Response.of(XxlJobContext.HANDLE_CODE_FAIL, "block strategy effect:"+ExecutorBlockStrategyEnum.DISCARD_LATER.getTitle());
}
} else if (ExecutorBlockStrategyEnum.COVER_EARLY == blockStrategy) {
// kill running jobThread
if (jobThread.isRunningOrHasQueue()) {
removeOldReason = "block strategy effect:" + ExecutorBlockStrategyEnum.COVER_EARLY.getTitle();
jobThread = null;
}
} else {
// just queue trigger
}
}
// replace thread (new or exists invalid)
if (jobThread == null) {
jobThread = XxlJobExecutor.getInstance().registJobThread(triggerRequest.getJobId(), jobHandler, removeOldReason);
}
// push data to queue
return jobThread.pushTriggerQueue(triggerRequest);SERIAL_EXECUTION(默认):入队排队,前一个跑完后依次消费。DISCARD_LATER:如果线程正在跑或队列非空,直接返回失败,不执行。实测:第一个 6 秒任务执行中,第二次触发在 admin 侧trigger_code=500、handle_code=0,trigger_msg含"丢弃后续调度"——被丢弃的触发不会产生执行回调,所以日志行会停在"调度失败"。COVER_EARLY:打断当前执行的线程并重建,正在跑的任务以"被覆盖"失败回调。本实验未运行 COVER_EARLY(打断语义对业务不安全的概率高,且需要更精细的时序控制),结论以源码为准,不冒充实测。
注意:DISCARD_LATER 的"丢弃"发生在执行器,而不是 admin。也就是说 admin 的日志会记录这条触发,但执行器拒绝入队。排障时不要只看"调度次数"判断实际执行次数。
失败与恢复的四个层次
xxl-job 的"恢复"分散在四个互相独立、时间尺度不同的机制里。把它们混成一句"失败重试",会在排障时找不到正确的开关。
第一层:失败重试(executor_fail_retry_count)
执行失败(handle_code=500)后,admin 的失败监控线程每 10 秒扫一次 alarm_status=0 且 handle_code != 200 的日志行,先 CAS 把 alarm_status 从 0 锁成 -1,再判断是否重试:
List<Long> failLogIds = XxlJobAdminBootstrap.getInstance().getXxlJobLogMapper().findFailJobLogIds(1000);
if (failLogIds!=null && !failLogIds.isEmpty()) {
for (long failLogId: failLogIds) {
// lock log
int lockRet = XxlJobAdminBootstrap.getInstance().getXxlJobLogMapper().updateAlarmStatus(failLogId, 0, -1);
if (lockRet < 1) {
continue;
}
XxlJobLog log = XxlJobAdminBootstrap.getInstance().getXxlJobLogMapper().load(failLogId);
XxlJobInfo info = XxlJobAdminBootstrap.getInstance().getXxlJobInfoMapper().loadById(log.getJobId());
// 1、fail retry monitor
if (log.getExecutorFailRetryCount() > 0) {
XxlJobAdminBootstrap.getInstance().getJobTriggerPoolHelper().trigger(log.getJobId(), TriggerTypeEnum.RETRY, (log.getExecutorFailRetryCount()-1), log.getExecutorShardingParam(), log.getExecutorParam(), null);
String retryMsg = "<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_type_retry") +"<<<<<<<<<<< </span><br>";
log.setTriggerMsg(log.getTriggerMsg() + retryMsg);
XxlJobAdminBootstrap.getInstance().getXxlJobLogMapper().updateTriggerInfo(log);
}关键点:
- 重试是异步的,由另一条日志行完成,原日志行不会变成成功;
executor_fail_retry_count在每次重试的新日志行里递减 1,减到 0 就不再重试——实测轨迹[500, 500, 200],对应retry_left=[2, 1, 0];- 重试是"整任务重跑",执行器拿到的
logId是新的,因此JobThread的 logId 去重不会拦住重试(这正是设计意图); - 重试次数与业务幂等是两件事:重跑会再执行一次 handler,副作用是否叠加取决于业务,见重复消费、幂等与顺序消息。
第二层:调度过期(misfire)
如果扫描时发现 next_time 已经过期超过 5 秒(说明停机、卡顿或 admin 不可用),按 misfire_strategy 处理:
@Override
public void handle(int jobId) {
// FIRE_ONCE_NOW 》 trigger
XxlJobAdminBootstrap.getInstance().getJobTriggerPoolHelper().trigger(jobId, TriggerTypeEnum.MISFIRE, -1, null, null, null);
logger.warn(">>>>>>>>>>> xxl-job, schedule MisfireFireOnceNow: jobId = " + jobId );
}3.5.0 只有两种:DO_NOTHING(默认,什么也不做,只记录一条 warn 日志后直接推进 next_time)与 FIRE_ONCE_NOW(立即补触发一次,标记为 MISFIRE 类型)。实测:把两个 20 秒任务的时间戳回拨 65 秒人为制造"错过约 3 个周期"的停机,DO_NOTHING 任务 0 条日志,FIRE_ONCE_NOW 任务恰好 1 条、trigger_msg 含"调度过期补偿"。
由此得到 misfire 的真实语义:它不是"按周期逐段回填",而是"错过多少都只补一次"。这与 @Scheduled(fixedRate) 在同一进程内的"逐轮追赶"完全不同(后者见调度执行正文);也与工作流调度器的"补数窗口"不同——xxl-job 没有业务日期参数的概念,补跑哪一天由任务参数自己决定。
第三层:丢失任务(回调丢失与执行器不在线)
执行器执行完后回调失败(网络、admin 重启)会让日志行停在 handle_code=0。admin 的完成监控线程每 60 秒扫一次,条件是双重的:日志行 trigger_time 早于 10 分钟前,且 executor_address 在注册表里找不到(LEFT JOIN 为 NULL):
jobMonitorThread = new CyclicThread("JobCompleteHelper#jobMonitorThread", true, new Runnable() {
@Override
public void run() {
// 任务结果丢失处理:调度记录停留在 "运行中" 状态超过10min,且对应执行器心跳注册失败不在线,则将本地调度主动标记失败;
Date losedTime = DateTool.addMinutes(new Date(), -10);
List<Long> losedJobIds = XxlJobAdminBootstrap.getInstance().getXxlJobLogMapper().findLostJobIds(losedTime);
if (losedJobIds!=null && losedJobIds.size()>0) {
for (Long logId: losedJobIds) {
XxlJobLog jobLog = new XxlJobLog();
jobLog.setId(logId);
jobLog.setHandleTime(new Date());
jobLog.setHandleCode(XxlJobContext.HANDLE_CODE_FAIL);
jobLog.setHandleMsg( I18nUtil.getString("joblog_lost_fail") );
XxlJobAdminBootstrap.getInstance().getJobCompleter().complete(jobLog);
}
}
}
}, 60 * 1000L, true);
jobMonitorThread.start();这解释了本实验里两个看起来矛盾的结果:
- 崩溃后把日志
trigger_time回拨 11 分钟,并每 20 秒刷新一次注册行模拟心跳,跨过完整监控周期(≥70 秒)——仍不判失败(实测handleCode=0),因为地址仍能 JOIN 到新鲜注册行。"只凭超时"不足以判丢失。 - 把注册行的
update_time回拨 100 秒(模拟心跳过期)后——注册被移除、组地址清空,下一轮监控把该行标成handle_code=500、handle_msg=任务结果丢失,标记失败。
这个双条件设计的意图是"执行器可能只是回调丢了、还在跑",但它也带来一个真实窗口:执行器真的崩了,也要等注册过期(90 秒)+ 10 分钟阈值,admin 才会把这条日志收敛为失败。在这段时间里,任务在调度侧是"运行中",不会被再次触发——对"必须尽快知道失败"的业务,这是需要额外告警的盲区。
第四层:回调重试(执行器侧落盘)
回调本身也做了重试:先走内存队列批量回调,全部失败时把回调数据写成文件,retryCallbackThread 每 30 秒重扫回调日志目录并重试:
private void doCallback(List<CallbackData> callbackDataList, final XxlJobExecutor xxlJobExecutor){
boolean callbackRet = false;
// callback request, will retry + append-log if fail
for (AdminBiz adminBiz: xxlJobExecutor.getAdminBizList()) {
try {
Response<String> callbackResult = adminBiz.callback(new CallbackRequest(callbackDataList));
if (callbackResult!=null && callbackResult.isSuccess()) {
appendCallbackResult(callbackDataList, "<br>----------- xxl-job job callback finish.");
callbackRet = true;
break;
} else {
appendCallbackResult(callbackDataList, "<br>----------- xxl-job job callback fail, callbackResult:" + callbackResult);
}
} catch (Throwable e) {
appendCallbackResult(callbackDataList, "<br>----------- xxl-job job callback error, errorMsg:" + e.getMessage());
}
}
// write callback-file, will retry later
if (!callbackRet) {
writeCallbackLog(callbackDataList);
}这一层的边界要说清:落盘重试**只覆盖"执行器还活着、回调没送到"**的情况。执行器进程被 kill -9 或 Runtime.halt 时,回调文件根本来不及写,只能靠第三层的丢失判定收敛。三个层次的恢复责任分属 admin、执行器与人工/对账,不能互相替代。
注册、心跳与地址:执行器退出时发生了什么
执行器启动后每 30 秒向 admin 注册一次(POST /api/registry,携带 appname、地址、accessToken);admin 的注册监控每 30 秒做三件事:删掉超过 90 秒没更新的注册行、把在线地址按 appname 聚合、写回执行器组的 address_list:
registryMonitorThread = new CyclicThread("JobRegistryHelper#registryMonitorThread", true, new Runnable() {
@Override
public void run() {
// 2.1、refresh auto-registry group
List<XxlJobGroup> groupList = XxlJobAdminBootstrap.getInstance().getXxlJobGroupMapper().findByAddressType(0);
if (groupList!=null && !groupList.isEmpty()) {
// a、remove dead address (admin/executor)
List<Integer> ids = XxlJobAdminBootstrap.getInstance().getXxlJobRegistryMapper().findDead(Const.DEAD_TIMEOUT, new Date());
if (ids!=null && !ids.isEmpty()) {
XxlJobAdminBootstrap.getInstance().getXxlJobRegistryMapper().removeDead(ids);
}
// b、fresh online address (appname : List<address>)
HashMap<String, List<String>> appnameAddressMap = new HashMap<>();
List<XxlJobRegistry> list = XxlJobAdminBootstrap.getInstance().getXxlJobRegistryMapper().findAll(Const.DEAD_TIMEOUT, new Date());
if (list != null) {
for (XxlJobRegistry item: list) {
if (RegistTypeEnum.EXECUTOR.name().equals(item.getRegistryGroup())) {
String appname = item.getRegistryKey();
List<String> registryList = appnameAddressMap.get(appname);
if (registryList == null) {
registryList = new ArrayList<String>();
}
if (!registryList.contains(item.getRegistryValue())) {
registryList.add(item.getRegistryValue());
}
appnameAddressMap.put(appname, registryList);常量定义在 Const 里,DEAD_TIMEOUT = BEAT_TIMEOUT * 3:
// ---------------------- for registry ----------------------
/**
* registry beat interval, default 30s
*/
public static final int BEAT_TIMEOUT = 30;
/**
* registry dead timeout, default 90s
*/
public static final int DEAD_TIMEOUT = BEAT_TIMEOUT * 3;于是"执行器退出"有三种截然不同的路径,本实验逐一跑过:
| 退出方式 | 注册表 | 组地址 | 日志 | 实测观察 |
|---|---|---|---|---|
| 优雅停机 SIGTERM | 立即 registryRemove 删除 | 下一轮(≤30s)清空 | 在途任务回调 [job running, killed],排队任务回调 job not executed...killed | codes=[500,500],registryRemoved=True |
崩溃 halt(17) / SIGKILL | 残留 90 秒 | 残留 90 秒后清空 | 无任何回调,日志停在运行中 | 崩溃后立即再触发:trigger_code=500,trigger_msg 是连接被拒绝的堆栈 |
| 全部 admin 停机后重启 | 保留/重建 | 重新聚合 | 停机期间无调度 | 重启后 15 秒新增 5 行,无突发补跑 |
第二行是最容易误判的窗口:执行器进程已经不在了,但注册行还在(心跳超时是 90 秒),组地址也还是旧地址。此时 admin 触发会真的发起 HTTP 请求,连接被拒后把整段异常写进 trigger_msg。排障时要同时看"注册行是否还在"与"连不连得上",不能只看地址非空就认为执行器健康。
优雅停机的细节值得单独说明:执行器 destroy() 先停内嵌服务器,再等在途任务 5 秒(Const.ELEGANT_SHUTDOWN_WAITING_SECONDS),然后对每个 JobThread 调 toStop 并中断,join 完成后清空仓库;排队中的请求在退出循环后统一以"not executed"失败回调:
} finally {
if(triggerParam != null) {
// callback handler info
if (!toStop) {
// common
XxlJobExecutor.getInstance().getTriggerCallbackThreadHelper().pushCallBack(new CallbackData(
triggerParam.getLogId(),
triggerParam.getLogDateTime(),
XxlJobContext.getXxlJobContext().getHandleCode(),
XxlJobContext.getXxlJobContext().getHandleMsg() )
);
} else {
// is killed
XxlJobExecutor.getInstance().getTriggerCallbackThreadHelper().pushCallBack(new CallbackData(
triggerParam.getLogId(),
triggerParam.getLogDateTime(),
XxlJobContext.HANDLE_CODE_FAIL,
stopReason + " [job running, killed]" )
);
}
}
}
}
// callback trigger request in queue
while(triggerQueue !=null && !triggerQueue.isEmpty()){
TriggerRequest triggerParam = triggerQueue.poll();
if (triggerParam!=null) {
// is killed
XxlJobExecutor.getInstance().getTriggerCallbackThreadHelper().pushCallBack(new CallbackData(
triggerParam.getLogId(),
triggerParam.getLogDateTime(),
XxlJobContext.HANDLE_CODE_FAIL,
stopReason + " [job not executed, in the job queue, killed.]")
);
}
}注意 toStop 的中断只是请求,而 destroy() 对每个 JobThread 的 join() 没有超时:handler 若不响应中断(阻塞在不可中断 IO),destroy() 会一直等到它执行完,JVM 的退出时间取决于最慢的在途任务——不是"等满 5 秒就带着线程退出"。本实验的 handler 是 Thread.sleep,可被中断,所以结果干净;对不可中断任务,停机预算要按"最坏任务时长"而不是按 5 秒优雅等待来算。
重复调度:三层防线与主节点切换
"同一时刻只调度一次"不是靠单一机制,而是三层叠加:
- 数据库行锁:每个 admin 的调度线程在事务里
SELECT ... FOR UPDATE锁schedule_lock行,同一时刻只有一个 admin 能进入扫描; - next_time 推进:扫描时就把
trigger_next_time推到下一周期并提交,另一个 admin 再来扫描时该行已不在next_time <= now+5s范围内; - 执行器 logId 去重:即便前两层都被绕过(例如两个 admin 几乎同时读到同一行),同一个
logId进入JobThread时会被triggerLogIdSet拒绝,日志里出现repeat trigger job。
本实验让两个 admin 同时连同一库、同一执行器组跑了 22 秒(cron 每 2 秒):
U85 duplicateFirst14sTwoAdmins=5 next22sAdded=11 total=16
U85 duplicateSameSecond=0 executorRepeatTriggers=0
U85 duplicateExecutions=16 distinctLogIds=16对照组:如果行锁失效,22 秒两 admin 应产生约 22 行新增(每次调度两份);实测 11 行,与"单 admin 每 2 秒一次"的期望一致。第三层的 logId 去重为零触发,说明前两层已经足够——但不能因此删掉第三层,它防的是前两层被配置/时钟错误绕过的极端情况。
接着做了主节点切换:杀掉 admin A,B 继续调度 7 秒(实测 +3 行);再全部停掉 8 秒后重启一个全新 admin C,重启后 15 秒新增 5 行,没有补跑停机期间错过的槽位(DO_NOTHING)。三个观察合起来说明:
- xxl-job 的 admin 没有主备选举,是"谁抢到行锁谁扫描"的对等结构;所谓"主节点切换"其实是"剩下的 admin 自动接管",不需要选主协议;
- 但这依赖数据库本身:DB 不可用时所有 admin 都停摆,库的可用性就是调度可用性的上限;
- "重启不补跑"是
DO_NOTHING的产物;需要补的业务应把 misfire 设为FIRE_ONCE_NOW,或者自己在业务侧做日期分区回填——两者语义不同,不要混着说。
DAG:child_jobid 能表达什么,不能表达什么
child_jobid 的实现只有一段:父任务执行成功(handle_code == 200)时,JobCompleter 对每个子任务 ID 调一次触发池:
private void processChildJob(XxlJobLog xxlJobLog){
// 1、handle success, to trigger child job
String triggerChildMsg = null;
if (XxlJobContext.HANDLE_CODE_SUCCESS == xxlJobLog.getHandleCode()) {
XxlJobInfo xxlJobInfo = xxlJobInfoMapper.loadById(xxlJobLog.getJobId());
// process child job
if (xxlJobInfo!=null && StringTool.isNotBlank(xxlJobInfo.getChildJobId())) {
triggerChildMsg = "<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_child_run") +"<<<<<<<<<<< </span><br>";
String[] childJobIds = xxlJobInfo.getChildJobId().split(",");
for (int i = 0; i < childJobIds.length; i++) {
// process eath child
int childJobId = (StringTool.isNotBlank(childJobIds[i]) && StringTool.isNumeric(childJobIds[i]))
?Integer.parseInt(childJobIds[i])
:-1;
if (childJobId > 0) {
// valid
if (childJobId == xxlJobLog.getJobId()) {
logger.debug(">>>>>>>>>>> xxl-job, XxlJobCompleter-finishJob ignore childJobId, childJobId {} is self.", childJobId);
continue;
}
// trigger child job
XxlJobAdminBootstrap.getInstance().getJobTriggerPoolHelper().trigger(childJobId, TriggerTypeEnum.PARENT, -1, null, null, null);
Response<String> triggerChildResult = Response.ofSuccess();
// add msg
triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg1"),
(i+1),
childJobIds.length,
childJobIds[i],
(triggerChildResult.isSuccess()?I18nUtil.getString("system_success"):I18nUtil.getString("system_fail")),
triggerChildResult.getMsg());
} else {
triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg2"),实测一条 A→B→C 的链:三个节点各执行 1 次,A 的 handle_msg 含"触发子任务",trigger_time 顺序 A≤B≤C。反例:父任务 D 的 child_jobid 指向已被删除的 E——D 仍然成功,日志里仍写"触发子任务",但 E 没有任何日志(childLogs=0)。这说明:
- 子任务 ID 的合法性只在保存任务时校验(
XxlJobService.add/update检查子任务存在、且当前登录用户对它所属执行器组有权限),运行时不再校验;删掉子任务后,父任务会静默不再触发它; - "DAG" 在这里是单亲、成功才触发、无 join、无回填窗口的最小依赖;失败时子任务不会执行,也没有"失败也继续"的选项;
- 想在 xxl-job 内做扇入(多个父都完成才跑子),需要在业务侧自己聚合完成标记——这不是调度器的职责。
因此"选一个开源调度器"时,DAG 能力是分水岭:xxl-job 适合"周期任务 + 简单前后依赖 + 运维友好"的场景;真正的数据管道依赖(多父、跨天、补数)应选 Airflow/DolphinScheduler;仅单机定时用 @Scheduled 或 Quartz 更轻。
观测与排障:只看一张表
日常排障的核心对象是 xxl_job_log。状态码语义:
| 列/取值 | 含义 |
|---|---|
trigger_code=200 | 调度请求已送达执行器并被接受入队 |
trigger_code!=200 | 调度失败:地址为空、路由失败、连接被拒、执行器拒绝(如 DISCARD_LATER) |
handle_code=0 | 尚未回调:可能正在跑,也可能回调丢了/执行器崩了 |
handle_code=200 | 执行成功 |
handle_code=500 | 执行失败(业务抛异常、被 kill、not executed、丢失标记) |
handle_code=502 | 执行超时(executor_timeout > 0 时触发) |
alarm_status | 0 待处理、-1 锁定、1 无需告警、2 告警成功、3 告警失败 |
推荐排障顺序:"任务有没有被调度"(log 行是否存在、trigger_code)→"执行器收到没有"(executor_address、trigger_msg)→"执行结果"(handle_code、handle_msg 前几十字符)→"是否重试过"(同一 jobId 的多行、executor_fail_retry_count 递减)→"是否被丢弃"(trigger_code=500、handle_code=0,trigger_msg 里有执行器拒绝文案 block strategy effect;注意每条触发日志的策略字段本身也写着"丢弃后续调度")。
运营报表的日汇总 SQL 在 XxlJobLogMapper.xml:按 trigger_time 统计总数、运行中(trigger_code in (0,200) and handle_code=0)、成功(handle_code=200)。三个数加起来不等于总数时,缺口就是失败/超时/丢失。生产上还应补:注册节点数、扫描耗时、时间轮积压、回调落盘文件数。
容量与设计取舍
扫描容量:每秒一轮、每轮最多 (fastMax+slowMax)*10 条,默认 5000;扫描本身持行锁,所以"每秒可扫描的任务数 × 每批耗时"决定了调度器能承载的任务规模上限。本实验一共创建 14 个任务、未做压测;结论是算术模型,不是实测容量。
至少两种合理选择,取决于要什么:
| 方案 | 优点 | 代价 | 适用 |
|---|---|---|---|
| xxl-job(DB 行锁 + 拉模式) | 运维界面完整、部署简单、执行器无状态、失败重试/告警现成 | 依赖单一 DB;DAG 能力弱;无跨天回填 | 周期任务、简单依赖 |
| Quartz(JDBC JobStore) | 与 Spring 集成深、单进程即可、可集群(行锁) | 无运维界面、无执行器隔离、重试/告警自建 | 单体应用内定时 |
| Airflow / DolphinScheduler | 真 DAG、补数、跨语言、可视化 | 组件多、资源重、与 Java 业务集成需适配 | 数据管道、跨系统依赖 |
恢复责任要分清:调度器保证"到点触发一次"(在行锁可用前提下),不保证"执行恰好一次",更不保证"业务效果恰好一次"。执行器崩溃、回调丢失、misfire 补跑,都会重新执行 handler;业务侧的幂等键与对账是最后一道防线。这条与分布式锁、分布式 ID 与租约里"租约到期只允许接管、不证明旧尝试未产生副作用"是同一个道理。
演进方向:任务数量增长 → 关注扫描耗时与行锁竞争,必要时按业务分库或迁移到支持分片的调度器;依赖复杂度增长 → 迁移到工作流调度器,把"任务编排"从业务代码里移出去;跨机房/高可用要求 → 调度器自身的 DB 高可用成为核心指标,执行器多副本 + 路由策略(FAILOVER/BUSYOVER)成为常态。
实验读法
对应 labs/data-scheduler 的 7 组场景,建议按下面的观察行复读(全部来自同一次 exit 0 的运行):
U85 dagChain/U85 dagMissingChild:DAG 链各 1 次;删除子任务后父任务静默。U85 blockDiscard ... executorRejected=True/U85 blockSerial ... serializedByMarker=True:丢弃策略trigger_code=500, handle_code=0且执行器返回拒绝文案;串行策略两次都 200 且第二个在第一个结束后才开始。U85 retryCodes=[500,500,200] retryLeft=[2,1,0]:失败重试递减。U85 misfireSkip logs=0/U85 misfireOnce logs=1 compensation=True:过期只补一次。U85 gracefulKill:killed与not executed两种失败回调;registryRemoved=True。U85 crash11minButRegistryAliveAfter70s handleCode=0→U85 crashRegistryExpired→U85 crashLostJob:丢失判定的双条件与最终收敛。U85 duplicateFirst14sTwoAdmins=5 next22sAdded=11 duplicateSameSecond=0 executorRepeatTriggers=0:双 admin 互斥,执行器 logId 去重未被触发;U85 schedulerFailover/U85 restartResume newRows=5:切换与重启不补跑。
条件变化与未运行边界
- 未运行:COVER_EARLY 覆盖策略的真实打断;
executor_timeout超时(502)的真实触发;分片广播SHARDING_BROADCAST;故障转移FAILOVER/ 忙碌转移BUSYOVER路由;多执行器实例的负载分布;回调落盘重试(第四层)只在源码层解释、未构造"执行器存活但回调不可达"的实验;时间轮"处理跨刻度往前两格"与distinct()去重未构造长耗时场景,只由源码与同秒计数佐证。 - 未覆盖的网络拓扑:admin 与执行器分机器(跨主机时钟偏差)、跨机房 DB、网络分区下的行锁行为。
- 未覆盖的故障模型:MySQL 主从切换、DB 全停、admin 进程在执行中崩溃、磁盘满导致日志写失败。
- 实验操作说明:丢失任务的 10 分钟阈值与 90 秒注册过期,在实验里通过回拨时间戳构造(
trigger_time/registry.update_time),这是对真实判据的受控加速,不是生产事实;misfire 的停机也是直接改库时间戳构造的,不代表 admin 真的停了 65 秒。 - v3.5.0 专属:本页的表结构、OpenAPI 端点与线程行为都以 3.5.0 为准;其他 3.x 版本未逐项核查,不应把本页的接口细节直接套用到旧版本(升级前应按目标版本的官方 SQL 与源码重新核对)。
- "调度一次"不等于"业务执行一次":所有恢复路径都会重新执行 handler,业务幂等必须自己做。
闭卷验收
本页 20 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| xxl-job 3.5.0 源码(固定提交) | 3.5.0 | admin 调度/触发/回调/注册、执行器线程与阻塞策略 |
| 官方建表 SQL | 3.5.0 | 表结构与字段语义 |
| xxl-job 官方文档 | 3.5.0 对应 | 调度类型、阻塞策略、misfire 的产品语义(与源码对照阅读) |
| 本机运行 | xxl-job 3.5.0 / MySQL 8.4.7 / JDK 21.0.11 | 7 组场景、exit 0,证据见 labs/data-scheduler/evidence/u85-2026-10-27.json |
访问与运行日期:2026-10-27。源码片段为固定 tag 的连续行段,整文件 SHA-256 随每段元数据登记;运行版本以 jar 的 SHA-256 与启动日志为准。产品文档、源码与实验是三类证据,不能互相替代。