MySQL死锁排查与预防:从InnoDB锁模型到最小复现案例
2026/9/19 14:04:18 网站建设 项目流程

死锁在 MySQL 中一点也不“高端”,它几乎每天都在生产环境里发生。尤其是当系统刚上线时,运维和开发最常遇到的一类问题就是:两条单独执行都很快、语法上完全正常的 SQL,一旦并发执行,突然就互相卡住,接着应用层报出Deadlock found when trying to get lock。这篇文章从 InnoDB 的锁模型讲起,用一个可复现的最小案例还原两条正常 update 互相卡死的全过程,然后带你读死锁日志,最后给出一套在实际项目中可落地的排查与预防方案。

主题不算难,但涉及事务、索引、锁、隔离级别、死锁日志等多个点。只要按顺序跟着走,就能把一个看起来像玄学的问题变成可以推理、可以试验、可以解决的技术问题。

1. 先理解 InnoDB 的锁,死锁的四个必要条件才能落地

1.1 从“卡死”这个现象开始

用户看到的“卡死”,在数据库内核里其实是锁等待。事务 A 正在修改某一行,事务 B 也想修改同一行或相邻范围,B 就必须停下来等待 A 提交或回滚。

如果只是单方向等待,系统不会死锁,最多就是一条 SQL 慢一点。真正危险的是循环等待:事务 A 持有事务 B 需要的锁,事务 B 又持有事务 A 需要的锁,两边谁都不肯先放手。

MySQL 的 InnoDB 引擎会自动检测这种循环等待。一旦检测到,会回滚其中一个事务,让另一个事务继续执行。所以“死锁”并不是数据库卡住不动,而是其中一个事务被数据库强制回滚,应用层收到一个明确的错误码。

1.2 行锁、间隙锁和 Next-Key Lock

InnoDB 的锁是加在索引上的,不是加在“行记录”上。这里有两个刚入门的人最容易忽略的事实:

  • 普通SELECT不加锁,MVCC 通过快照读来保证隔离。
  • UPDATEDELETESELECT ... FOR UPDATE加锁,加的是排他锁(X 锁)。
  • 在默认隔离级别REPEATABLE READ下,范围查询还可能会加间隙锁(Gap Lock)和 Next-Key Lock。

把锁类型整理成一张速查表,后续分析死锁日志会反复用到:

锁类型锁的作用范围典型触发 SQL是否兼容
共享锁 S当前记录可以被多个事务同时读SELECT ... LOCK IN SHARE MODES 与 S 兼容,S 与 X 互斥
排他锁 X当前记录只能被本事务写UPDATEDELETESELECT ... FOR UPDATEX 与任何锁都互斥
记录锁 Record Lock锁定某条具体索引记录主键或唯一索引等值更新只锁单条记录
间隙锁 Gap Lock锁定索引记录之间的空隙,防止其他事务插入RR 隔离级别下的范围查询不同事务的 Gap Lock 互相兼容
Next-Key Lock记录锁加前方间隙锁RR 隔离级别下的范围查询与插入意向锁冲突
插入意向锁 Insert Intention Lock表示事务准备插入某个间隙INSERT与 Gap Lock 和 Next-Key Lock 冲突

最容易被误解的是 Gap Lock。很多人以为UPDATE ... WHERE id = 3只锁id=3这一行,实际上在REPEATABLE READ下,如果查询条件命中一个区间,或者二级索引不唯一,InnoDB 会锁住记录本身以及它前面的间隙,防止其他事务在同一个间隙插入新数据。间隙锁的加入,既保证了可重复读,也成了死锁最常见的来源之一。

1.3 死锁必须同时满足四个条件

教科书里反复出现的“死锁四条件”,在数据库场景下同样成立:

  1. 互斥:同一资源同一时刻只能被一个事务以排他方式占有。
  2. 持有并等待:事务持有一个锁,又等待另一个锁。
  3. 不可剥夺:已经持有的锁不能被其他事务强行抢走,只能由持有事务自己释放。
  4. 循环等待:事务之间形成了一条环形等待链。

在 MySQL 里,前三个条件基本无法消除,因为数据库的行锁天然就是互斥的。开发者的切入点只能放在打破“循环等待”上。

1.4 为什么 InnoDB 会主动回滚一个事务

InnoDB 默认开启死锁检测,相关参数是innodb_deadlock_detect,默认值为ON

每次事务请求锁失败进入等待时,InnoDB 都会检查是否产生了循环等待。一旦发现,它会选择一个“回滚代价更小”的事务进行回滚,把锁释放出来,另一个事务才能继续。被回滚的事务会收到类似下面的错误:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

这里有个容易混淆的概念:1213是死锁,1205是锁等待超时。1205表示某个事务等待另一个事务释放锁超过了innodb_lock_wait_timeout默认的 50 秒,但没有形成循环等待:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

理解清楚这两个错误码,排查时就能少走很多弯路。

2. 最小复现案例:两条正常 update 为什么互相卡住

2.1 建表和初始数据

一个最简单的版本,不需要间隙锁,只需要两个事务按相反顺序更新两行数据。下面这张表模拟账户余额:

CREATE TABLE `account` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `balance` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入 5 条数据:

INSERT INTO `account` (`id`, `user_id`, `balance`) VALUES (1, 101, 500), (2, 102, 500), (3, 103, 500), (4, 104, 500), (5, 105, 500);

这里注意一个关键点:表必须使用 InnoDB,并且要有主键索引。如果使用 MyISAM,锁粒度是表锁,不会出现行级死锁,本文讨论的范围也不适用。

2.2 两个事务的执行顺序

现在打开两个 MySQL 会话,分别执行以下事务。

事务 A:

BEGIN; UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 1; -- 模拟业务处理,这里先不提交 -- 然后再更新另一行 UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 2; COMMIT;

事务 B:

BEGIN; UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 2; -- 模拟业务处理 UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 1; COMMIT;

如果这两个事务串行执行,任何一组都不会死锁,因为前一个事务在极短时间内就提交并释放了锁。

如果并发执行,并且时间点踩准,流程会变成:

  1. 事务 A 执行第一条 update,拿到id = 1这一行的排他锁。
  2. 事务 B 执行第一条 update,拿到id = 2这一行的排他锁。
  3. 事务 A 执行第二条 update,想要id = 2的锁,发现被事务 B 持有,于是 A 进入等待。
  4. 事务 B 执行第二条 update,想要id = 1的锁,发现被事务 A 持有,于是 B 也进入等待。
  5. InnoDB 死锁检测介入,回滚其中一个事务,另一个事务继续执行。

用锁矩阵看会更清楚:

时刻事务 A 持有的锁事务 B 持有的锁事件
t1id=1 的 X 锁A 更新 id=1
t2id=1 的 X 锁id=2 的 X 锁B 更新 id=2
t3id=1 的 X 锁id=2 的 X 锁A 等待 id=2
t4id=1 的 X 锁id=2 的 X 锁B 等待 id=1,形成环

2.3 为什么每条 SQL 单独执行都正常

把事务 A 的两条 SQL 单独抽出来,任何一条都很快:

UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 1; UPDATE `account` SET `balance` = `balance` - 100 WHERE `id` = 2;

问题不在 SQL 本身,而在 SQL 的“执行顺序”。两个事务以不同顺序访问同一组资源时,就打破了“全局统一访问顺序”的约定。

这也是排查死锁时最核心的思路:不要盯着一条 SQL 找问题,要把事务里所有访问过的表、索引、行、间隙全部画出来。

2.4 案例延伸:没有索引的 update 更危险

把上面的案例改一下,如果更新条件不是主键id,而是一个没有索引的字段:

UPDATE `account` SET `balance` = `balance` - 100 WHERE `user_name` = '张三';

user_name没有索引时,InnoDB 无法通过索引快速定位行,只能全表扫描。扫描过程中,它会对扫描到的每一行加锁。在高并发下,这种 update 会锁住大量记录,甚至让其他事务的所有写操作都排长队。

更严重的情况是,两条无索引 update 条件不同,但都扫描到了同一批记录。两个事务各自持有部分记录的锁,又等待对方持有的其他记录,同样会触发死锁。所以生产环境里,UPDATEDELETE的 WHERE 条件必须能命中索引,这不是性能优化建议,而是锁安全的基本要求。

3. 死锁发生时,从哪里找到现场

3.1 客户端看到的错误信息

被回滚的事务会收到类似这样的错误:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

对应到 Java 的 JDBC 异常,通常是:

Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction

看到121340001,基本可以确定是死锁。

3.2 开启死锁日志和 InnoDB 状态输出

默认情况下,只有最近一次死锁会保存在 InnoDB 的内存里,可以通过命令查看:

SHOW ENGINE INNODB STATUS\G

输出结果里重点看LATEST DETECTED DEADLOCK段落。

不过SHOW ENGINE INNODB STATUS只能看到最近一次死锁。如果死锁频繁发生,建议打开参数,让每次死锁都记录到 MySQL 错误日志:

SET GLOBAL innodb_print_all_deadlocks = ON;

这个参数修改后立即生效,不需要重启。生产环境建议永久写入配置文件:

[mysqld] innodb_print_all_deadlocks = ON innodb_deadlock_detect = ON

注意:官方默认innodb_deadlock_detect本来就是ON,日常不需要改。只有在压测中发现死锁检测本身成为瓶颈时,才需要考虑关闭,但关闭后只能依靠 50 秒超时兜底,风险很高,不建议普通业务尝试。

3.3 LATEST DETECTED DEADLOCK 日志逐段解读

SHOW ENGINE INNODB STATUS的输出很长,正确阅读顺序是先定位到LATEST DETECTED DEADLOCK,然后看下面两个事务块。

下面的日志是简化示意,字段会比真实输出少,但结构一致:

LATEST DETECTED DEADLOCK ------------------------ 2025-01-20 14:32:10 0x7f1a2c0b1700 *** (1) TRANSACTION: TRANSACTION 12345, ACTIVE 5 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 8, OS thread handle 12345, query id 100 localhost root updating UPDATE account SET balance = balance - 100 WHERE id = 2 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table `test`.`account` trx id 12345 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table `test`.`account` trx id 12345 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) TRANSACTION: TRANSACTION 12346, ACTIVE 5 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 9, OS thread handle 12346, query id 101 localhost root updating UPDATE account SET balance = balance - 100 WHERE id = 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** WE ROLL BACK TRANSACTION (2)

日志里关键信息是:

  • TRANSACTION (1)TRANSACTION (2):两个事务的编号。
  • HOLDS THE LOCK(S):该事务当前持有的锁。
  • WAITING FOR THIS LOCK TO BE GRANTED:该事务等待的锁。
  • WE ROLL BACK TRANSACTION (2):InnoDB 最终回滚了事务 2。

读日志的时候,把两个事务“持有的锁”和“等待的锁”放在一张表里,死锁环基本一目了然。

3.4 实时查看锁等待

死锁日志只能看到“已经发生”的死锁。如果系统正在持续锁等待,可以通过performance_schema实时观察。

MySQL 8.0 推荐查询:

SELECT trx_id, trx_state, trx_query FROM information_schema.INNODB_TRX\G SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks\G

MySQL 5.7 及早期版本,可以查询:

SELECT * FROM information_schema.INNODB_LOCKS\G SELECT * FROM information_schema.INNODB_LOCK_WAITS\G

注意版本差异:8.0 中INNODB_LOCKS已经被移除,不要依赖旧查询。锁等待现场的排查思路是,先找到所有未提交事务,再查每个事务持有哪些锁、正在等哪些锁,最后把“等待与被等待”的关系连成环。

4. 常见死锁场景与排查路径

4.1 场景一:不同顺序更新多行

这是最简单也最常见的死锁,对应的就是上文的最小复现案例。

典型特征:

  • 两个事务都包含两个以上的更新操作。
  • 更新的是同一组行。
  • 访问顺序不同。
  • 每行单独执行时间都很短。
  • 并发量上去后,死锁概率明显升高。

排查时先看两个事务的 SQL 列表,如果出现“同样两张表,顺序相反”,基本可以确定是这类问题。

事务 A事务 B
UPDATE t SET ... WHERE id=1UPDATE t SET ... WHERE id=2
UPDATE t SET ... WHERE id=2UPDATE t SET ... WHERE id=1

解决办法是统一排序:所有事务在更新多行之前,先对 id 排序,保证每个事务都按 1、2 的顺序访问。

方法说明
应用层排序在传参前对 id 列表sort
数据库层排序SELECT ... FOR UPDATE按排序顺序锁定要操作的记录
业务层拆分尽量不要在一个事务里批量更新多条记录

4.2 场景二:范围更新触发间隙锁

REPEATABLE READ隔离级别下,范围更新会锁住范围前后不存在的记录,也就是间隙锁。

比如有两张表或对一个范围做更新:

UPDATE orders SET status = 1 WHERE amount > 100 AND amount < 500;

事务 A 更新amount在 100 到 300 的订单,事务 B 更新amount在 300 到 600 的订单。两边可能同时持有部分记录锁,又在请求对方正在持有的间隙锁,最终形成死锁。

间隙锁死锁的一个典型特点是:死锁日志里能看到lock_mode X locks gap before rec或者lock_mode X locks gap之类的关键字。

这类死锁的治理思路要分层:

  • 如果业务允许,把隔离级别改为READ COMMITTED,InnoDB 在该隔离级别下不会使用 Next-Key Lock。
  • 尽量把范围更新改成精确到主键的等值更新。
  • 不要在事务里执行大范围UPDATEDELETE
  • 如果必须批量处理,建议拆成小批次提交,避免长时间持有一大段间隙锁。

4.3 场景三:唯一键冲突或插入意向锁等待

插入场景的死锁更容易被忽视。它的底层机制是:

  1. 事务 A 插入一条记录,因为间隙被事务 B 的 Gap Lock 锁住,A 进入等待。
  2. 事务 B 又尝试插入一条记录,发现需要等待事务 A 释放的插入意向锁或其他锁。
  3. 两个事务互相等待,死锁。

现象通常是两个事务都在执行INSERT,报错可能出现在某个INSERT语句上,但死锁日志里能看到另外一方持有的是 Gap Lock。

典型触发条件:

  • 并发向同一范围插入数据。
  • 使用了唯一索引,插入时发生唯一键冲突,冲突后另一个事务继续等待。
  • INSERT ... ON DUPLICATE KEY UPDATE在更新阶段又去获取其他锁。

检查SHOW ENGINE INNODB STATUS时,如果看到lock_mode X inserts intention waitinglock_mode X locks gap before rec,基本可以判断是间隙锁与插入意向锁冲突。

4.4 排查路径和工具清单

现在把排查路径固定下来。遇到死锁问题,按下面的顺序走:

  1. 先确认错误码:1213是死锁,1205是锁等待超时。
  2. 打开死锁日志:SET GLOBAL innodb_print_all_deadlocks = ON;
  3. 执行SHOW ENGINE INNODB STATUS\G,截取LATEST DETECTED DEADLOCK段。
  4. 从日志中提取两个事务的 SQL、持有的锁、等待的锁。
  5. 画出循环等待关系图,确认环的组成。
  6. 如果死锁仍在发生,用performance_schema.data_locks实时观察事务当前锁状态。
  7. 回到应用层,找到对应事务的调用链,确认 SQL 执行顺序是否固定。

下面的表格可以作为排查工作的速查清单:

排查项命令或位置要确认的信息
错误码应用日志1213 还是 1205
最近死锁SHOW ENGINE INNODB STATUS两个事务的 SQL 和锁等待关系
所有死锁记录MySQL error log开启innodb_print_all_deadlocks
当前未提交事务information_schema.INNODB_TRX事务状态、执行时间、SQL
当前锁MySQL 8.0 用performance_schema.data_locks锁类型、锁模式、锁住的数据
锁等待关系MySQL 8.0 用performance_schema.data_lock_waits谁在等谁

5. 如何避免和修复死锁

5.1 应用层统一更新顺序

最小复现案例已经证明了“更新顺序不一致”是死锁最常见的原因。统一顺序是最简单也最有效的办法。

假设转账业务涉及两个账户,不要这样写:

@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { accountMapper.updateBalance(fromId, amount.negate()); accountMapper.updateBalance(toId, amount); }

两个并发转账如果方向相反,就会出现 A 等 B、B 等 A 的循环等待。改成先排序再更新:

@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { List<Long> ids = Arrays.asList(fromId, toId); Collections.sort(ids); for (Long id : ids) { if (id.equals(fromId)) { accountMapper.updateBalance(id, amount.negate()); } else { accountMapper.updateBalance(id, amount); } } }

排序这个动作看似简单,却能保证不同事务访问同一组资源时,始终按照相同方向加锁,从源头上切断循环等待。

5.2 事务要短,锁要精确

事务越长,持锁时间越长,与其他事务发生冲突的概率越大。

常见的长事务来源:

  • 在事务里调用了外部 HTTP 接口。
  • 在事务里执行大批量查询,再逐条更新。
  • 事务开始后等待用户输入或人工确认。

这些场景都不是数据库本身的问题,而是业务设计问题。事务里尽可能只保留数据库操作,外部调用放到事务外。单条 SQL 尽量通过索引精确定位要更新的行,不要用范围过大的条件。

5.3 索引设计决定锁范围

死锁日志里经常会出现index PRIMARY或者某个二级索引名。InnoDB 是通过索引定位记录的,索引选得不好,锁的范围就会扩大。

无索引或索引区分度低时,一条 update 可能锁住大量记录,甚至整表扫描加锁。最典型的例子是:

UPDATE account SET balance = balance - 100 WHERE status = 'ACTIVE';

如果status区分度很低,优化器可能选择全表扫描,于是所有ACTIVE用户所在的行都会被锁住。两个类似的事务并发时,死锁概率会急剧上升。

合理的索引策略是让WHERE条件能精确命中唯一记录,或至少命中一个很小的索引范围。这里不必盲目给所有字段加索引,索引过多还会影响写入性能,具体字段要根据业务查询条件评估。

5.4 隔离级别调整的取舍

REPEATABLE READ是 MySQL 默认隔离级别,也是死锁高发的常用场景。它的主要特点是具备可重复读能力,同时需要 Next-Key Lock 来防止幻读。

如果业务允许,把隔离级别调整为READ COMMITTED,InnoDB 会放弃 Next-Key Lock,只保留记录锁。这样很多由间隙锁引发的死锁会直接消失。

适合调整为READ COMMITTED的业务特征:

  • 不需要在同一事务里多次读取同一范围的数据。
  • 对幻读不敏感。
  • 能接受某事务提交后,另一个事务后续查询读到最新数据。

调整前要评估两项:

  1. 主从复制:建议把 binlog 格式改为ROW,避免基于语句复制出现数据不一致。
  2. 业务代码:如果有地方依赖 RR 的可重复读语义,需要先改造。

官方默认隔离级别是 RR,调整是可行的,但需要经过充分的业务评审,不能为了“减少死锁”而无脑修改。

5.5 死锁发生后的重试策略

死锁导致的事务回滚是数据库层面的正常保护机制。应用层不能只把异常打印到日志就结束,要主动重试。

一个可落地的重试逻辑包括:

  • 捕获DeadlockLoserDataAccessException或 JDBC 错误码1213
  • 等待一个随机毫秒数,避免所有重试请求再次同时撞上。
  • 记录重试次数,超过上限后人工介入。
  • 重试时重新执行整个事务,不要只重试最后一条 SQL。

伪代码思路:

max_retry = 3 for i in range(max_retry): try: do_transaction() break except DeadlockError: if i == max_retry - 1: raise time.sleep(random.uniform(0.05, 0.2))

重试不是万能药,但能把由死锁引起的偶发失败对用户的影响降到最低。

6. 生产环境实践清单

6.1 发布前检查清单

新功能上线前,按下面清单过一遍,能减少大部分死锁问题:

检查项说明
UPDATEDELETE是否通过索引定位行无索引条件必须补充索引
事务内是否有多条更新语句如果有,确认所有事务访问顺序一致
事务内是否有外部调用有则拆出事务,或改为异步
批量更新范围是否太大大范围更新拆成小批次
是否使用INSERT ... ON DUPLICATE KEY UPDATE评估并发冲突场景
是否了解当前隔离级别明确使用 RR 还是 RC,并确认 binlog 格式
是否开启死锁日志建议生产环境设置innodb_print_all_deadlocks=ON

6.2 监控指标和告警

死锁日志本身不会主动发送到监控平台,需要靠数据库错误日志和应用日志配合。

推荐监控以下指标:

  • 错误日志中关键字Deadlock found的出现次数。
  • information_schema.INNODB_TRX中长时间未提交的事务数。
  • SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK的时间戳。
  • performance_schema中的锁等待事件数量。

如果死锁出现频率从“几天一次”变成“每小时几十次”,说明有新的并发路径改变了锁顺序,需要立刻查看最近上线的业务。

6.3 需要留下的核心结论

死锁不是 MySQL 的 bug,也不是 SQL 写错了,而是多个事务对锁资源的竞争顺序形成了环。真正要解决的是“锁顺序”和“锁范围”。

本文最重要的一个判断是:想避免死锁,不要盯着单条 SQL 看,要看整个事务对锁资源的访问路径。

下一步值得做的练习是:

  1. 在本地环境复现最小案例,跑出1213错误。
  2. 执行SHOW ENGINE INNODB STATUS读一遍死锁日志。
  3. 给业务里的多行更新加排序逻辑,再看并发下是否还出现死锁。

把第一步到第三步完整走一遍,比看十篇死锁原理文章都管用。数据库锁相关的知识,只有亲手抓到一次死锁现场,才算真正入门。

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

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

立即咨询