MySQL 死锁:形成原因、检测、日志分析、重试和规避(123)

MySQL与关系型数据库约 6 分钟阅读3,531 字符
知识库文档 124 / 251

阅读时间:约 45 分钟 | 难度:中高级 | 编号:123


一、引言:死锁是并发编程的经典问题

死锁不是 MySQL 的 bug,而是并发系统中锁互斥的必然结果。InnoDB 会自动检测死锁并回滚其中一个事务,但理解死锁的形成原因、学会分析死锁日志、掌握规避策略,是每个后端工程师的必备技能。

死锁的四要素: 互斥、持有并等待、不可剥夺、循环等待。只要满足这四个条件,死锁就可能发生。

文章配图
文章配图


二、死锁的形成原理

2.1 经典死锁场景

code
事务 A:                          事务 B:
START TRANSACTION;               START TRANSACTION;
UPDATE t SET name='A'             UPDATE t SET name='B'
WHERE id = 1;  -- 持有 id=1 X锁   WHERE id = 2;  -- 持有 id=2 X锁
                                   UPDATE t SET name='B'
UPDATE t SET name='A'              WHERE id = 1;  -- 等待 id=1 X锁
WHERE id = 2;  -- 等待 id=2 X锁     ← 互相等待 → 死锁!

2.2 死锁的四个必要条件

条件 说明 在 InnoDB 中的体现
互斥 资源只能被一个事务占用 X 锁排他
持有并等待 持有资源的同时等待其他资源 事务持有行锁,等待其他行锁
不可剥夺 资源不能被强制释放 事务的锁不能被其他事务解除
循环等待 事务之间形成循环等待链 A 等 B,B 等 A

文章配图
文章配图


三、常见死锁场景

3.1 场景一:不同顺序访问相同资源

sql
-- 事务 A: 先锁 id=1,再锁 id=2
UPDATE t SET name='A' WHERE id = 1;
UPDATE t SET name='A' WHERE id = 2;

-- 事务 B: 先锁 id=2,再锁 id=1(顺序相反!)
UPDATE t SET name='B' WHERE id = 2;
UPDATE t SET name='B' WHERE id = 1;

解决: 统一资源访问顺序,所有事务按相同顺序加锁。

3.2 场景二:INSERT 与间隙锁

sql
-- 事务 A: 范围查询加锁
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;

-- 事务 B: 插入 id=15
INSERT INTO t VALUES (15, 'B');
-- 等待事务 A 释放间隙锁

-- 事务 A: 插入 id=15
INSERT INTO t VALUES (15, 'A');
-- 等待事务 B... 死锁!

3.3 场景三:二级索引与聚簇索引

sql
-- 索引 idx_name (name)
-- 事务 A: 通过二级索引更新
UPDATE t SET age = 25 WHERE name = 'Alice';
-- 先锁 idx_name 中的记录,再回表锁聚簇索引中的行

-- 事务 B: 直接通过主键更新
UPDATE t SET age = 30 WHERE id = 1;  -- id=1 对应 name='Alice'
-- 先锁聚簇索引,如果也要更新 name...

-- 加锁顺序相反 → 死锁

文章配图
文章配图


四、死锁检测与处理

4.1 InnoDB 的死锁检测

InnoDB 在事务等待锁时,自动检测死锁:

  • 检测到死锁 → 回滚其中一个事务(通常是持有最少行锁的事务)
  • 回滚的事务收到 ERROR 1213 (40001): Deadlock found when trying to get lock

死锁检测的性能开销: 当 innodb_deadlock_detect=ON(默认),InnoDB 需要检查等待图。在高并发下(如 1000+ 并发线程),死锁检测本身可能成为性能瓶颈。

4.2 死锁日志分析

sql
SHOW ENGINE INNODB STATUS\G
-- 搜索 "LATEST DETECTED DEADLOCK"

死锁日志关键信息:

code
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s)
MySQL thread id 10, query id 100
UPDATE t SET name='A' WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 4 n bits 80
index PRIMARY of table `test`.`t` trx id 12345
lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 0 page no 4 n bits 80
index PRIMARY of table `test`.`t` trx id 12346
lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 4 n bits 80
index PRIMARY of table `test`.`t` trx id 12346
lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD

*** WE ROLL BACK TRANSACTION (2)

解读要点:

  1. 看 (1) WAITING FOR 和 (2) WAITING FOR → 理解互相等待什么锁
  2. 看 (1) HOLDS THE LOCK 和 (2) HOLDS THE LOCK → 理解各自持有什么锁
  3. 看 WE ROLL BACK TRANSACTION (2) → 哪个事务被回滚

文章配图
文章配图


五、死锁的规避策略

5.1 策略一:统一访问顺序

所有事务按相同顺序访问资源。例如,始终先锁 id 小的行,再锁 id 大的行。

sql
-- 按 id 排序后处理
SELECT * FROM t WHERE id IN (2, 1) ORDER BY id FOR UPDATE;

5.2 策略二:缩短事务

事务越小,持有锁的时间越短,死锁概率越低。

sql
-- 坏:大事务
START TRANSACTION;
UPDATE t1 SET ...;
-- 大量业务逻辑
UPDATE t2 SET ...;
COMMIT;

-- 好:小事务
START TRANSACTION;
UPDATE t1 SET ...;
COMMIT;

START TRANSACTION;
UPDATE t2 SET ...;
COMMIT;

5.3 策略三:使用低隔离级别

READ COMMITTED 不使用间隙锁,减少锁冲突。

5.4 策略四:索引优化

确保 UPDATE/DELETE 使用索引,避免全表扫描导致大量行锁。

5.5 策略五:应用层重试

java
int retry = 3;
while (retry-- > 0) {
    try {
        // 执行事务
        break;
    } catch (DeadlockLoserDataAccessException e) {
        if (retry == 0) throw e;
        Thread.sleep(100);  // 短暂等待后重试
    }
}

文章配图
文章配图


六、死锁与锁等待的区别

维度 死锁 锁等待
定义 互相等待,形成环 单向等待
检测 InnoDB 自动检测 超时参数控制
处理 回滚一个事务 等待超时或锁释放
参数 innodb_deadlock_detect innodb_lock_wait_timeout
日志 SHOW ENGINE INNODB STATUS performance_schema

文章配图
文章配图


七、实战案例

案例一:批量更新的死锁

sql
-- 多个事务同时执行
UPDATE t SET status = 'DONE' WHERE id IN (1, 2, 3, 4, 5);

问题: 不同事务的 IN 列表中的 id 顺序不同,加锁顺序不同,导致死锁。

解决: 应用层对 id 排序后再执行。

案例二:INSERT ON DUPLICATE KEY UPDATE 死锁

sql
-- 多个事务同时插入相同的唯一键
INSERT INTO t (id, name) VALUES (1, 'A')
ON DUPLICATE KEY UPDATE name = 'A';

问题: 三个事务同时 INSERT,都获取了 S 锁(duplicate key 检查),然后都需要升级为 X 锁,互相等待。

解决: 使用 INSERT ... ON DUPLICATE KEY UPDATE 时,确保不会有多个事务同时插入相同的唯一键。

文章配图
文章配图


八、总结

策略 效果 代价
统一访问顺序 消除循环等待 需要应用层配合
缩短事务 减少锁持有时间 需要拆分业务逻辑
低隔离级别 减少间隙锁 接受幻读
索引优化 减少锁范围 需要索引设计
应用层重试 自动恢复 增加代码复杂度

文章配图
文章配图


九、面试官视角提问

Q1:InnoDB 如何检测死锁?等待图(Wait-for Graph)是如何构建的?死锁检测的时间复杂度是多少?

Q2:**innodb_deadlock_detect=OFF**会有什么后果?什么场景下应该关闭死锁检测?

Q3:两个事务都执行**SELECT * FROM t WHERE id=5 FOR UPDATE**(id=5 不存在),会产生死锁吗?为什么?

Q4:**INSERT ... ON DUPLICATE KEY UPDATE**为什么容易导致死锁?从锁的角度分析整个过程。

Q5:一个事务持有 1000 个行锁,另一个事务等待其中 1 个行锁。如果发生死锁,InnoDB 会回滚哪个事务?为什么?

Q6:你在生产环境遇到过一次死锁,但日志中显示**WE ROLL BACK TRANSACTION (2)**,事务 (2) 是哪个?InnoDB 选择回滚事务的标准是什么?


作者注:本文为系列第 123 篇,属于「08 MySQL 与关系型数据库」分类。


十、面试官提问 · 参考答案

Q1:InnoDB 如何检测死锁?等待图如何构建?时间复杂度?

参考答案:

InnoDB 使用等待图(Wait-for Graph)检测死锁。 节点是事务,边是"事务 A 等待事务 B 释放锁"。当等待图中出现环时,死锁发生。

时间复杂度:O(n),n 是等待图中的节点数。 当并发线程数很高(如 1000+),死锁检测本身可能成为性能瓶颈。这就是 innodb_deadlock_detect=OFF 的动机——在高并发场景下,关闭死锁检测,依赖 innodb_lock_wait_timeout 超时回滚。


Q2: 有什么后果?什么场景关闭?

参考答案:

后果: 死锁不会被自动检测,事务会一直等待直到 innodb_lock_wait_timeout 超时(默认 50 秒)。用户体验变差。

关闭场景: 极高并发(1000+ 并发线程),死锁检测的 O(n) 开销超过死锁本身的开销。MySQL 官方建议在并发超过 200 时考虑关闭。

替代方案: 关闭死锁检测后,减小 innodb_lock_wait_timeout(如设为 5 秒),让等待超时的事务快速回滚。


Q3:两个事务都执行 (id=5 不存在),会死锁吗?

参考答案:

不会死锁。 两个事务都获取了 (上一个id, 下一个id) 的间隙锁。间隙锁之间不冲突,两个事务可以同时持有。

但如果一个事务尝试 INSERT id=5: 会被另一个事务的间隙锁阻塞,但不会死锁——只是单向等待。


Q4: 为什么容易死锁?

参考答案:

三个事务同时 INSERT 相同的唯一键值:

  1. 事务 A、B、C 都获取了该唯一键的 S 锁(duplicate key 检查)
  2. 发现记录已存在,都需要将 S 锁升级为 X 锁(执行 UPDATE)
  3. S 锁之间兼容,但 S→X 升级需要等待所有 S 锁释放
  4. A 等 B 释放 S 锁,B 等 C 释放 S 锁,C 等 A 释放 S 锁 → 死锁

解决: 使用 INSERT ... ON DUPLICATE KEY UPDATE 前,先用 SELECT ... FOR UPDATE 获取 X 锁,避免 S→X 升级。


Q5:事务 A 持有 1000 个行锁,事务 B 等待其中 1 个。发生死锁回滚谁?

参考答案:

回滚事务 B。 InnoDB 选择回滚"持有最少行锁"的事务,或者"undo log 最少"的事务(回滚代价最小)。

选择标准: 回滚代价最小的事务。事务 B 只持有 1 个行锁(或没有),回滚代价远小于事务 A(持有 1000 个行锁)。

注意: 这不是选择"回滚等待者",而是选择"回滚代价最小的"。如果事务 A 持有 1 个行锁,事务 B 持有 1000 个行锁,InnoDB 会回滚事务 A。


Q6:生产环境死锁日志 ,事务 (2) 是哪个?选择标准?

参考答案:

事务 (2) 是死锁日志中第二个列出的事务。 InnoDB 回滚的是"回滚代价最小"的事务,标准是:

  1. 持有行锁数量少的事务
  2. undo log 数量少的事务
  3. 如果两者相同,选择权重较小的事务(**innodb_trx_weight**)

死锁日志中的 (1) 和 (2) 不是按时间顺序排列,而是 InnoDB 内部的编号。 回滚哪个事务由代价决定,不是由编号决定。


全文完 | 编号 123 | 分类 08 MySQL 与关系型数据库