数据库死锁引发xxl-job任务不执行:完整排查与修复实战

发布时间:2026/9/13 13:04:42
数据库死锁引发xxl-job任务不执行:完整排查与修复实战
早上九点业务群里突然冒出一条消息“昨天的财务报表怎么没出来”我打开xxl-job调度中心看了一眼任务明明显示“执行成功”执行耗时也正常。但翻业务库报表表里空空如也。这种“调度平台全绿、业务数据全空”的现场往往比直接报错更棘手因为你根本不知道应该去查哪里。顺着执行器日志、数据库连接池、活跃事务一路查下去最后定位到了数据库死锁上——凌晨的定时任务在数据库层和另一个事务互相锁死任务线程被卡住执行器线程池被慢慢耗尽后续调度根本拿不到线程表现就是“任务不自动执行”。这篇文章我把完整的排查链路和修复方案整理出来项目里用的是MySQL InnoDB引擎和xxl-job遇到类似问题的朋友可以直接照这个思路走。1. 周一早上的事故现场报表没生成调度平台却一片绿1.1 业务反馈与第一轮排查当时我们的第一反应是“定时任务是不是没触发”。结果打开xxl-job调度日志最近的调度记录都在而且状态显示“成功”。这就很奇怪了——任务明明执行了数据却没写进去。第二轮开始查执行器所在服务的应用日志。Spring Boot服务里集成了xxl-job执行器日志中确实能看到任务被调度进来的记录但再往下就没有任何业务日志了。也就是说任务线程跑进来了卡在了某个地方既没有正常结束也没有抛出异常。这时候去看数据库连接池我们用的Druid活跃连接数已经逼近最大值大部分连接被同一个任务的SQL占着。再执行下面这条SQL查了一下当前事务果然发现有一批事务已经运行了几分钟甚至十几分钟一直处于RUNNING状态SELECT trx_id, trx_state, trx_started, trx_query, trx_mysql_thread_id FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY trx_started;看到结果那一刻基本可以确定问题不是出在xxl-job调度逻辑上而是数据库层有长期未提交/未结束的事务把任务线程全部拖住了。1.2 调度链路拆解调度成功、执行成功、业务成功不是一回事很多人一看到“xxl-job任务不自动执行”第一反应就是去查调度中心但调度中心只是负责“下发指令”。完整的链路其实是这样的调度中心xxl-job-admin根据cron表达式触发任务生成一条调度记录把任务参数发给执行器。执行器业务服务里的JobThread接手参数丢到线程池里真正执行业务逻辑。业务逻辑执行完毕执行器把结果回调给调度中心调度中心更新调度日志状态。这里有一个很容易被忽略的点调度中心显示“成功”只代表执行器把任务“接过去”了并且回调时返回了成功标记。如果业务代码内部把异常吞掉了或者任务线程压根没执行完就被中断调度中心看到的可能依然是成功。所以遇到“日志显示成功但数据没变”别急着怀疑调度先从执行器日志和数据库状态下手。还有另一个极端情况任务线程卡死之后执行器的线程池会被慢慢占满。xxl-job执行器默认的线程池核心线程数是200也就是说如果同时有200个任务线程卡在数据库锁上后续的调度就算正常下发执行器也没线程去跑任务只能排队或被丢弃。从业务视角看就是“不再自动执行”了。2. 死锁四要素全部命中的锁等待链两个事务的互相较劲2.1 死锁四要素这次事故全踩中了搞数据库的人应该都背过死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。教科书上的定义很抽象套到这次事故里就非常具体了。互斥MySQL InnoDB的行锁本身就是互斥的同一行记录同一时刻只允许一个事务持有X锁排他锁。持有并等待事务A已经持有users表某些行的S锁共享锁又想拿orders表某些行的X锁但X锁被事务B拿着于是A进入等待状态。与此同时事务B已经持有orders表的X锁又想拿users表的X锁而users表的那部分锁正被A持有B也在等待。不可剥夺InnoDB不会主动去抢事务A或事务B手里的锁除非死锁检测机制介入回滚其中某个事务。循环等待A等B释放orders锁B等A释放users锁形成一个完整的环形等待链。四个条件同时成立死锁就产生了。这里有一个很关键的心态不要觉得“死锁是数据库随机发生的怪事”绝大多数死锁都是事务设计时锁顺序不一致造成的。这次事故里报表定时任务先查users表统计再更新orders表写结果而另一个后台订单同步任务正好相反先更新orders表再更新users表。两个事务的锁获取顺序完全相反只要执行时间重叠死锁几乎是必然的。2.2 InnoDB的死锁检测与回滚机制为什么任务还会失败MySQL InnoDB实际上是有死锁自动检测机制的默认开启。它内部维护了一张锁等待的依赖图一旦检测到有向图中出现循环就会立刻选择回滚其中一个“代价较小”的事务通常是根据事务影响的记录行数、undo量等维度判断然后释放它持有的锁让另一个事务继续跑。听起来很智能但实际运行中有一个被很多人忽略的后果被回滚的那个事务在应用层会抛出一个异常典型的错误是这样的Deadlock found when trying to get lock; try restarting transaction也就是说死锁的“物理问题”数据库已经自动解决了但“业务问题”没解决。如果应用代码没有捕获这个异常并做重试那么这一次任务就是失败的。我们的报表任务恰恰属于这种情况凌晨被死锁干掉一次事务回滚后任务线程终止但因为xxl-job上配置的失败重试次数是0后面不会再自动补跑于是报表就漏了。还有一个更隐蔽的问题死锁检测虽然能处理“真正的死锁”但处理不了“长时间的锁等待”。比如某个事务持有orders表的一批行锁迟迟不提交其他事务就一直在innodb_lock_wait_timeout默认50秒之内默默等待。这种场景下数据库不会把它判定为死锁但线程照样被卡住。任务线程一旦卡住线程池资源就被白占后面的任务全部排队等不到线程这才是“任务不再自动执行”最直接的原因。3. 从执行器日志到innodb_status的排查链路一步步锁定真凶3.1 执行器日志先确认任务卡在数据库层排查这类问题我习惯按“应用日志 → 连接池 → 数据库状态”的顺序来。先看执行器服务的完整日志重点找任务调度进来的时间点再找该线程后续是否输出了任何SQL日志、异常堆栈或者业务日志。如果发现有调度记录但后续毫无日志很可能就是卡在数据库操作上。此时去看连接池状态Druid的监控页面或者后台统计如果有配置可以看到活跃连接全部卡住且getConnection耗时飙升基本可以断定是数据库侧的问题。这里有个小经验一定要把执行器的日志级别在排查期间临时调整到DEBUG尤其是mybatis或者JPA的SQL日志这样能看到任务线程到底卡在哪个SQL上。我们当时把SQL日志打开后发现任务卡在一条更新orders表的SQL上执行计划走的是全表扫描。3.2 查information_schema找到锁等待源头确认了卡在数据库层下一步就是实时查看锁等待关系。MySQL 5.7及以上版本可以直接用sys库视图非常方便-- 查看当前锁等待关系包括阻塞者与被阻塞者 SELECT * FROM sys.innodb_lock_waits\G这个视图会返回waiting_pid、blocking_pid等字段直接告诉你哪个线程在等锁、被谁阻塞了。我当时查到的结果是报表任务线程waiting_pid在等待一个orders表行锁而阻塞它的线程blocking_pid是后台订单同步任务它已经持有了orders表相关行锁同时又在等待users表的锁。如果只想看事务概览用之前提到的那条innodb_trx查询就行。看的时候注意两个字段trx_started表示事务开始时间trx_state是事务状态。如果有事务运行了几分钟还处于RUNNING状态且它的SQL非常长或者已经查不到当前SQL了说明它在等待拿锁这种基本就是锁等待链的“源头”。3.3 开启死锁日志还原抢锁顺序实时锁状态只能告诉你“现在谁在等谁”要还原死锁的完整经过还需要死锁日志。用这条命令可以查看最近一次死锁的详细信息SHOW ENGINE INNODB STATUS\G输出内容里有一个段落叫“LATEST DETECTED DEADLOCK”里面会列出发生死锁的两个事务分别持有哪些锁、在等待哪些锁、执行的最后一条SQL是什么。一般看这个段落就能还原抢锁顺序。但这里有一个坑SHOW ENGINE INNODB STATUS只能看到最近一次死锁的记录如果你的系统死锁频繁可能你看到的是其他任务的死锁而不是导致报表任务失败的那一次。所以在排查阶段建议全局开启所有死锁的记录SET GLOBAL innodb_print_all_deadlocks ON;这个参数开启后所有死锁都会打印到MySQL错误日志error log里不会覆盖。生产环境可以长期开启对性能影响很小但排查问题时的收益非常大。我们当时就是靠这个参数在错误日志里同时找到了好几份死锁记录发现报表任务和订单同步任务的死锁已经发生过多次只是之前没有触发告警都被忽略了。4. 根因还原定时任务和后台同步任务踩了同一批索引4.1 两条SQL的加锁范围分析死锁日志还原完之后我们把两条SQL单独拎出来分析。报表任务的核心逻辑是先查询users表汇总数据此时对users表部分记录加了S锁。把汇总结果写入orders表需要更新该表对应的记录加X锁。订单同步任务的核心逻辑是先根据状态字段拉取需要处理的订单然后更新orders表对若干行加X锁。更新关联的users表状态对users表记录加X锁。两个任务都涉及orders表和users表但加锁顺序完全相反。数据库死锁日志里显示的场景也验证了这一点报表任务持有users表某条记录的S锁等待orders表记录的X锁订单同步任务持有orders表记录的X锁等待users表某个记录的X锁。循环等待形成MySQL选择回滚了其中代价较小的事务。这里面还有一个放大问题订单同步任务更新orders表时where条件用的是一个区分度很低的索引甚至在某些数据量下执行计划直接走了全表扫描。全表扫描意味着它锁的范围不只是某几行而是把扫描过程中碰到的所有行都上了锁。锁范围越大和别的任务撞车的概率就越高这也是为什么这次死锁会在数据量上涨后才开始频繁出现——以前orders表就几万行全表扫描也很快锁很快就还回去了现在表涨到了千万级一次全表扫描加锁的时间从几十毫秒变成几秒和定时任务的重叠窗口大大增加。4.2 为什么是偶发数据量、索引选择与执行计划排查死锁时一定会遇到这样的疑问同一个任务之前跑得好好的为什么偏偏昨天死了死锁本质上是一个“概率事件”它需要两个事务在时间窗口内重叠并且锁集合产生交集。以下几点都会让概率变大数据量增长导致SQL执行计划变化。比如某个SQL之前走索引数据量增加到一定程度后优化器认为全表扫描更快于是加锁范围变大。业务流量突增。比如某个时间段订单数量激增订单同步任务的执行频率或单次处理条数变高了。定时任务时间调整。如果报表任务提前执行和订单同步任务的高峰期重合死锁概率就会上升。我们复盘时还发现一个重要的细节订单同步任务里有一段调用外部接口的逻辑是在事务内执行的也就是说它拿到orders表的锁之后去调用外部RPC等RPC返回后才继续执行后面的users表更新。这个外部接口正常情况下几十毫秒但某一次网络抖动就可能耗时几秒甚至十几秒。在这段时间里它一直持有着orders表的锁不放极大拉长了锁等待链。事务中不要做远程调用这条铁律真的不是说着玩的。5. 止血和根治kill阻塞会话之后还得改掉四个坏习惯5.1 快速止血kill阻塞会话与手动重跑线上问题第一时间是止血不是开长会讨论设计。当时我们的操作分三步第一步确认哪些事务是阻塞源头。通过sys.innodb_lock_waits查到blocking_pid再结合innodb_trx里的trx_started判断这些阻塞事务是不是正常的业务事务。如果是异常遗留直接kill掉对应的MySQL线程-- 根据之前查询到的 thread id 执行 KILL 12345;第二步让执行器线程池里卡住的任务尽快超时释放。同时重启了执行器服务或者等待线程池任务超时保证后续调度有线程可用。这一点要看你们服务的容错能力如果任务里没有做数据库操作超时配置等待时间可能很长。第三步在xxl-job调度中心手动触发一次报表任务确认数据能正常生成。这一步是为了验证“死锁源头已消除”如果手动触发仍然失败说明还有别的事务在抢占锁。三轮操作下来报表恢复输出业务侧暂时无感。5.2 根治一统一事务内的锁获取顺序止血之后是根治。最核心的一条就是所有涉及多张表更新的业务锁的获取顺序必须全局统一。你可以定一个规范比如“先更新主表再更新子表”“先更新users表再更新orders表”所有事务必须按这个顺序来。如果业务逻辑确实比较乱至少保证同一批表之间不会出现互相反着锁的情况。具体到代码层面有几个手段把两个任务里涉及的表更新顺序调整成一致。如果改代码成本大可以引入业务层面的分布式锁比如Redisson的公平锁在任务入口处对同一个key加锁人为串行化同一批数据上的操作。使用“先查后锁”只能缓解真正解决问题还得靠统一顺序。还有一个小技巧在代码里对事务做重试。Spring的Retryable注解或者手动catch死锁异常后重试能让偶发的死锁自愈而不是任务直接失败。注意重试的次数要在合理范围13次每次重试间隔几百毫秒避免死锁持续时疯狂重试打爆数据库。5.3 根治二索引优化缩小锁范围死锁发生的概率和锁范围成正比索引优化是缩小锁范围最直接的方式。订单同步任务里那条更新SQL原本的where条件是status和一个日期范围我们最终给它加了一个组合索引ALTER TABLE orders ADD INDEX idx_status_date (status, create_time);加完索引后执行计划从全表扫描变成索引范围扫描加锁的行数从几百万行降到了几十行。报表任务更新orders表的where条件也类似同样补了合适的索引。这里需要提醒的是索引不是越多越好组合索引要结合当前where条件的实际筛选度来设计建议先拿线上真实SQL跑EXPLAIN确认执行计划再落地。另外不要在事务里一次性更新大量数据。如果同步任务需要处理几万条订单可以分批处理比如每次只更新500条循环执行。这样每个事务的持锁时间都极短即便偶发死锁影响范围也很小。5.4 根治三xxl-job侧的超时、重试与阻塞策略xxl-job本身提供了几个保护措施排查完数据库问题后务必把这些配置检查一遍。第一是任务超时时间。编辑任务时可以设置“任务超时时间”单位是秒默认0表示不限制。如果任务线程卡死不限制超时会让线程池被白白占满。我们给所有核心任务都设置了超时时间一般是35分钟超时后执行器会自动中断任务线程避免无限卡死。第二是关键中的关键——阻塞处理策略。xxl-job提供了三种单机串行同一任务上一次没跑完下一次调度会在队列里排队等上一个跑完后自动执行。丢弃后续调度上一次没跑完后面的调度直接丢弃。覆盖之前调度新调度会终止上一次还没跑完的任务。这三个策略没有绝对的好坏要按业务的容忍度来选。如果任务是幂等的且周期很短用“丢弃后续调度”不会产生问题如果任务必须执行且不能丢用“单机串行”配合合理的超时时间能避免任务堆积时全部往数据库上冲。第三是失败重试次数。这次事故还有一个痛点是失败后没有重试并且没有告警。我们在配置里给重要任务设置了1次失败重试并接入了xxl-job的失败告警通道任务一旦执行失败会立刻发到内部IM群不用等到业务方发现报表没出才炸锅。6. 事后复盘把死锁监控和任务告警真正跑起来6.1 数据库层的锁监控方案事故之后我在数据库层面部署了几项监控这里直接列出来供参考。第一个是活跃事务与锁等待巡检。写一个定时任务每隔1分钟扫描一次innodb_trx和sys.innodb_lock_waits发现有事务运行超过30秒、或者持续出现锁等待就立刻告警。脚本思路很简单核心SQL就是前面提到的-- 活跃超30秒的事务 SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_s FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) 30;直接接Prometheus也可以mysql_exporter本身就带有锁等待相关的指标配上Alertmanager规则即可。第二个是死锁日志的保留与聚合。开启innodb_print_all_deadlocks之后死锁会写到MySQL错误日志里。用filebeat或者fluentd采集error log然后通过关键字“DEADLOCK”聚合到日志平台每出现一次死锁就自动发告警。这一步很重要因为我们不能单纯依赖InnoDB内部检测后自动回滚每次死锁都意味着业务侧可能有异常需要人工关注。第三个是慢查询监控。慢查询是死锁的前兆很多死锁都是因为SQL本身太慢持锁时间太长引发的。把long_query_time设为1秒慢查询日志落库或接入监控定期review。线上慢SQL的数量涨上来接下来大概率就是要出事了。6.2 xxl-job调度监控与告警xxl-job的调度日志页面上能看到每次调度的结果但总靠人来盯是不现实的。至少要做两件事一是把xxl-job的告警通知配置好。在“执行器管理”里可以配置管理员的邮箱或账号任务失败时调度中心会发告警邮件。我们额外写了一个外部webhook转发把告警接入企业微信和IM群确保失败后第一时间有人响应。二是做调度结果巡检。如果你们没有统一监控平台可以写一个定时任务扫描xxl-job的调度日志表找出“最近10分钟内没有产生新调度记录”的任务或者“连续失败超过3次”的任务然后告警。注意xxl-job的调度日志表日常会增长得很快扫描时一定要按时间去查并做好索引。6.3 上线前的SQL Review与事务评审最后一条其实是管理规范但杀伤力比任何技术手段都大。我们复盘后发现这次出事的两个任务在代码评审阶段其实都没人提过“事务里的锁顺序不一致”这个问题。后来部门定了一条硬性规矩所有涉及多表更新的需求评审清单里必须包含以下内容事务里一共会获取哪些表或哪些行的锁加锁顺序是什么。每条SQL的执行计划是否走索引锁范围大概多大。事务里有没有远程调用、外部IO、Thread.sleep等耗时操作如果有则必须移到事务外。是否设置了合理的事务超时时间。在项目早期可能还没多少人关注这些但等到线上数据量上来再去改成本和风险都完全不一样。这次事故给我们最大的教训就是死锁不是“数据库偶尔抽风”而是事务设计缺陷在特定负载下的必然暴露。早点把这些检查项固化到流程里比事后加多少监控都管用。我在实际处理这类问题时的体会是别急着改代码先把现场保存下来——死锁日志、锁等待快照、执行器日志、连接池状态这四样东西缺一不可。所有修复方案都要基于现场证据而不是靠猜。另外一个比较实用的习惯是平时就把innodb_print_all_deadlocks开着低负载时它不会有什么噪音真正出事的时候你才能拿到完整的历史死锁记录。这套组合拳打下来以后再遇到“数据库死锁导致xxl-job任务不执行”基本半小时内就能定位到根因。