☰
MySQL死锁从原理到解决:一份完整排查指南
2026/10/20 7:17:40 网站建设 项目流程

死锁这种问题,越早搞懂越省事

做后端开发的朋友,大概率都见过这条报错:Deadlock found when trying to get lock; try restarting transaction。第一次见到的时候,我盯着日志愣了半天,明明两条SQL单独跑都没问题,怎么放到一起就出事了?后来被线上故障教育了几次,才把死锁这玩意儿彻底吃透。

MySQL死锁不是玄学,它是一套规则清晰、可预测、可解决的数据库并发问题。核心就一句话:两个或多个事务各自持有对方需要的锁,谁也不让谁,数据库只能牺牲其中一个,强制回滚。这篇文章我不会讲那些华而不实的概念,而是从原理、定位、快速处理、SQL规避到真实案例,把一套完整的死锁处理策略完整地摊开讲。适合刚接触MySQL并发编程的开发者,也适合被线上死锁折磨过、想系统梳理一遍的运维和架构同学。

1. 先搞清楚死锁到底是怎么发生的

1.1 死锁产生的四个必要条件

教科书上把死锁归纳成四个必要条件,在MySQL的InnoDB引擎里同样适用,我用大白话翻译一下:

  • 互斥:同一时刻,一把行锁只能被一个事务持有。
  • 持有并等待:事务A持有锁X,又在等待锁Y;与此同时事务B持有了锁Y,又在等待锁X。
  • 不可剥夺:事务持有的锁在自己提交或回滚之前,不能被别的强制抢走。
  • 循环等待:A等B释放,B等A释放,形成一个等待环。

这四个条件缺一不可,任何一个被打破,死锁就解开了。这其实也给我们提供了解决思路:不用去做太复杂的调整,只要破坏其中一条就能解决问题。最常见手段就是统一SQL加锁顺序,或者缩小锁粒度——本质上是打破“循环等待”和“持有并等待”。

打个比方,你和同事在一条单行道上迎面而行,两个人手里都抱着箱子,谁也不肯先靠边,这就是死锁。解决的办法要么让一个人退回去(回滚),要么约定好永远靠右走(统一加锁顺序),要么把路拓宽成双行道(缩小锁粒度)。

1.2 InnoDB的锁类型与加锁时机

要理解死锁,绕不开InnoDB的锁机制。很多新手以为InnoDB只有行锁,其实它是多级锁组合的,我列一下常见的:

  • 记录锁(Record Lock):锁定单条索引记录,是行锁的基础。
  • 间隙锁(Gap Lock):锁定一个范围,但范围里没有记录,用于防止幻读。
  • Next-Key Lock:记录锁和间隙锁的组合,既锁记录又锁记录前面的间隙,是InnoDB在可重复读级别下默认的加锁方式。
  • 意向锁(Intention Lock):表级别的标记锁,表示事务准备对表中的某些行加锁,用于协调行锁和表锁。

加锁时机取决于隔离级别。在默认的REPEATABLE READ(可重复读)隔离级别下,InnoDB使用Next-Key Lock,SELECT ... WHERE的加锁范围不仅仅是命中的那些行,而是可能覆盖一个区间。这就导致了一个非常反直觉的现象:两条看起来更新不同行的SQL,也可能因为间隙锁相互阻塞。

举个直观的例子:

-- 事务A UPDATE orders SET status = 1 WHERE id > 100 AND id < 200; -- 事务B UPDATE orders SET status = 1 WHERE id = 150;

如果事务A先执行,它锁的是id在(100,200)这个范围内的间隙和记录;事务B想更新id=150的记录,发现这个记录已经被事务A的Next-Key Lock覆盖了,只能等待。类似这种场景叠加多了,一旦形成环,死锁就出现了。

提示:SELECT操作默认是快照读,不会加锁;只有SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE和DELETE才会触发锁定读。

理解了锁的种类和加锁范围,你就能明白一个道理:死锁本质上不是SQL写错了,而是多个事务的锁范围交叉了。所以排查死锁的第一步,永远是搞清楚每个事务到底锁了哪些记录和间隙。

2. 死锁来了怎么定位——查看死锁现场

2.1 用SHOW ENGINE INNODB STATUS抓取死锁日志

遇到死锁,我最崩溃的不是问题本身,而是很多人第一反应是“重启”“改配置”,完全没有去看现场的意识。死锁是即时的,事务回滚后现场会消失,但InnoDB会保留最近一次死锁的完整信息。

先用一条命令把死锁现场拉出来:

SHOW ENGINE INNODB STATUS\G

重点看LATEST DETECTED DEADLOCK这一段。这段日志长这样(实际输出会比这复杂,我简化了核心结构):

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-04-18 10:22:31 0x7f5c1a0a4700 *** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 7890, OS thread handle 140..., query id 1234 ... UPDATE orders SET status = 1 WHERE order_id = 888 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 3 n bits 72 index `PRIMARY` of table `test`.`orders` *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 5 n bits 72 index `uk_order_no` of table `test`.`orders` *** (2) TRANSACTION: TRANSACTION 123457, ACTIVE 2 sec starting index read LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 7891, OS query id 1235 ... UPDATE orders SET status = 2 WHERE order_no = 'NO20230418001' *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 5 n bits 72 index `uk_order_no` of table `test`.`orders` *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 3 n bits 72 index `PRIMARY` of table `test`.`orders`

这段日志的信息量非常大,我分享下我的读法:

  • 看TRANSACTION后面的数字和SQL语句,确认是哪两个事务在打架。
  • 看HOLDS THE LOCK(S),即这个事务手里握着哪把锁——日志里会标明索引名、页号和表名。
  • 看WAITING FOR THIS LOCK TO BE GRANTED,即它等的是哪把锁。

上面这个例子非常典型:事务1先通过order_id(主键索引)更新,手里握着主键锁,但因为WHERE条件里涉及order_no的唯一索引,它可能又要通过唯一索引去定位;事务2正好相反,先通过order_no更新,持有唯一索引锁,又要回到主键索引。两者在两条索引路径上互相等待,死锁就发生了。

这是死锁日志里最常见也最容易看走眼的一类——看起来两个事务更新的是同一行,实际上是索引访问顺序相反导致的。

2.2 用performance_schema做长期监控

SHOW ENGINE INNODB STATUS只能看到最近一次死锁,对于偶发死锁,你需要更长周期的观测。我的做法是开启performance_schema的相关表,把死锁和锁等待的信息定期记录下来。

直接查锁等待相关的表:

SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;

这两张表在MySQL 8.0里特别有用。data_locks展示当前所有事务持有的锁和等待的锁,data_lock_waits则直接展示谁在等谁。配合sys.innodb_lock_waits视图,能快速定位阻塞链:

SELECT * FROM sys.innodb_lock_waits\G

这个视图会把等待的事务、阻塞的事务、锁类型、索引名全部关联好,比手工翻SHOW ENGINE INNODB STATUS省力多了。

另外强烈建议把死锁日志在应用层记录下来,而不仅仅依赖MySQL。在业务代码里捕获死锁异常的时候,把当前事务涉及的SQL、参数、堆栈一起打成日志,长期积累后你会发现死锁的规律其实很明显:经常集中在某几个热点表或热点数据的更新上。

注意:开启performance_schema会带来少量性能开销,线上正式环境建议在小流量阶段确认无异常后再全量开启,或者在测试环境专门部署一套配置复现死锁。

3. 快速处理策略:一场死锁发生后的第一反应

3.1 先从MySQL服务端入手

死锁已经发生了,第一目标是让业务先恢复正常,而不是深究原因。InnoDB的死锁检测机制默认是开启的,当检测到死锁时会自动选择一个事务回滚,另一个继续执行。这也是为什么你在日志里经常会看到WE ROLL BACK TRANSACTION这样的提示。

如果死锁频率很高,可以先看两个参数:

SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; SHOW VARIABLES LIKE 'innodb_deadlock_detect';

innodb_lock_wait_timeout默认是50秒,指的是一个事务等待锁的最长时间,超时就直接报Lock wait timeout exceeded。它不是死锁处理的专用参数,但很多时候死锁和锁等待混在一起,如果业务等不了那么久,可以适当调小,比如10秒。不过这个参数要谨慎调——调太小会让正常的锁等待也被误杀。

innodb_deadlock_detect决定了死锁检测开关。在分库分表或高并发场景下,有人会把死锁检测关掉,完全依赖innodb_lock_wait_timeout兜底。我不建议常规业务这么干,因为关闭后死锁不会自动回滚,反而全部变成锁超时,响应时间会急剧恶化。只有在你确认死锁检测本身成为性能瓶颈的极端场景,才考虑这个方案。

另外一个容易被忽略的配置是事务隔离级别。拿线上业务举例,如果业务逻辑上能接受读已提交,就没必要死守可重复读。READ COMMITTED级别下InnoDB会减少间隙锁的使用,死锁概率能明显下降:

SET GLOBAL transaction_isolation = 'READ-COMMITTED'; SET SESSION transaction_isolation = 'READ-COMMITTED';

当然,这是把双刃剑:如果业务对“一次事务内多次查询结果一致”有要求,降级隔离级别会产生更多逻辑问题,宁可保持死锁后用重试兜底,也不要为了降死锁而引入数据不一致。

3.2 从业务代码侧解除死锁

我最推崇的快速处理方案,不是调参数,而是在代码里做重试机制。MySQL的官方建议在报错信息里写得明明白白:try restarting transaction。既然死锁牺牲了一个事务,那我们就在业务层面把被牺牲的重试一遍。

实现重试有几个关键点:

  1. 必须捕获明确的死锁异常,不要一股脑全重试。在Java的开发中,可以捕获DeadlockLoserDataAccessException(Spring体系)或MySQLTransactionRollbackException;在Python的SQLAlchemy里则捕获OperationalError并判断错误码1213。
  2. 重试次数要有限制,一般3次以内,多了系统压力反而更大。
  3. 重试之间要有退避间隔,用指数退避,不要一瞬间冲上去。

我贴一个伪代码,这套逻辑在各个语言里几乎可以平移:

def execute_with_deadlock_retry(connection, sql, params, max_retries=3): for attempt in range(max_retries): try: # 开启事务前先记录当前自动提交状态 connection.begin() connection.execute(sql, params) connection.commit() return True except DeadlockError: connection.rollback() if attempt == max_retries - 1: raise sleep_time = 0.05 * (2 ** attempt) # 指数退避:50ms, 100ms, 200ms time.sleep(sleep_time) return False

这套方案我实测下来,对偶发死锁的兜底效果非常好。注意重试前必须rollback(),否则事务状态是脏的,后续执行会出大问题。

3.3 事务边界与自动提交

还有一种“隐藏死锁”,其实是事务边界太宽导致的。很多同学把Spring的@Transactional随手一标,一个事务里既查用户信息又查订单,还调了两次远程接口,再来一个更新,这整个期间所有行锁都攥在手里,跟其他事务碰撞的概率呈指数级上升。

死锁本质是锁的竞争,锁拿得越久、越多,竞争越激烈。你在一个事务里执行的SQL条数越多,加锁的中间状态就越多,越容易跟别的事务形成锁环。所以快速处理策略里,我有几个固定动作:

  • 检查@Transactional注解是否加在了不需要事务的查询方法上。
  • 事务方法里禁止做远程RPC调用、文件读写、消息队列发送,这些操作可能耗时数秒,期间锁一直不释放。
  • 数据量大的更新,分批提交,每批一个事务。

注意:set autocommit = 0以后如果忘记commit,连接归还连接池的那一刻可能自动回滚,但更严重的是连接持有时间过长导致锁等待连锁反应。我用连接池时,都强制校验连接归还事务状态,确保没有未提交事务。

4. 更重要的:从SQL设计上绕开死锁

4.1 统一加锁顺序是最有效的武器

处理死锁,最优雅的方式不是“出了死锁怎么解”,而是“让死锁根本不发生”。在所有规避手段里,统一加锁顺序是效果最明显、成本最低的一个。

死锁的循环等待,根源是多个事务以不同顺序获取多把锁。以转账场景举例:

-- 事务A:从账号1转100元到账号2 UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; UPDATE accounts SET balance = balance + 100 WHERE account_id = 2; -- 事务B:从账号2转100元到账号1 UPDATE accounts SET balance = balance - 100 WHERE account_id = 2; UPDATE accounts SET balance = balance + 100 WHERE account_id = 1;

如果事务A先锁账号1,事务B先锁账号2,然后互相等对方,死锁必然发生。解决办法是在业务层约定一个顺序规则,比如对账号ID做排序,永远先更新ID小的:

def transfer(from_account, to_account, amount): # 按ID统一排序,保证加锁顺序一致 first, second = sorted([from_account, to_account]) update_balance(first, -amount) update_balance(second, amount)

这个sorted操作看起来不起眼,但它直接打破了循环等待。我在团队里推广这个规范后,转账类业务死锁几乎绝迹。

4.2 缩小事务与锁范围

锁的范围有两个维度:事务持有的锁的时间长度,以及单条SQL扫描和锁定的记录数。两个维度都能优化。

锁的时间长度,前面说过要把远程调用和慢操作移出事务。锁的记录数,核心是看SQL的WHERE条件有没有走索引。全表扫描的UPDATE会锁住整张表的所有行,任何并发更新都会被堵住,死锁概率急剧上升。用EXPLAIN检查执行计划是必须的动作:

EXPLAIN UPDATE orders SET status = 1 WHERE status = 'PENDING';

这种SQL如果status没有索引,执行计划会显示走ALL全表扫描,意味着要对所有匹配行加锁。务必要给status建索引,或者改成按主键范围分批更新。

4.3 合理使用索引,避免间隙锁误伤

间隙锁是死锁的一大来源,因为它会把“不存在的记录”也锁住。在我遇到过的死锁案例里,很大一部分是UPDATE语句的WHERE条件不走索引,或者走的是范围条件,导致间隙锁范围交叉。

举个例子,表orders有一条唯一索引uk_order_no,下面两个事务并发执行:

-- 事务A INSERT INTO orders (order_no, status) VALUES ('NO-1001', 1); -- 事务B INSERT INTO orders (order_no, status) VALUES ('NO-1001', 2);

两个事务都要往同一个唯一索引上插入冲突记录。InnoDB在处理唯一键冲突时,会对已存在记录加共享锁,而插入方又会等对方的锁释放——这时候如果事务结构复杂,很容易死锁。解决办法之一是把唯一键冲突的检查前置,或者用INSERT ... ON DUPLICATE KEY UPDATE改写,减少手工先查再插。

还有一类是范围更新,尽量避免使用BETWEEN、>、<这类条件去更新大批量数据,尤其当索引的覆盖范围重叠时。改成等值条件,或者把大批量更新拆成多个小批量,每条SQL只精确更新固定主键范围,能很有效地缩小间隙锁范围。

4.4 改写更新逻辑:乐观锁思路

如果业务允许“最终一致”,用乐观锁替代悲观锁也能绕开死锁。乐观锁不在事务里长期持锁,而是通过版本号判断冲突,冲突就重试。

比较常见的做法是给表加version字段:

UPDATE orders SET status = 2, version = version + 1 WHERE order_id = 888 AND version = 1;

如果更新的行数是0,说明版本号已经被别的改了,业务层决定是查新数据重试还是放弃。这种方案的优点是更新瞬间很短,锁窗口极小,基本不会产生死锁。缺点是需要业务接受“失败重试”的流程,不是所有场景都适用。

我自己的选择倾向是:读多写少的业务、对一致性要求没那么极端的业务,优先用乐观锁;需要强一致、必须独占修改的场景,用悲观锁并配合重试兜底。

5. 常见死锁场景与排查实录

5.1 典型场景一:批量更新时锁范围交错

有位同事反馈,一个定时任务在每天凌晨批量更新订单状态,偶尔会报死锁。排查日志后发现,其实同一个任务里同时开了多个线程,每个线程更新不同批次的数据:

-- 线程1 UPDATE orders SET status = 'PAID' WHERE id BETWEEN 1 AND 1000; -- 线程2 UPDATE orders SET status = 'PAID' WHERE id BETWEEN 500 AND 1500;

两个线程的更新范围有交叉,间隙锁互相覆盖,自然就撞了。解决办法是把任务改成单线程执行,或者把批量更新改成互不重叠的等值区间。还有一种思路是增加一个重试,不过既然是内部定时任务,改成单线程更省事。

5.2 典型场景二:唯一键冲突引发的死锁

这个场景很容易被误判为普通异常。业务代码里先SELECT查一下记录是否存在,不存在就INSERT,两个请求同时来,都查出不存在,一个插入成功,另一个插入时遇到唯一键冲突——此时数据库会自动给冲突记录加锁,而这个加锁操作又和别的锁形成依赖,死锁就发生了。

这是典型的“先查后改”引发的死锁。推荐方案是直接用INSERT ... ON DUPLICATE KEY UPDATE或者INSERT IGNORE,把两步压缩成一步,减少锁的获取次数。实在需要先查后改,就在查的时候加上FOR UPDATE,把锁先拿到,避免两条路交叉。

5.3 典型场景三:主外键关联更新

主表和子表之间有外键约束时,更新子表记录,数据库会自动对主表加共享锁。如果两个事务分别更新了不同主表记录,接下来又要更新对方的子表记录,就会在父子表之间形成循环等待。

这种场景的处理比较麻烦,因为加锁顺序不是代码里能直接看到的,而是数据库隐式做的。我的建议是:

  • 先更新主表,再更新子表,并且全团队统一这个顺序。
  • 更新子表时尽量避免一次更新多条涉及多个主表记录的语句。
  • 如果业务没有强一致要求,考虑在应用层删除外键约束,改用代码保证一致性,减少数据库隐式锁。

5.4 一份快速排查清单

把我这些年踩过的坑整理成清单,每次遇到死锁按步来就好:

步骤动作目的
1SHOW ENGINE INNODB STATUS拉取最近死锁日志定位两个事务和锁信息
2确认data_locks和data_lock_waits中的实时等待链观察是否还有正在发生或即将发生的死锁
3检查死锁相关SQL的执行计划(EXPLAIN)确认是否全表扫描、是否用到索引
4确认事务边界是否过大、是否包含远程调用缩小锁持有的时间和范围
5在代码层加上死锁异常捕获与有限重试兜底偶发死锁,保证业务可用性
6统一相关业务SQL的加锁顺序从根上消除循环等待
7评估隔离级别是否可降级减少间隙锁使用

这个清单我自己打印了一份贴在工位上,排查死锁的效率高了很多。

5.5 死锁排查的一个额外技巧:用慢查询日志做预判

死锁不是凭空冒出来的,它往往伴随着慢查询。如果一个SQL执行时间长,意味着它持有锁的时间也长,成为死锁一环的概率就高。建议把慢查询日志打开,long_query_time设成1秒,定期扫一遍哪些UPDATE、DELETE语句经常上榜,这些语句就是死锁的高危候选。

我曾经有一次疑难死锁排查了三天,最后发现源头就是一条UPDATE语句因为索引失效全表扫描,锁定了大量记录,导致其他所有更新都受影响。把索引优化好之后,死锁频率直接归零。这个经验让我养成了一个习惯:凡是死锁案例,第一步必查执行计划,先解决索引问题再说别的。

一点实际操作后的心里话

关于MySQL死锁,我想说处理它真正考验的不是你知道多少理论,而是你面对日志时的分析路径是否清晰。每次死锁日志都是完整的案发现场,两个事务各自的SQL、锁的持有和等待关系全部写得明明白白,关键看你有没有耐心逐行去读。

我在实际处理中体会最深的一条:死锁问题的终极解法永远是设计层面的,代码层的重试只是兜底。你SQL写得再小心,表结构设计不合理、事务边界不清晰,死锁迟早会发生。所以建议把死锁处理流程沉淀成团队的规范——谁遇到死锁,先按排查清单走一遍,把日志留下来,定期复盘,比一个人临时抱佛脚高效得多。最后再分享一个小技巧:处理完一批死锁后,不要急着删日志,把死锁涉及的表名和SQL模式攒起来,过两周再看一次,你会发现自己业务的死锁规律比想象中明显得多。

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

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

立即咨询