跳到正文

任务生命周期与状态字段 ​

一句话:XXL-JOB 没有一个「8 态状态机」,真实状态只落在两处——xxl_job_info.trigger_status 表示任务启停,xxl_job_log 的 trigger_code 和 handle_code 分别表示「调度是否送达」和「执行是否结束」;Admin 不感知执行过程,Executor 只在结束时回调一次。

适用版本:XXL-JOB 2.3.x / 2.4.0。SQL 以 MySQL 5.7/8.0 + 默认 schema 为准。

为什么需要 ​

排障时最怕把「调度失败」和「执行失败」混成一件事。任务没跑,可能是 Admin 根本没发出去,也可能是 Executor 收下了但 Handler 报错,还可能是执行完了但回调丢了。这三种情况在日志表里对应不同的字段组合,处置方式完全不同。

所以生命周期不是背状态名,而是记住三个边界:Admin 写调度记录 → Executor 接收 → Executor 回调结束。每跨一个边界,就有一个字段被改写。

核心机制 ​

两个地方,四个字段 ​

位置字段取值含义
xxl_job_infotrigger_status0 / 1任务停止 / 运行;只决定会不会被扫到
xxl_job_logtrigger_code0 / 200 / 500调度记录初始值 / 调度成功 / 调度失败
xxl_job_loghandle_code0 / 200 / 500尚未收到结束回调 / 执行成功 / 执行失败
xxl_job_loghandle_timedatetime执行结束时间,不是开始时间

trigger_status 不是状态机,只是启停开关。真正描述一次执行的是 xxl_job_log 的一行记录。

一次执行的时间线 ​

  1. cron 到点或用户点「执行一次」,Admin 插入一条 xxl_job_log:trigger_time=now,trigger_code=0,handle_code=0。
  2. Admin 用这条记录的 id 作为 logId,组装 TriggerParam,POST /run。
  3. Executor 把触发放进该 jobId 的 JobThread 队列并返回 200;Admin 把 trigger_code 改成 200。若连不上或执行器地址为空,改成 500,trigger_msg 写原因。
  4. Handler 开始执行。没有「开始执行回调」,所以此时 handle_code 仍然是 0。
  5. Handler 结束、抛异常或被判定超时,Executor 的 TriggerCallbackThread 发一次 /api/callback。
  6. Admin 按 logId 更新:handle_time=now,handle_code=200/500,handle_msg 写结果。
  7. 如果回调一直没来,并且执行器已经从注册表消失超过 10 分钟,JobLosedMonitorHelper 会把 handle_code 改成 500,消息是 joblog_lost_fail。

关键点:第 4 步到第 6 步之间,Admin 只知道「还没结束」,不知道 Handler 在干什么、跑了多久、是否卡死。handle_code=0 的准确含义是没有收到结束回调,不是「正在运行」。

阻塞策略:同一个 jobId 在上一个没跑完时又触发了 ​

策略Executor 行为/run 返回
SERIAL_EXECUTION(默认)放进该 jobId 的队列,等上一个跑完200
DISCARD_LATER线程正在跑或队列非空,直接丢弃本次500,trigger_code=500
COVER_EARLY给旧线程发中断,再排入新触发200

DISCARD_LATER 不是「悄悄跳过」:它会让 /run 返回失败,Admin 记一条调度失败日志。COVER_EARLY 的中断是协作式的,Handler 不响应 interrupt 时旧线程仍可能继续跑。

超时、重试、回调三件事不要混 ​

  • 执行超时:executor_timeout 默认 0(不限)。大于 0 时,Executor 在 JobThread 里用 Future.get(timeout) 等结果,超时后回调 500,消息类似 job execute timeout。它不保证 Handler 真的停下。
  • 失败重试:executor_fail_retry_count 默认 0。执行失败(handle_code=500)后,Admin 会以 TriggerTypeEnum.RETRY 再触发一次,产生新的 log 记录,不会复用原 logId。
  • 回调重试:Executor 回调失败时会把结果序列化到本地 callbacklog/xxl-job-callback-*.log,每 30 秒重试。它只补「结果上报」,不会重跑 Handler。

Admin 侧没有「按 executor_timeout 定时扫描并标记超时」的逻辑;真正兜底的是第 7 步的结果丢失检查,条件是「10 分钟 + 执行器不在注册表」。

实战场景 ​

一、handle_code=0 被当成“正在运行”,卡死两小时无人发现 ​

现象:日终清算任务的看板用 handle_code 判断执行状态:0 显示为“运行中”,200 成功,500 失败。某天任务在 02:00 触发后卡住(下游接口无超时),看板一直显示“运行中”,值班没有收到任何告警,直到 04:10 Admin 侧把这条日志改成失败(joblog_lost_fail)时,执行器实例已经被运维重启掉,现场没了。

排查:Admin 在第 4 步到第 6 步之间只知道“还没收到结束回调”。

定位层取证据判定
handle_code=0 的真实含义没有“开始执行回调”,Handler 起跑时它仍是 0含义是“没收到结束回调”,不是“正在运行”
handle_time该字段是执行结束时间为 null 只说明没结束,看不出何时开始
是否真在跑Executor 侧线程栈、JobThread 队列线程停在 socketRead 即卡住而非在算
谁把它改成失败JobLosedMonitorHelper:回调一直没来且执行器从注册表消失超过 10 分钟说明“卡住的实例还活着”时不会被标记
触发与执行的差距trigger_code=200 但无回调调度成功、执行结果未知,是两段独立事实
下游超时Handler 内的 HTTP/RPC 是否设超时无超时是长尾卡死的常见来源

对症做法:

java
// 1. 状态判定不要只看 handle_code:用“触发时间 + 预期时长”自己算是否超时
public JobHealth check(XxlJobLog log) {
    if (log.getHandleCode() == 200) return JobHealth.DONE;
    if (log.getHandleCode() == 500) return JobHealth.FAILED;
    long runningMs = System.currentTimeMillis() - log.getTriggerTime().getTime();
    if (runningMs > expectedMs(log.getJobId()) * 3) return JobHealth.STUCK;    // 经验倍率,需按任务校准
    return JobHealth.RUNNING;
}
java
// 2. Handler 内部所有外部调用必须有超时与可取消点
XxlJobHelper.log("start, shard={}", XxlJobHelper.getShardIndex());
for (List<Record> batch : splitter.of(records, 500)) {
    if (XxlJobHelper.getHandleResult() == FAIL) return;      // 协作式检查
    if (Thread.currentThread().isInterrupted()) { XxlJobHelper.handleFail("interrupted"); return; }
    service.process(batch, Duration.ofSeconds(30));          // 关键:外部调用带超时
}
text
3. 告警口径改成三条独立信号:
   - 触发失败:trigger_code=500(连不上执行器、地址为空)
   - 无结束回调:handle_code=0 且距触发时间超过预期时长 3 倍
   - 执行失败:handle_code=500
   只盯“失败”会漏掉“卡住”,只盯 handle_code 会把卡住误判成运行中。
4. 卡死任务要有主动中断手段:Admin 的“终止任务”按钮走的是协作式 interrupt,
   Handler 不响应就无效,所以超时必须写在业务调用里而不是依赖中断。

复盘要点:判据是“handle_code=0 的存活时长有独立告警”。这张表四个字段(trigger_status / trigger_code / handle_code / handle_time)的语义要写进值班手册,尤其是“trigger_status 只是启停开关、不是状态机”——把启停当状态判断会得出完全错误的结论。

二、COVER_EARLY 没中断旧线程,两个线程同时清算 ​

现象:清算任务原本用默认 SERIAL_EXECUTION,因为“上一天没跑完会把后面几天全堵住”改成 COVER_EARLY。某天旧任务在等待一个大 SQL(JDBC 不可中断),新触发进来后 Admin 给旧线程发了中断,旧线程没响应继续跑,两个线程同时执行同一天的清算,账面出现 2 套结果,人工对账一天。

排查:COVER_EARLY 的中断是协作式的。

定位层取证据判定
是否并发执行Executor 日志同一 jobId 的两个线程名与时间区间重叠重叠即双跑
Handler 是否响应中断卡住的位置是否是 JDBC/synchronized/native 调用不可中断点会让 COVER_EARLY 失效
阻塞策略语义COVER_EARLY:给旧线程发中断,再排入新触发,/run 返回 200新触发一定会执行,不保证旧的停下
数据是否互斥两次执行是否操作同一业务键同一天/同一批次必然冲突
幂等是否存在写入是否有唯一键或版本判定无幂等则双跑直接污染数据

对症做法:

java
// 1. 业务级“同一账期只允许一个执行者”:用数据库唯一约束或分布式锁
boolean mine = claim.tryAcquire("settle", settleDate, Duration.ofHours(6));   // 抢占式记账
if (!mine) { XxlJobHelper.handleFail("another run holds this date"); return; }

// 2. 中断要能被感知:把大 SQL 拆成可中断的小批,并设置 queryTimeout
stmt.setQueryTimeout(60);                       // JDBC 层超时,不依赖线程中断
for (Batch b : batches) {
    if (Thread.currentThread().isInterrupted()) { XxlJobHelper.handleFail("interrupted"); return; }
    process(b);
}
text
3. 长任务优先用 DISCARD_LATER 而不是 COVER_EARLY:
   新触发直接丢弃、/run 返回 500、Admin 记一条调度失败日志,
   至少保证“同一时刻只有一个实例在跑”。
   丢弃不是静默跳过:必须给 trigger_code=500 配告警,否则会积累成“天天失败但没人看”。
4. 真正会“堵住后面几天”的场景应该改设计:
   把任务参数化成“按日期分片”,让不同日期可以并行,而不是覆盖同一天。

复盘要点:判据是“同一 jobId 是否存在并发执行”,用执行日志的线程名与账期键做检测;同时“同一账期只有一份结果”应由数据层唯一约束保证,而不是靠调度策略。策略选择写成决策:需要严格串行用 SERIAL_EXECUTION,宁可丢触发不能双跑用 DISCARD_LATER,COVER_EARLY 只用于 Handler 可中断且重复执行无害的任务。

三、超时判失败 + 自动重试,结果执行了两次 ​

现象:任务配置里执行器路由正常、Handler 实际跑了 8 分钟成功,但任务设置的超时是 5 分钟。Admin 在 5 分钟时把这次执行判为失败并触发重试(失败重试次数 1),第二次执行同样跑完了全量。因为写库是“先删后插”,两次执行之间下游已经读取过中间状态,对账出现一天的缺口。

排查:超时、重试、回调是三件事,串起来会形成重复执行。

定位层取证据判定
超时判定方是 Admin 的超时还是 Executor 的中断超时判定不代表执行已停止
第一次是否成功Executor 日志与业务结果成功却被记失败,重试就是二次执行
重试次数配置失败重试次数、调度重试与执行重试是否叠加叠加会放大到多次执行
回调迟到第一次的结束回调是否在重试之后才到迟到回调会覆盖状态,掩盖真相
写入方式是否“先删后插”这类非幂等写法中间状态被读到即产生缺口

对症做法:

text
1. 超时必须大于最坏执行时长并留余量(例如 P99 × 3,经验值需校准),
   而不是“按感觉给个 5 分钟”。长任务改成分片/分批 + 进度记录。
2. 只有确认可重试的任务才配失败重试:
   重试的前提是“执行未产生副作用”或“副作用幂等”,两者都不满足就设 0。
3. 写入方式从“先删后插”改成 UPSERT + 版本判定:
   INSERT ... ON DUPLICATE KEY UPDATE,或直接按唯一键更新,
   避免中间态被读到、也避免重复执行污染。
sql
-- 幂等写法:按业务键更新,重复执行结果一致
INSERT INTO settle_result(settle_date, account_id, amount, version)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount), version = VALUES(version);
java
// 4. 进度化:任务记录已完成到的位置,重试从断点继续而不是全量重跑
long cursor = jobCursor.load(jobId, settleDate);
for (Batch b : repo.fetchAfter(cursor, 1000)) { process(b); jobCursor.save(jobId, settleDate, b.lastId()); }

复盘要点:判据是“重试次数 > 0 的任务,其执行体必须满足幂等或断点续跑”,这条要作为任务注册时的表单校验项(否则不允许配置重试)。另外回调迟到导致的“状态被覆盖”要能在日志里看出来:保留每次触发的 logId 与回调时间差作为观测口径。调度侧的触发机制见 调度核心机制。

最小上手 ​

  1. 在「执行器管理」确认 appname 在线。
  2. 在「任务管理」新建任务:Cron 0/30 * * * * ?,JobHandler demoJobHandler,路由 FIRST,阻塞 SERIAL_EXECUTION,超时 0,失败重试 0。
  3. 启动任务,点一次「执行一次」。
  4. 在 Admin 的「调度日志」里点开最新一条,再用 SQL 对照:
sql
-- MySQL 5.7/8.0,xxl_job_log 默认字段
SELECT id, job_id, executor_address,
       trigger_time, trigger_code, trigger_msg,
       handle_time, handle_code, handle_msg,
       TIMESTAMPDIFF(SECOND, trigger_time, handle_time) AS elapsed_s
FROM xxl_job_log
WHERE job_id = 1
ORDER BY id DESC
LIMIT 5;

逐字段读:

  • trigger_time:Admin 写日志并发起调度的时间。
  • trigger_code=200:Admin 成功把 /run 送达 Executor;500 表示没送达,看 trigger_msg。
  • handle_time:Executor 回调到达 Admin 的时间,也就是执行结束时间;不是开始时间。
  • handle_code=200:Handler 正常结束;500:异常、超时、被终止或结果丢失。
  • elapsed_s:从调度到结束的墙钟时间,包含排队、网络和执行,不等于 Handler 纯执行耗时。

生产动作 ​

默认值(2.4.0):executor_block_strategy=SERIAL_EXECUTION、executor_timeout=0、executor_fail_retry_count=0、misfire_strategy=DO_NOTHING、executor_route_strategy 在新建页面默认 ROUND(表字段本身可为 NULL,以页面/版本为准)。

按字段组合排障:

现象证据判断与处置
任务没跑trigger_code=500Admin 侧失败:看 trigger_msg,常见是「执行器地址为空」「connect timed out」
页面一直「执行中」trigger_code=200、handle_code=0Executor 收下了但没有结束回调:查 Executor 日志、线程栈、callbacklog/ 目录
执行失败handle_code=500看 handle_msg;若是 job execute timeout,调大超时或优化任务
结果丢失handle_code=500、handle_msg=joblog_lost_fail回调没来且执行器已从注册表消失;业务结果可能实际成功,必须按业务幂等核对
长期卡住handle_code=0 且超过 10 分钟用下面的 SQL 找出来,再对照 xxl_job_registry 判断执行器是否还活着
sql
-- MySQL 5.7/8.0:找出调度成功但长时间没有结束回调的记录
SELECT l.id, l.job_id, l.executor_address, l.trigger_time,
       TIMESTAMPDIFF(MINUTE, l.trigger_time, NOW()) AS age_min
FROM xxl_job_log l
WHERE l.trigger_code = 200
  AND l.handle_code = 0
  AND l.trigger_time < DATE_SUB(NOW(), INTERVAL 10 MINUTE)
ORDER BY l.trigger_time;

判定回调丢失:Executor 本地日志显示 Handler 已成功 → Admin 日志没有对应的 handle_time → Executor 的 callbacklog/ 下有文件或历史报错 → 再确认 Admin 在回调窗口内是否重启过。满足这些条件,先按「结果已产生、状态未上报」处理,不要直接重跑业务。

阻塞策略怎么选:对账、报表必须顺序执行,用 SERIAL,但要把 cron 间隔设得大于最大执行时间;非关键任务用 DISCARD_LATER,宁可少跑一次;实时性优先且旧结果无意义时用 COVER_EARLY。SERIAL 的队列没有上限,长期堆积会把 Executor 内存和线程拖垮,见 07-生产踩坑与运维兜底。

边界 ​

  • 没有「8 个调度状态」。trigger_status 只有 0/1;一次执行的结果由 trigger_code、handle_code 两个维度组合表达。
  • 没有开始回调。Executor 只在结束时回报一次,所以 Admin 无法知道任务何时真正开始,也无法知道执行进度。
  • handle_time 不是开始时间。要算 Handler 纯耗时,只能看 Executor 日志里的时间戳或业务埋点。
  • 超时标记不等于终止。Executor 侧的 Future.get 超时只是不再等待,Handler 线程可能还在跑;要停只能在 Admin 点「终止」,发 /kill,而且依赖 Handler 响应中断。
  • 不保证恰好一次。重试、手动触发、回调重放、业务重跑都可能产生多次执行,幂等必须由业务键保证。

讲清楚 ​

XXL-JOB 的生命周期可以只看三个边界:Admin 写调度日志并发出 /run,Executor 收下并执行,Executor 结束时回调一次。trigger_code 记录第一个边界是否成功,handle_code 记录第三个边界是否到达;中间的 handle_code=0 只表示「还没收到结束回调」,不代表任务正在正常运行。handle_time 是结束时间,所以 handle_time - trigger_time 是包含排队和网络的墙钟耗时。超时由 Executor 侧判断,失败重试会产生新的日志记录,回调重试只补状态、不重跑 Handler。排障时先看这两个 code 的组合,再决定是查 Admin、查 Executor,还是按业务幂等核对结果。

适用 XXL-JOB 2.3.x / 2.4.0 | 初稿 2026-10

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