☰
MySQL死锁排查与预防:从InnoDB锁机制到实战案例
2026/10/22 10:59:40 网站建设 项目流程

MySQL死锁这个话题,做过几年后端的人多少都跟它打过照面。你大概率见过类似报错:Deadlock found when trying to get lock; try restarting transaction。第一次在线上看到这个异常时,我第一反应是业务代码写错了,后来排查多了才发现,死锁不一定是业务逻辑的锅,但一定跟事务、索引和锁顺序有关。这篇文章我想把死锁的定位思路和预防经验一次性讲清楚,适合正在被死锁困扰的开发,也适合想系统了解InnoDB锁机制的后端同学。我会尽量少讲空泛理论,多放能直接落地的排查手法和案例复盘。

1. 先把死锁的老家底搞明白:InnoDB到底在锁什么

1.1 行锁、间隙锁和Next-Key Lock,别被名词唬住

InnoDB默认隔离级别是REPEATABLE READ,在这个级别下,锁的类型不只是行锁。很多人看到死锁日志里出现lock_mode X locks gap before rec就懵了,其实这就是间隙锁。间隙锁锁的不是某一行,而是两个索引记录之间的空隙,目的是防止其他事务在这个空隙里插入新记录,从而保证幻读不会出现。

这里有个很容易忽略的细节:间隙锁本身是相互兼容的。两个事务可以同时持有同一个间隙的间隙锁,但如果其中一个事务想在间隙里插入记录,就需要获取插入意向锁,插入意向锁会跟已有的间隙锁冲突,于是就开始等。这个等待关系一旦形成闭环,死锁就出现了。很多死锁都是在这个环节发生的,而不是经典教科书里说的那种“两个事务各拿一行锁,然后互相要对方的行锁”。

Next-Key Lock则是行锁和间隙锁的组合,锁的是“当前记录+前面的间隙”。比如你在REPEATABLE READ下执行SELECT ... WHERE id > 100 FOR UPDATE,即使有些记录不存在,也会把索引区间内的间隙锁住。这意味着别人不能往这个区间插入数据,更新也会受限。理解了这个基础,后面分析死锁案例会顺很多。

1.2 数据库场景里“死锁四条件”怎么翻译

先看看操作系统课本里的死锁四必要条件:互斥、持有并等待、不可剥夺、循环等待。放到MySQL里,四个方面都对应得很具体:

  • 互斥:InnoDB的行锁、间隙锁本身是排他的,一个事务锁住了记录,另一个事务就不能再持有同一个锁。
  • 持有并等待:事务A持有某行的X锁,还想获取另一行的X锁,但这一行被事务B持有着。
  • 不可剥夺:InnoDB不会强行回收事务已经持有的锁,除非事务提交、回滚,或者被死锁检测机制判定为牺牲者后回滚。
  • 循环等待:事务A等事务B的锁,事务B又在等事务A的锁,形成一个环。

把这个四条件翻译成业务语言,就是:两条更新链路都在往同一个资源上加锁,但加锁顺序恰好相反,而且谁也等不到谁释放。比如订单支付流程里,先更新订单主表再更新账户余额,另一个流程先更新账户余额再更新订单主表,高并发下撞在一起就是经典死锁。

这里想强调一个容易被忽略的点:死锁发生的概率跟事务持有的锁数量正相关。事务越大、更新的行数越多,持有锁的时间越长,撞车的概率就越高。所以很多人问“为什么小流量压测时不报错,一上活动就死锁”,答案往往不是SQL写错了,而是并发度上来了,锁竞争的被放大。

1.3 为什么没走索引的更新最容易惹事

死锁日志里经常能看到index PRIMARY或者某个二级索引的名字,但如果执行计划是全表扫描,InnoDB会先给所有记录加上锁,然后一条条过滤。这时候死锁的严重性就变大了:本来只应该锁一行,结果整张表都进入了锁的范围。

我记得之前排查过一个案例,某业务方要对一批用户的消息状态做批量更新,更新条件是WHERE user_name = ?,但这个字段没有索引,同一时刻两个批处理任务各跑各的,结果把整个表的记录锁了个七七八八,稍微有点交集就开始互相卡。后来给user_name加了二级索引,死锁报警立刻沉寂了。

所以遇到死锁第一件事不是改事务隔离级别,而是看执行计划。EXPLAIN里如果是全表扫描、type是ALL,就算只是更新一行,也会先锁大量记录再过滤,这种场景不仅性能差,死锁概率也直线上升。索引的意义在这里不仅是加速查询,也是缩小锁粒度。

2. 收到死锁报警,该从哪里下手查

2.1 先开日志,别手动复现半天看不清

死锁排查的第一步不是看代码,是看现场。MySQL自己会在死锁发生时记录日志,但默认开关不一定开着。建议在配置文件里显式设置:

innodb_print_all_deadlocks = ON

这个参数的作用是把所有死锁都记录到错误日志里,而不是只记录最新的那条。否则你用SHOW ENGINE INNODB STATUS只能看到最近一次死锁的信息,要复现第二次、第三次就只能靠运气。生产环境开这个参数代价很小,因为死锁本来就不是高频事件,日志量完全可控。

另外,如果业务用的是云数据库,一般控制台都有慢日志、错误日志下载入口,把错误日志拉下来搜deadlock或者LATEST DETECTED DEADLOCK关键字就行。这一步先把案发现场固定住,再来谈分析。

2.2 死锁日志长什么样,怎么逐段读

拿一段典型的死锁日志来拆解(内容做了精简保留结构):

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-01-10 14:32:18 0x7f8c1a2b3700 *** (1) TRANSACTION: TRANSACTION 823456, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 3847392, query id 99183721 ... UPDATE coupon SET status=1 WHERE user_id=100 AND coupon_id=1001 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 123 n bits 72 index PRIMARY of table `db_order`.`coupon` trx id 823456 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 823460, ACTIVE 9 sec starting index read LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 3847410 ... UPDATE coupon_batch SET used_count=used_count+1 WHERE batch_id=2001 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 130 n bits 72 index PRIMARY of table `db_order`.`coupon_batch` trx id 823460 lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (2)

读这段日志关键看三处:

第一处是TRANSACTION下面的SQL语句,日志会打印出导致死锁事务最后执行的SQL。这里的SQL不一定是死锁开始时的第一条语句,而是事务在死锁发生那一刻正在执行的语句。排查时需要结合事务上下文还原整个执行序列。

第二处是WAITING FOR THIS LOCK TO BE GRANTED,它告诉你这个事务正在等哪一把锁,锁在哪个表的哪个索引上。这里看table和index就行,比如上面日志中两个事务分别等待coupon表和coupon_batch表的PRIMARY索引锁。

第三处是末尾的WE ROLL BACK TRANSACTION (2),表示InnoDB的死锁检测器选择了事务2作为牺牲者,回滚了它。业务方看到异常的就是这个事务,对应的代码会报Deadlock异常。理解这个机制很重要:死锁发生时总有一个事务会被回滚,不是两个都回滚,开发只需要保证业务对报错的事务做重试。

2.3 从锁对象反推业务代码,定位到具体方法

日志只能告诉你锁冲突发生在哪张表、哪个索引、哪条SQL上,但不能直接告诉你是哪个代码方法发起的。我惯用的排查方式是这样的:

先看SQL,把UPDATE、DELETE、SELECT ... FOR UPDATE这类语句在代码仓库里全局搜索,找到所有可能执行到它的方法。这里有个小技巧:日志里SQL的WHERE条件经常带着具体参数值,直接用参数值反查业务记录,回看这个时间点附近的操作日志,能更快锁定是哪条业务链路。

再结合时间点。死锁日志自带时间,去应用日志里捞同一时间段内的异常调用链,看线程ID在做什么,配合链路追踪里的traceId,很快就能对应到具体方法。我们团队曾经用这个方法,三分钟就从日志定位到是“优惠券发放”和“优惠券核销”两个接口在互相抢锁。

假如业务没有接入链路追踪,也至少要保证应用日志里打了异常堆栈和当前事务的SQL参数。没有参数的死锁日志就像只有车牌没有监控画面的交通事故,能看,但要猜很久。

3. 几个我看过的真实死锁场景复盘

3.1 支付流水和批次库存互相等锁

第一个案例是模拟项目X里的一个结算场景。系统里有settlement_order支付单表和batch_stock批次库存表。支付单审核通过后要更新订单状态,同时扣减批次库存。简化后的事务逻辑是这样:

事务A:

BEGIN; UPDATE settlement_order SET status=1 WHERE order_id=1001; UPDATE batch_stock SET available_qty=available_qty-1 WHERE batch_id=2001; COMMIT;

事务B:

BEGIN; UPDATE batch_stock SET available_qty=available_qty-1 WHERE batch_id=2001; UPDATE settlement_order SET status=1 WHERE order_id=1001; COMMIT;

两个事务加锁顺序完全相反。当A锁了order_id=1001,B锁了batch_id=2001之后,A想继续锁batch_id=2001,B想继续锁order_id=1001,互相等待,死锁成立。

复盘这个案例时我们发现,两个事务出现在不同服务里,一个叫“订单审核服务”,一个叫“库存扣减服务”,它们都涉及两行数据,但顺序没有统一约定。生产环境并发不高的时候很难复现,一旦大促压测或者某个商品爆单,死锁就频繁跳出来。

解决办法也直白:统一加锁顺序。所有涉及这两张表更新的业务,都按“先订单表后批次表”的顺序获取锁。代码评审时把这个顺序写进规范,后续新增需求照着执行就行。

3.2 库存扣减里范围查询撞上间隙锁

第二个案例更有迷惑性。某个抢购系统里,商品库存表stock_sku按sku_id分组存库存,表结构大致是这样:

CREATE TABLE stock_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, qty INT NOT NULL, version INT NOT NULL, KEY idx_sku_id (sku_id) );

业务侧扣减库存的逻辑,是在事务里先查一下当前SKU的总可用量,再决定是否扣减。由于并发高,查询语句用上了FOR UPDATE:

BEGIN; SELECT SUM(qty) FROM stock_sku WHERE sku_id = 12345 FOR UPDATE; -- 业务判断后执行扣减 UPDATE stock_sku SET qty = qty - 1, version = version + 1 WHERE sku_id = 12345 AND id = 1008; COMMIT;

问题就出在SELECT ... FOR UPDATE上了。在REPEATABLE READ隔离级别下,这句查询锁住的不是一行,而是idx_sku_id上sku_id=12345对应的所有记录,以及这个值前后可能存在的间隙。两个并发事务同时执行这条查询时,如果记录区间存在交错,就会各自持有部分间隙锁,然后插入新的库存记录时需要获取插入意向锁,又互相等待,就死锁了。

这类死锁的隐蔽之处在于,从业务日志看两个事务执行的是完全一样的SQL,没有加锁顺序问题,可还是死锁。本质上是因为FOR UPDATE的范围查询把锁的范围从行扩大到了间隙。

后期我们的处理方式:不让库存扣减先做范围FOR UPDATE,改成在事务内直接对确定的主键ID加锁,必要时对SKU汇总信息单独建一行汇总记录来加锁。如果没有汇总记录,就用SELECT ... FOR UPDATE只锁单行主键,而不是锁整个sku_id区间。这个改动之后,抢购场景的死锁就再没出现过。

3.3 批量更新大事务,锁等待滚成死锁

第三个案例来自一个对账系统。某定时任务会扫描一批待处理的流水,把它们的状态从0改成1,同时插入一条对账明细。原来的实现是把5000条流水放在一个事务里循环更新,每条流水更新之前还要查一次关联的账户余额。

问题在于,同一时刻有两个定时任务实例在跑,扫描到的流水范围存在重叠。任务1先更新了流水1-1000,任务2先更新了流水500-1500,两边各持有一部分行锁,随后双方继续往后更新时,就产生了交叉等待。因为事务太大,持有锁的时间非常长,死锁检测器介入后,其中一个任务直接回滚,回滚代价很大,还影响了别的事务。

这类大事务批量更新的解法不复杂,但需要下决心改代码:

一是拆分事务。把5000条流水按500条一批提交,切分成多个小事务,每个小事务独立提交,行锁持有时间大幅缩短。

二是按主键顺序更新。批量更新前先对ID排序,保证所有任务实例都按同一个顺序处理流水,从源头上消掉循环等待。

三是失败重试机制。哪怕已经做了前两步,还是建议在应用层对死锁异常做重试,比如捕获异常后休眠100毫秒再重试,最多重试3次。这个兜底策略几乎是必须的,因为任何设计都无法100%保证在高并发下不死锁。

4. 预防死锁的设计经验与配置取舍

4.1 事务内统一加锁顺序,收益最大

我见过太多死锁案例的根因就是加锁顺序不统一。这个顺序问题在单体应用里还好控制,一旦拆成微服务,每个服务各写各的事务,很容易出现同一个业务对象在不同服务里被以不同顺序更新。

一个比较实用的约定:所有事务内涉及多表更新时,先按照表名字典序加锁。比如先更新account,再更新order,不管业务语义是先下订单还是先动账,锁的获取顺序都按这个约定走。这样做虽然看起来有点“不自然”,但能极大降低死锁概率。

如果更新的是同一张表的多个行,也建议按主键排序后批量更新。例如在事务内要更新多个销售订单,不要按照业务处理顺序逐条更新,先ORDER BY id排序再更新,两个并发事务就基本不会因为更新顺序相反而死锁。

4.2 索引、执行计划与SQL改写配合

索引对死锁的影响主要体现在锁粒度上。一个好的执行计划能只锁必要的记录,一个全表扫描的执行计划可能把全表都卷入锁竞争。日常SQL评审时,我建议把EXPLAIN作为必查项,尤其关注type列和key列。type出现ALL或者key为NULL时,就要警惕锁范围扩大。

SQL改写也是预防死锁的重要手段。举个例子,有些更新语句写的是UPDATE table SET status=1 WHERE create_time < '2025-01-01',如果create_time没有索引就会全表扫描。改成先查出主键ID列表,再按ID批量更新,锁范围就小得多。虽然多一条查询的开销,但换来了更低的锁冲突概率。

另外要避免在事务中做太耗时的操作。远程调用、外部接口、大量计算这些都应该放到事务外完成。事务只保留必要的数据库操作,减少锁持有时间。锁持有时间缩短了,死锁窗口期自然就窄了。

4.3 隔离级别和死锁检测参数怎么权衡

常见的调法是把隔离级别从REPEATABLE READ降到READ COMMITTED。在RC下,InnoDB只保留行锁,不再使用间隙锁,幻读问题需要业务自己兜底。这确实能消灭一部分死锁,尤其是间隙锁引发的死锁,但要注意副作用:如果业务强依赖RR下的间隙锁来防止并发插入,降级后可能引入新的正确性问题。

还有两个参数经常一起被讨论:innodb_deadlock_detect和innodb_lock_wait_timeout。InnoDB默认开启了死锁检测,发生死锁时会立刻回滚牺牲者。如果业务场景极其特殊——比如单机并发非常高、热点行更新频繁——死锁检测本身也会消耗性能。官方文档和很多实践都提到,极端场景下可以关闭死锁检测,让锁等待直到innodb_lock_wait_timeout超时再报错。但我个人不太建议在生产环境关掉死锁检测,除非你能接受超时时间内所有锁等待都阻塞在那里,并且有完善的监控报警兜底。常规业务保持默认就好了。

innodb_lock_wait_timeout默认是50秒,对OLTP业务来说有点长。很多团队会把它调到5秒甚至3秒,这样即使死锁检测没及时介入,锁等待也不会把线程池拖垮。这个参数要根据业务对延迟的容忍度来设置,不是越小越好。

5. 死锁排查速查表和避坑清单

5.1 常见死锁场景速查表

我把平时排查过程中比较容易碰到的死锁场景整理成了一个速查表,方便你在收到报警时快速对照:

场景特征典型日志/现象常见根因优先处理思路
两个事务更新不同表但顺序相反日志里两条SQL分属不同表,互相等待PRIMARY锁加锁顺序不一致统一表内加锁顺序,必要时按字典序
相同SQL,范围查询后互相等待日志里出现lock_mode X locks gap before rec间隙锁冲突缩小锁范围,避免FOR UPDATE范围锁
一条SQL更新大量行日志显示多个row lock,事务耗时长大事务持有过多行锁拆批、排序、缩短事务时间
单条更新没走索引执行计划type=ALL或key为NULL全表扫描导致锁全表记录加索引,改写SQL条件
唯一键冲突日志出现insert intention等待插入意向锁冲突先查后插,或改分布式锁避免重叠

这张表只是排查起点,最终还是要结合具体SQL和执行计划做判断。不过大多数死锁,根因都能归到前面这几类里。

5.2 避坑清单,都是踩过之后的经验

第一条,不要靠SELECT SLEEP()去复现死锁。死锁需要精确的并发时序,手工加sleep模拟出来的结果只能证明“这个姿势可能死锁”,并不能证明线上就是这么触发的。更靠谱的方式是把线上死锁日志里的两条SQL原样提取出来,用两个会话并发执行,配合SHOW ENGINE INNODB STATUS观察锁等待。

第二条,不要一收到Deadlock异常就改隔离级别为READ UNCOMMITTED。这是最危险的调优方向,因为这等于用正确性换性能。先定位是间隙锁问题再考虑RC,而且RC下面也要重新评估业务的唯一性和幻读需求。

第三条,不要把try restart transaction当摆设。很多ORM框架和数据库驱动已经内置了死锁重试机制,但有些框架默认关闭或者重试间隔不合理。建议在业务代码层面对“Deadlock”相关的异常做显式捕获和重试,约定一个幂等键,保证重试时不会重复处理。

第四条,排查死锁时多看一眼performance_schema.data_locks。如果系统是MySQL 8.0,且当前死锁还处于等待状态,直接查这张表能看到实时的锁持有和等待关系,比翻历史日志更直观。不过要留意查询权限,生产库一般需要单独授予权限。

最后分享两个小技巧

我自己在排查死锁时养成了两个习惯,一个是把死锁日志里的SQL带上参数,格式化后排到代码仓库全局搜索,先找调用入口,再沿调用链看事务范围;另一个是每次大促前把核心链路的死锁日志打开,提前压测。压测时宁可在测试环境多报几次死锁,也不要等到线上大流量来了才懵。还有一个小细节:如果用的是连接池,尽量保证事务内的所有SQL走同一个连接,不要在事务中间去获取新连接,否则事务隔离性和锁的持有都会变得不可控。

死锁这东西,刚遇到时觉得玄学,看多了就会发现它背后就是锁顺序、锁粒度、锁时间三件事的组合。把这三点掰开揉碎,绝大多数死锁都能在几分钟内定位清楚。希望这篇文章能让你遇到死锁时少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询