主题
任务生命周期与状态字段
一句话: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_info | trigger_status | 0 / 1 | 任务停止 / 运行;只决定会不会被扫到 |
xxl_job_log | trigger_code | 0 / 200 / 500 | 调度记录初始值 / 调度成功 / 调度失败 |
xxl_job_log | handle_code | 0 / 200 / 500 | 尚未收到结束回调 / 执行成功 / 执行失败 |
xxl_job_log | handle_time | datetime | 执行结束时间,不是开始时间 |
trigger_status 不是状态机,只是启停开关。真正描述一次执行的是 xxl_job_log 的一行记录。
一次执行的时间线
- cron 到点或用户点「执行一次」,Admin 插入一条
xxl_job_log:trigger_time=now,trigger_code=0,handle_code=0。 - Admin 用这条记录的
id作为logId,组装TriggerParam,POST/run。 - Executor 把触发放进该 jobId 的
JobThread队列并返回 200;Admin 把trigger_code改成 200。若连不上或执行器地址为空,改成 500,trigger_msg写原因。 - Handler 开始执行。没有「开始执行回调」,所以此时
handle_code仍然是 0。 - Handler 结束、抛异常或被判定超时,Executor 的
TriggerCallbackThread发一次/api/callback。 - Admin 按
logId更新:handle_time=now,handle_code=200/500,handle_msg写结果。 - 如果回调一直没来,并且执行器已经从注册表消失超过 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 与回调时间差作为观测口径。调度侧的触发机制见 调度核心机制。
最小上手
- 在「执行器管理」确认 appname 在线。
- 在「任务管理」新建任务:Cron
0/30 * * * * ?,JobHandlerdemoJobHandler,路由FIRST,阻塞SERIAL_EXECUTION,超时 0,失败重试 0。 - 启动任务,点一次「执行一次」。
- 在 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=500 | Admin 侧失败:看 trigger_msg,常见是「执行器地址为空」「connect timed out」 |
| 页面一直「执行中」 | trigger_code=200、handle_code=0 | Executor 收下了但没有结束回调:查 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