跳到正文

生产踩坑与运维兜底 ​

一句话:XXL-JOB 的生产事故集中在五类——积压、回调丢失、重复执行、分片放大、日志表膨胀;它们都从「默认值 + 调度/执行/回调三段解耦」长出来,处置时先拿证据定位在哪一段,再动手。

适用版本:XXL-JOB 2.3.x / 2.4.0。以下 SQL 在 MySQL 5.7/8.0 + 默认 schema 下执行;table_rows 在 InnoDB 是估算值。

为什么需要 ​

XXL-JOB 的默认值偏向「不丢触发、不轻易终止」:串行排队、超时不限、失败不重试。单看每条都合理,叠在一起就会在流量或故障时放大成事故。更麻烦的是 Admin 不感知执行过程,页面上的「执行中」可能只是「没收到回调」,不是任务真的在跑。

所以运维动作的第一步不是重启,而是用日志字段和注册表把问题归到某一段。

核心机制 ​

一次执行只有三个边界,事故也只会发生在边界上:

  1. Admin → Executor:/run 是否送达,看 trigger_code。
  2. Executor 内部:Handler 是否跑完、是否卡住、是否积压,Admin 看不到。
  3. Executor → Admin:/callback 是否到达,看 handle_code / handle_time。

会放大问题的默认值:

默认值风险
SERIAL_EXECUTION同一 jobId 的触发无上限排队,cron 快于执行就积压
executor_timeout=0不判超时,卡死任务会一直占着 JobThread
executor_fail_retry_count=0失败不自动重试;调大后又可能重复执行
logretentiondays=30清理线程失效时日志表持续膨胀
心跳 30s / 剔除 90s执行器刚挂时,最多约 2 分钟仍会被调度到

最小上手 ​

先把四张诊断 SQL 存成巡检脚本:

sql
-- MySQL 5.7/8.0,库名 xxl_job
-- 1. 注册表年龄:正常应 <= 30s
SELECT registry_value, update_time,
       TIMESTAMPDIFF(SECOND, update_time, NOW()) AS age_s
FROM xxl_job_registry
WHERE registry_group = 'EXECUTOR'
ORDER BY age_s DESC;

-- 2. 调度成功但长期没有结束回调
SELECT id, job_id, executor_address, trigger_time,
       TIMESTAMPDIFF(MINUTE, trigger_time, NOW()) AS age_min
FROM xxl_job_log
WHERE trigger_code = 200 AND handle_code = 0
  AND trigger_time < DATE_SUB(NOW(), INTERVAL 10 MINUTE)
ORDER BY trigger_time;

-- 3. 日志表大小;失败数可按 job_id 对 handle_code=500 做 COUNT(*) 统计
SELECT table_name, table_rows,
       ROUND(data_length / 1024 / 1024 / 1024, 2) AS data_gb
FROM information_schema.TABLES
WHERE table_schema = 'xxl_job' AND table_name = 'xxl_job_log';

生产动作 ​

一、积压:执行速度长期小于触发速度 ​

现象:执行延迟越来越大,Executor CPU/内存升高,日志里大量 handle_code=0。

证据:jstack <pid> 看 jobId 对应的 JobThread 是否在跑、队列是否堆着大量 TriggerParam;xxl_job_log 里同一 jobId 的记录密集出现;Executor 日志显示每次执行耗时大于 cron 间隔。

根因:默认 SERIAL_EXECUTION 把每次触发都排进无界队列;cron 比执行快,队列只增不减。

处置:先在 Admin 暂停任务,阻止新触发;评估队列里的旧触发是否还有业务意义,没有就重启 Executor(会丢掉内存队列);把 cron 间隔调到大于最大执行时间,或改为 DISCARD_LATER;给任务设 executor_timeout。不要只调大线程池——同一 jobId 仍然只有一个 JobThread。

二、回调丢失:业务成功了,页面还在「执行中」 ​

现象:业务数据已正确,Admin 页面显示执行中或最终变成「结果丢失」。

证据:handle_code=0 且 trigger_time 超过 10 分钟;Executor 本地日志显示 Handler 已成功;xxl.job.executor.logpath/callbacklog/ 下有回调文件或报错;xxl_job_registry 里该地址已消失;Admin 在回调窗口内重启过。

根因:回调只在结束时发一次,失败后靠本地文件每 30 秒重试;Admin 长时间不可达、Executor 被杀、磁盘不可写,都会让结果到不了。Admin 的 JobLosedMonitorHelper 只在「超过 10 分钟 + 执行器不在注册表」时把记录标成 500,消息是 joblog_lost_fail,它不代表业务失败。

处置:先按业务键核对结果,不要直接重跑;恢复 Admin 连通性,让 callbacklog/ 的重试线程把结果送出去;Admin 发版做优雅下线;业务侧必须幂等。监控 callbacklog/ 的文件数和目录大小。

三、重复执行:同一时刻出现多条日志 ​

现象:同一个 cron 时刻产生两条以上 xxl_job_log,或下游收到重复数据。

证据:按 trigger_time 排序看多条记录的间隔;查 executor_fail_retry_count 是否大于 0;确认是否有人点了「执行一次」;确认同一 appname 是否被多个环境注册到同一个 Admin;确认 /run 是否发生过 read timed out(Admin 记失败,但 Executor 可能已经执行)。

根因:XXL-JOB 是至少一次语义。失败重试、手动触发、超时后的再次调度、多环境共用 appname,都会产生重复。xxl_job_lock 只保证同一轮扫描不重复认领,不是业务级分布式锁。

处置:用业务唯一键(日期 + 任务 + 维度)做幂等;不同环境用不同 appname 和 Admin;谨慎调大失败重试;不要靠「查日志表有没有跑过」来防重,日志写入本身也可能延迟。不要再尝试用 UPDATE xxl_job_lock 去「修锁」,原因见下一节。

四、分片放大:一次触发打出 N 个请求 ​

现象:多个分片任务同一时刻触发,Admin 触发线程池排队,Executor 出现大量 /run 超时,下游 QPS 突增。

证据:SHARDING_BROADCAST 任务数 × 在线实例数 = 瞬时请求数;Admin 日志里 /run 的 read timed out 增多;下游监控出现与实例数同阶的尖峰;xxl_job_log 每次触发产生 N 行。

根因:分片广播按注册实例数 fan-out,没有全局并发控制;多个任务又用了相同 cron。

处置:把分片任务的 cron 错开;控制单组实例数;在 Handler 入口加信号量/限流;把大分片拆成批量;做容量规划时把「任务数 × 实例数」算进去。先限流,不要先调大 Admin 线程池。

五、日志表膨胀:磁盘被 xxl_job_log 吃满 ​

现象:MySQL 磁盘使用率告警,xxl_job_log 行数和体积持续增长。

证据:上面的表大小 SQL;xxl.job.logretentiondays 配置;Admin 日志里清理线程是否报错;是否存在长事务或主从延迟导致 DELETE 跟不上。

根因:默认 schema 没有分区,清理是按主键分批 DELETE;保留天数配置为 0 或清理线程失效时,表只增不减。

处置:确认并开启 xxl.job.logretentiondays(默认 30 天);紧急清理用分批 DELETE ... LIMIT,不要一条大 DELETE;历史数据先归档;监控磁盘 80%。只有在自建了按 trigger_time 分区的表之后,才存在 DROP PARTITION 这条路——分区表要求所有唯一键包含分区列,主键通常要改成 (id, trigger_time),这是改造项,不是默认运维手段。

六、调度全停:锁等待与错误处置 ​

现象:所有任务都不触发,Admin 日志显示调度线程阻塞,或 xxl_job_lock 相关查询变慢。

证据:

sql
-- MySQL 5.7/8.0:先看谁在跑,再定位事务
SHOW PROCESSLIST;
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING' ORDER BY trx_started;
-- MySQL 8.0 还可查 performance_schema.data_locks

根因:某个会话在事务里持有 schedule_lock 的行锁(慢查询、网络中断、Admin 线程卡住),其他 Admin 的 SELECT ... FOR UPDATE 一直等。

处置:找到阻塞的 trx_mysql_thread_id,确认它属于哪个 Admin/会话后 KILL <id>;必要时重启对应 Admin。不要用 UPDATE xxl_job_lock 释放锁:行锁由事务持有,UPDATE 只会加入等待队列,等到 innodb_lock_wait_timeout(默认 50s)后报错,既不放锁还可能加剧阻塞。也不要在调度运行时 TRUNCATE/DROP 这张表。

监控阈值 ​

指标阈值说明
注册行年龄> 90 秒与 DEAD_TIMEOUT 一致,不要写成 5 分钟
handle_code=0 时长> 10 分钟结合执行器是否在线判断是运行中还是丢失
单任务失败率按业务定,例如近 1 小时 > 5%用 trigger_time 窗口统计
xxl_job_log 体积 / 磁盘、Admin JVM / DB 池磁盘或池 > 80%,heap > 85%清理失效、调度或回调受影响

边界 ​

  • 状态不是执行真相:handle_code=0 只表示没收到结束回调;joblog_lost_fail 也不代表业务失败。
  • 没有业务级 Exactly-Once:重试、超时、手动触发都可能重复,幂等只能由业务做。
  • 默认没有分区:DROP PARTITION 不是默认可用的清理手段。
  • 锁表不能手工「解锁」:行锁属于事务,正确手段是定位并 KILL 阻塞会话;90 秒剔除也有窗口,执行器刚挂时最多约 2 分钟仍可能被调度。

讲清楚 ​

XXL-JOB 的线上问题基本都能归到三段:Admin 发 /run、Executor 跑 Handler、Executor 回 /callback。积压是第二段——默认串行队列无界,cron 比执行快就会堆;回调丢失是第三段——结果只在结束时上报,重试靠本地文件,Admin 长时间不可达就会丢;重复执行是整条链路至少一次语义的体现,失败重试、手动触发、超时后的再次调度都会产生新记录;分片放大是第一段的 fan-out,任务数乘实例数就是瞬时请求数;日志膨胀是存储段——默认没有分区,只能分批 DELETE,保留天数失效就涨。处置顺序永远是先拿证据定位到哪一段,再决定暂停、限流、重启还是补数。xxl_job_lock 的锁等不要用 UPDATE 去解,行锁由事务持有,正确做法是找阻塞会话并 KILL。

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

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