
先说一个故障现场。某个工作日的早上项目群里突然开始刷屏所有定时任务都不跑了。登录 xxl-job 控制台一看调度记录停在了几分钟前执行器心跳还正常但任务列表里没有任何新的触发记录。翻调度中心日志看到了非常扎眼的报错Deadlock found when trying to get lock; try restarting transaction。这个报错指向的就是数据库死锁。数据库死锁本身不算罕见但死锁能波及到 xxl-job让原本按时触发的定时任务全部停摆说明问题早就不是某个任务 SQL 写坏这么简单了——是锁的竞争已经影响到了调度链路本身。大学里做数据库课程设计时几乎没人关心锁但线上生产环境并发一上来死锁就是真刀真枪的坑。这篇文章从一次真实的数据库死锁导致 xxl-job 任务不自动执行的故障出发把死锁的成因、排查方法、xxl-job 的调度锁机制、以及最终的预防方案完整拆一遍。适合所有用 xxl-job 做调度、同时被 MySQL 锁问题困扰过的后端开发同学。看完之后至少你能独立定位“任务为什么停了”这一类问题并且知道怎么避免它再次发生。1. 问题表象与影响分析1.1 故障现场任务“不自动执行”的几种表现“不自动执行”这个词其实很笼统。我在不同项目里见过好几种表现背后原因完全不一样排查方向也不同。第一种是调度中心界面正常但任务列表里没有生成新的调度记录。这种情况基本可以断定是调度中心这一侧出了问题数据库死锁导致调度线程挂掉或者调度锁一直拿不到。第二种是调度中心显示任务已经触发了但执行器没收到。执行器注册信息和调度地址都对不上多半是执行器的心跳注册失效或者调度中心下发任务时写日志失败。第三种是执行器收到了调度但任务一直卡在“执行中”状态。这种情况往往是执行器里的某个 SQL 卡在数据库锁上事务一直不提交任务既没成功也没失败。第四种是任务明明执行失败了但后续的调度也不来了。这个在分片广播或者依赖上一次执行结果的任务里很常见前一次执行异常导致调度状态没有被正确重置。我那次遇到的是第一种和第三种的混合调度中心日志里死锁报错在疯狂刷同时有任务在数据库里卡了十几分钟没出来。1.2 影响范围为什么说这是高优故障xxl-job 承载的业务通常都是重后台逻辑订单超时处理、数据统计、报表生成、补偿任务、定时刷新缓存。这些任务一旦全部停摆影响面比单个接口故障大得多。订单超时没人关单、账单没有出、缓存没有刷新用户侧很快就能感知到。更要命的是调度中心挂掉不代表执行器挂掉。很多任务其实在执行器里还在跑但已经没有新的指令发过去了。业务方看到的现象是“任务好像没在跑”但实际上执行器里的老任务可能还在消耗数据库连接和 CPU。所以排查这种问题第一件事就是分清“调度中心没触发”和“触发了没执行成功”两个层面。这两个层面共存的情况也很常见——调度线程被死锁干掉之后正在执行的老任务还会继续占着数据库资源进一步加剧锁竞争形成恶性循环。2. 死锁发生的底层原理为什么会出现锁竞争2.1 死锁的四个必要条件与数据库场景对应聊死锁之前先过一遍理论。死锁的四个必要条件放到 MySQL InnoDB 的场景里是这么对应的互斥条件同一行记录的行锁在同一时刻只能被一个事务持有。这是 InnoDB 行锁天然的特性。占有且等待事务 A 锁住了订单表的一行然后继续去申请用户表的锁事务 B 锁住了用户表的某行然后继续去申请订单表的锁。两边都是手里攥着一把锁眼睛还盯着另一把锁。不可剥夺条件InnoDB 的行锁只能由持有者自己提交事务或者回滚才能释放数据库不会强行从 A 手里把锁拿走给 B。循环等待条件A 在等 B 持有的锁B 在等 A 持有的锁形成一个完整的等待环。只要破坏其中一个条件死锁就不会发生。这是后续所有优化方案的底层依据后面聊预防策略时我会反复回到这个框架。2.2 死锁常见触发场景实际生产环境里死锁最常见的触发场景有这么几类。第一类是多个事务对多张表按不同的顺序更新。还是那个最经典的例子事务 A 先更新订单表再更新用户表事务 B 先更新用户表再更新订单表。两个事务几乎同时启动A 锁住了订单行B 锁住了用户行然后互相等待。这种死锁最容易出现在定时任务里因为定时任务经常批量处理数据扫表范围大、锁的行多。第二类是批量 UPDATE 或 DELETE 时范围重叠。两个事务都在更新同一个范围内的数据比如一个UPDATE t_order SET status1 WHERE user_id IN (1,2,3)另一个UPDATE t_order SET status2 WHERE order_id IN (2,3,4)如果两条 SQL 的执行顺序和扫描主键顺序不同就会有锁的交叉等待。第三类是唯一键冲突。插入的数据碰到主键或唯一键冲突时InnoDB 会先检查后插入在这个过程中会加共享锁多个事务同时出入冲突记录时容易互相阻塞。第四类是间隙锁和插入意向锁的冲突。这个在 MySQL 默认的 RR 隔离级别下特别明显。事务 A 在一个范围上加间隙锁防止插入事务 B 试图在同一个间隙内插入新记录就会被阻塞如果两边都持有了对方需要的间隙锁就死锁了。2.3 为什么 xxl-job 会踩中死锁xxl-job 调度中心自己是有数据库操作的。调度线程在触发任务前会去xxl_job_lock表执行SELECT ... FOR UPDATE拿调度锁任务执行过程中执行器会向xxl_job_log写日志、更新状态执行器启动后还会周期性地向xxl_job_registry做心跳注册。这些操作全部依赖数据库锁。如果调度中心的库和业务库共用同一个 MySQL 实例业务任务里的大事务、长事务、乱序更新很容易就把锁竞争引到调度表上。我踩过的一个场景是某个业务任务在更新完订单表后又去更新用户表但中间有一段耗时很长的网络调用事务迟迟不提交导致xxl_job_log相关的插入操作被阻塞最终连锁引发死锁。这个就是典型的“业务表锁死波及调度表”。如果是达梦、人大金仓这类国产数据库语法和锁模型跟 MySQL 有差异但死锁的原理和排查思路是相通的核心还是看事务之间怎么互相等待。3. 数据库死锁排查实操从日志到定位3.1 第一步看 InnoDB 死锁日志排查数据库死锁第一件事永远是看死锁日志。核心命令就一条SHOW ENGINE INNODB STATUS\G输出内容非常长重点看LATEST DETECTED DEADLOCK段落。里面会显示两个事务的完整信息包括每个事务当前执行的 SQL、持有锁和等待锁的记录。典型结构是这样的------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 2012340001, ACTIVE 12 sec starting index read LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1024, OS thread handle 12345 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 100 page no 5 n bits 72 index PRIMARY of table test.t_order trx id 2012340001 lock_mode X waiting *** (2) TRANSACTION: TRANSACTION 2012340002, ACTIVE 15 sec started index read *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 100 page no 5 n bits 72 index PRIMARY of table test.t_order trx id 2012340002 lock_mode X *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 100 page no 5 n bits 72 index PRIMARY of table test.t_user trx id 2012340002 lock_mode X waiting *** WE ROLL BACK TRANSACTION (2)这个日志已经很直白了事务 1 在等t_order表的锁事务 2 持有t_order表的锁而且在等t_user表的锁。再结合事务里正在执行的 SQL基本就能还原死锁现场。MySQL 5.7 默认只记录最近一次死锁如果死锁频繁建议打开参数innodb_print_all_deadlocks ON打开之后每次死锁都会进 error log不会因为下一次死锁覆盖掉上一次的记录。对排查高频死锁特别有用。3.2 第二步用系统表查当前锁等待死锁日志是历史的想实时看锁状态要用系统表。MySQL 5.7 用这三张SELECT * FROM information_schema.INNODB_TRX\G SELECT * FROM information_schema.INNODB_LOCKS\G SELECT * FROM information_schema.INNODB_LOCK_WAITS\GMySQL 8.0 之后INNODB_LOCKS 和 INNODB_LOCK_WAITS 被挪到了 performance_schema 下SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\G通过这些表能看到哪个事务持有锁、哪个事务在等待、阻塞了多久、正在执行什么 SQL。定位到具体事务后检查trx_started字段凡是执行时间超过几百秒的事务都要重点标记——这些长事务就是死锁和锁等待的根源。紧急情况下需要恢复服务可以直接 kill 掉阻塞源头的事务-- 先从 INNODB_TRX 里查 trx_mysql_thread_id SHOW PROCESSLIST; KILL 12345;注意KILL 之前一定要确认清楚别误杀主从复制线程或者重要的写入事务。最稳妥的做法是先把trx_started很久、阻塞了很多其他事务的线程挑出来人工确认后处理。3.3 用一条 SQL 查所有锁等待关系实际排障的时候我不太喜欢一张一张表翻更常用一条联合查询直接输出等待者和阻塞者的对应关系SELECT w.PROCESS_ID AS waiting_pid, w.THREAD_ID AS waiting_thread, l.TABLE_NAME, l.INDEX_NAME, l.LOCK_TYPE, l.LOCK_MODE, r.PROCESS_ID AS blocking_pid FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON w.REQUESTING_ENGINE_TRANSACTION_ID l.ENGINE_TRANSACTION_ID JOIN performance_schema.data_locks r ON w.BLOCKING_ENGINE_TRANSACTION_ID r.ENGINE_TRANSACTION_ID查询结果直接告诉你是谁卡了谁不用再拼凑多个表的数据。尤其是遇到多个事务连环阻塞的时候这张表比死锁日志更直观。配合SHOW PROCESSLIST看线程当前执行的 SQL马上就能找到问题代码。4. xxl-job 调度机制拆解锁在调度链路里的位置4.1 调度中心的调度锁机制xxl-job 调度中心是支持集群部署的。多个调度中心节点如果同时去触发同一个任务会造成重复执行所以调度中心在触发任务之前要先拿一把分布式锁。xxl-job 的锁实现非常简单粗暴数据库行锁。有一张表叫xxl_job_lock表里通过lock_name区分不同的锁最核心的就是schedule_lock。调度线程每次扫描未来一段时间内到期的任务之前会先执行这条 SQLSELECT * FROM xxl_job_lock WHERE lock_name schedule_lock FOR UPDATE拿到锁的节点才有资格去扫描任务并把任务放入时间轮没拿到锁的节点会等待或者直接跳过本轮调度。这个机制保证了集群环境下同一个任务只会被一个调度中心节点触发。问题就出在这个FOR UPDATE上。如果某个事务长期持有xxl_job_lock这行锁不释放或者多个事务在调度相关表上互相等锁形成死锁调度线程就会阻塞或者抛异常。xxl-job 的调度线程本身是个常驻后台线程一旦异常没被正确捕获线程直接退出整个调度中心就成了一个“看起来还活着实际什么活都不干”的空壳。界面正常心跳还在但任务一个都不会触发。4.2 执行器侧与数据库的交互执行器本身不直接依赖数据库做调度但它执行任务的过程中有大量数据库操作。执行开始要向xxl_job_log插入一条调度日志执行结束要更新日志状态和返回结果执行器启动后要周期性地向xxl_job_registry注册心跳。这些都是 INSERT 和 UPDATE 操作全都要走数据库锁。如果这些表出现锁等待或者死锁会出现一个特别迷惑人的现象任务其实已经执行完了但日志状态没有更新控制台上一直显示“执行中”。从业务角度看任务没有继续跑从调度中心看任务还占着一个执行中的名额。如果阻塞策略配置的是“单机串行”后续的调度还会因为上一个任务没结束而被拒掉看起来就更像“任务不自动执行”了。这里有个常见的大坑xxl_job_log表的数据量膨胀得特别快。如果不设置日志自动清理表越来越大插入日志时的索引维护成本变高锁竞争的概率也会跟着上升。xxl-job 本身支持按日志保留天数自动清理配置项就是logretentiondays别不设。4.3 死锁导致任务不自动执行的完整链路把整条链路串起来就是这次故障的全过程业务任务 A 在事务里更新了业务表过程中有耗时较长的操作事务迟迟不提交持有很多行锁。调度中心调度线程去扫描任务、更新调度记录时访问到相关表需要等待事务 A 释放锁。另一个事务 B 也在更新数据恰好需要调度线程持有的锁形成了循环等待。InnoDB 检测到死锁牺牲其中一个事务回滚抛Deadlock found when trying to get lock异常。调度线程正好是被牺牲的那一方异常没被妥善捕获调度线程直接退出。之后没有节点继续扫描过期任务所有任务停止触发。这条链路里最关键的一点是业务事务的长事务和乱序更新是根源调度表与业务表同库是放大器调度线程没有存活保护是最后一根稻草。三个条件叠加在一起才导致了这次全面停摆。如果用的是早期基于 Quartz 的 xxl-job 版本逻辑也类似只是锁表换成了QRTZ_LOCKS系列表。Quartz 集群调度时同样靠这些锁表做互斥锁表一旦被堵表现和上面一模一样调度线程卡死任务不执行。5. 解决方案与预防策略5.1 故障应急先恢复再根治遇到任务全面停摆第一优先级是恢复服务不是分析根因。最快的恢复手段是重启 xxl-job-admin。大多数情况下调度线程退出后重启就能立刻恢复。重启之前先确认一下数据库连接池没被占满如果连接被长时间等待的任务吃完了重启也白搭起来以后照样连不上库。第二步是把仍然在跑的长事务找出来。用前面提到的方法定位trx_started很久的事务确认是提交还是回滚。事务堵的时间越长锁竞争越严重。第三步是处理积压的业务。任务停了一段时间肯定有一批订单、数据没有处理。等调度恢复后根据业务情况决定是手动触发一次还是等下一次定时调度。注意检查一下任务的“过期策略”如果配置的是“忽略过期任务”那错过的时间窗口就找不回来了必须手动补一次。5.2 数据库层面从源头消除死锁从根上治理围绕死锁的四个必要条件逐个击破。第一给 WHERE 条件涉及的列建立合适的索引。没有索引导致的锁问题最冤枉明明只想更新一行结果全表扫描锁范围被扩大到一个表死锁概率几何级上升。用EXPLAIN看执行计划有 type 为 ALL 的就要警惕。第二多条记录更新时强制按固定顺序操作。比如同时更新订单表和用户表所有代码路径都先更新订单表再更新用户表破坏“循环等待”这个条件死锁天然不会发生。第三压缩事务长度。把一次更新几千行的大事务拆成小批量每批 100 条左右提交一次锁持有时间大幅缩短锁冲突的概率也会下降。第四评估隔离级别。如果业务能接受读已提交RC把隔离级别从默认的可重复读RR调成 RC很多间隙锁导致的死锁会自然消失。代码层面我推荐一个固定的套路死锁后重试也是很重要的兜底public void updateWithFixedOrder() { // 查询要更新的主键排序后分批更新 ListLong ids orderService.findPendingOrderIds(); ids.sort(Long::compareTo); for (Long id : ids) { try { orderService.updateStatus(id); transactionManager.commit(); } catch (DeadlockLoserDataAccessException e) { // 死锁被检测到后重试该批次 log.warn(deadlock occurred, retry id{}, id); orderService.updateStatus(id); } } }固定顺序 小事务 死锁重试这套组合下来能消掉绝大多数死锁问题。5.3 xxl-job 侧优化xxl-job 自身也要做几件事降低被业务连累的概率。调度中心尽量独立部署哪怕是同一条物理机上单独的数据库实例也比在业务库里建一套表强。业务表和调度表完全隔离业务死锁再严重也影响不到调度表。调度中心集群节点控制在 2~3 个就够了。节点再多调度锁的争抢反而更激烈并不能带来额外的高可用收益。xxl_job_log设置合理的日志保留天数比如 30 天让它自己清理历史日志。注意一下清理任务本身也会访问这张表所以清理时间窗口要设置在业务低峰期。调度中心要纳入统一的健康检查和告警。Spring Boot Actuator 的 health 端点接上监控系统要能第一时间发现调度中心异常。5.4 监控报警体系这次故障之后真正让团队不再被同样问题绊倒的是监控。分三层来建。数据库层监控活跃事务数量、锁等待时长、锁等待次数。活跃事务突然飙升或者锁等待时间变长都是长事务和锁竞争的前兆。调度层xxl-job 控制台能看到调度记录和失败记录配置 webhook 报警调度失败时第一时间通知。调度延迟也是重要指标调度时间和预期时间差超过阈值就要告警。应用层xxl-job-admin 的关键日志Deadlock、Lock wait timeout exceeded这些关键字要接入日志采集和告警。日志告警比什么都直接出了问题日志会第一时间告诉你。还有一个很实用的兜底方案写一个小任务每个周期检查xxl_job_log表里最近 N 分钟内的调度记录数量如果数量突然降为 0而正常业务还在运行就触发报警。这个方案能从业务视角直接覆盖“任务不自动执行”这个问题不会漏掉任何数据库告警没覆盖到的死角。6. 常见问题速查表这次排障过程中遇到的几个典型现象整理成一张速查表现象可能原因处理动作所有任务不触发调度中心界面正常调度线程退出xxl_job_lock 锁等待/死锁重启调度中心查死锁日志处理长事务单个任务一直显示“执行中”xxl_job_log 更新失败、执行器内 SQL 卡住查执行器日志查数据库锁等待kill 长事务调度中心显示调度成功执行器没收到执行器注册失效、地址不通检查 xxl_job_registry 心跳校验执行器配置任务偶发失败手动触发就成功业务表死锁事务被回滚查死锁日志优化事务内 SQL 顺序调度恢复后历史任务没有补跑过期策略配置为忽略手动触发一次补偿执行这张表不是全部情况但是覆盖了 xxl-job 死锁类问题最常见的五种表现。排障的时候可以先对着表定位再深入分析。最后再分享一个我个人的教训。发生这次故障之前我一直觉得死锁只是业务代码的问题跟调度框架没什么关系。后来才想明白xxl-job 调度中心本身就是个数据库重度依赖者它的正确运行建立在一套健康的数据库环境之上。现在我每次上线新的定时任务都会先检查任务里的 SQL 是不是按固定顺序更新数据事务里是不是有耗时操作同时坚持把调度库和业务库分开。踩过一次这个坑之后你会发现定时任务调度这种事宁可多做一层隔离也别赌“应该不会出问题”。