死锁专题分析
导学
事务和锁保证了并发安全,但两把锁交叉等待时就会陷入死锁——事务 A 持有锁 1 等待锁 2,事务 B 持有锁 2 等待锁 1,双方永远等下去。MySQL 5.7 有自动死锁检测,但检测本身也有开销。本节从死锁的四个必要条件出发,教你读懂 SHOW ENGINE INNODB STATUS 的死锁日志,并写出最小化死锁风险的 SQL。
定义
死锁(Deadlock):两个或多个事务互相持有对方需要的锁,且都在等待对方释放,导致所有涉及事务都无法继续执行的状态。InnoDB 会自动检测死锁并选择一个事务作为"牺牲者"回滚,但频繁死锁会严重拖累性能。
死锁的四个必要条件
死锁同时满足以下四个条件才会发生,破坏任意一个即可避免:
| 条件 | 含义 | 在 MySQL 中的体现 | 破坏手段 |
|---|---|---|---|
| 互斥 | 资源一次只能被一个事务占用 | 行锁(X锁/S锁)是互斥的 | 无法破坏(锁的本质) |
| 持有并等待 | 事务持有锁的同时还在请求新锁 | 事务 A 持有记录 1 的锁,等待记录 2 | 一次性申请所有需要的锁;或按固定顺序加锁 |
| 不可抢占 | 已获得的锁不能被强制剥夺 | InnoDB 不支持锁抢占,只能等事务提交/回滚 | 设置锁超时(innodb_lock_wait_timeout) |
| 循环等待 | 事务之间形成环形等待链 | A 等 B,B 等 A | 按主键/索引固定顺序访问记录 |
场景一:构造一个死锁
当前数据状态:
CREATE TABLE employees (
emp_id INT PRIMARY KEY,
emp_name VARCHAR(20),
score DECIMAL(5,2)
) ENGINE=InnoDB;
INSERT INTO employees VALUES (1, '大翔', 100), (2, '白歌', 85);
| emp_id | emp_name | score |
|---|---|---|
| 1 | 大翔 | 100 |
| 2 | 白歌 | 85 |
操作语句(两个会话同时执行):
会话 A:
START TRANSACTION;
UPDATE employees SET score = score + 10 WHERE emp_id = 1; -- 成功,持有 emp_id=1 的 X 锁
-- 此时切换到会话 B 执行第一步
UPDATE employees SET score = score + 10 WHERE emp_id = 2; -- 等待会话 B 释放 emp_id=2 的 X 锁
-- 死锁发生,MySQL 自动回滚本事务
会话 B:
START TRANSACTION;
UPDATE employees SET score = score + 10 WHERE emp_id = 2; -- 成功,持有 emp_id=2 的 X 锁
-- 此时切换到会话 A 执行第二步
UPDATE employees SET score = score + 10 WHERE emp_id = 1; -- 等待会话 A 释放 emp_id=1 的 X 锁
-- 死锁发生,MySQL 自动回滚本事务(或会话 A 被回滚)
结果解读:
- 会话 A 持有记录 1 的锁,等待记录 2
- 会话 B 持有记录 2 的锁,等待记录 1
- 形成循环等待,死锁产生
- InnoDB 死锁检测器发现后,选择代价较小的事务(通常修改行数少、undo 量小)作为牺牲者回滚,并返回错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction - 未被回滚的事务继续执行,成功提交
场景二:查看死锁日志
死锁发生后,立即执行:
SHOW ENGINE INNODB STATUS;
在输出中找到 LATEST DETECTED DEADLOCK 段落(节选):
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 14:32:10 0x7f8b8c0a2700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 12 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 15, OS thread handle 123456789, query id 100 localhost root updating
UPDATE employees SET score = score + 10 WHERE emp_id = 2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `company`.`employees` trx id 12345 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000001; asc ;; -- emp_id = 1
1: len 6; hex 000000000303; asc ;;
2: len 7; hex b20000011b0110; asc ;;
3: len 6; hex e5a4a7e7bf 94; asc 大翔;; -- emp_name = 大翔
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `company`.`employees` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000002; asc ;; -- emp_id = 2
...
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 10 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 16, OS thread handle 123456790, query id 101 localhost root updating
UPDATE employees SET score = score + 10 WHERE emp_id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `company`.`employees` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000002; asc ;; -- emp_id = 2
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `company`.`employees` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000001; asc ;; -- emp_id = 1
...
*** WE ROLL BACK TRANSACTION (1)
日志解读:
| 字段 | 含义 |
|---|---|
TRANSACTION 12345 | 事务 ID,唯一标识 |
ACTIVE 12 sec | 事务已活跃 12 秒 |
HOLDS THE LOCK(S) | 该事务当前持有的锁 |
WAITING FOR THIS LOCK | 该事务正在等待的锁 |
lock_mode X | 排他锁(X Lock) |
locks rec but not gap | 记录锁,非间隙锁 |
hex 00000001 | 十六进制表示的主键值 1(大翔) |
WE ROLL BACK TRANSACTION (1) | 事务 1(12345)被选为牺牲者回滚 |
结果解读:
- 事务 1(会话 A)持有
emp_id=1(大翔)的 X 锁,等待emp_id=2(白歌)的 X 锁 - 事务 2(会话 B)持有
emp_id=2(白歌)的 X 锁,等待emp_id=1(大翔)的 X 锁 - MySQL 选择回滚事务 1,事务 2 继续执行
场景三:死锁检测机制
innodb_deadlock_detect:控制是否开启死锁检测,默认 ON。
-- 查看当前设置
SELECT @@innodb_deadlock_detect;
| @@innodb_deadlock_detect |
|---|
| 1 |
操作语句:关闭死锁检测后,死锁不再被自动检测,事务将一直等待直到超时。
SET GLOBAL innodb_deadlock_detect = OFF;
结果解读:
ON(默认):InnoDB 主动检测死锁,发现后立即回滚牺牲者事务。检测通过**等待图(Wait-For Graph)**实现——每个事务是节点,等待关系是边,检测图中是否存在环。OFF:不主动检测死锁。如果发生死锁,事务会一直等待,直到innodb_lock_wait_timeout(默认 50 秒)超时后报错Lock wait timeout exceeded。- 高并发场景下死锁检测是性能瓶颈:当大量事务(> 1000)并发更新同一批热点行时,死锁检测需要遍历等待链,CPU 飙升。此时可考虑关闭死锁检测,改用
innodb_lock_wait_timeout控制,但会牺牲响应速度。
场景四:间隙锁导致的死锁
当前数据状态:
CREATE TABLE employees_gap (
emp_id INT PRIMARY KEY,
emp_name VARCHAR(20),
score DECIMAL(5,2)
) ENGINE=InnoDB;
INSERT INTO employees_gap VALUES (1, '大翔', 100), (5, '白歌', 85), (10, '孔蓝', 90);
| emp_id | emp_name | score |
|---|---|---|
| 1 | 大翔 | 100 |
| 5 | 白歌 | 85 |
| 10 | 孔蓝 | 90 |
操作语句(RR 隔离级别下):
会话 A:
START TRANSACTION;
-- 范围查询加间隙锁,锁住 (1, 5] 和 (5, 10] 的间隙
SELECT * FROM employees_gap WHERE emp_id > 1 AND emp_id < 10 FOR UPDATE;
-- 此时切换到会话 B
INSERT INTO employees_gap VALUES (7, '小崔', 80); -- 等待会话 B 释放间隙锁
会话 B:
START TRANSACTION;
-- 同样范围查询,请求间隙锁
SELECT * FROM employees_gap WHERE emp_id > 1 AND emp_id < 10 FOR UPDATE;
-- 间隙锁不冲突,成功(双方都是间隙锁,兼容)
-- 但接下来
INSERT INTO employees_gap VALUES (8, '小赵', 75); -- 等待会话 A 释放间隙锁
结果解读:
- 在
REPEATABLE READ下,FOR UPDATE的范围查询会加间隙锁(Gap Lock) - 双方先获得各自的间隙锁(间隙锁之间不互斥),然后都尝试在对方锁住的间隙内插入新记录
- 插入意向锁(Insert Intention Lock)与间隙锁冲突,形成死锁
- 这种死锁比行锁死锁更隐蔽,因为双方第一步都"成功"了
最小化死锁的 SQL 编写策略
策略一:按固定顺序访问记录
问题根源:交叉访问顺序是死锁最常见的原因。
正确写法:
-- 无论业务逻辑如何,UPDATE 多条记录时始终按主键升序
START TRANSACTION;
UPDATE employees SET score = score + 10 WHERE emp_id = 1; -- 先访问小 ID
UPDATE employees SET score = score + 10 WHERE emp_id = 2; -- 再访问大 ID
COMMIT;
结果解读:如果所有事务都按 emp_id 升序加锁,就不会出现 A 先锁 1 再锁 2、B 先锁 2 再锁 1 的交叉情况。循环等待条件被破坏。
策略二:一次性申请所有锁(减少持有并等待)
-- 不好:先查再逐条更新,每条都单独加锁
START TRANSACTION;
SELECT emp_id FROM employees WHERE dept = '技术部'; -- 查出 1, 2
UPDATE employees SET score = score + 10 WHERE emp_id = 1;
UPDATE employees SET score = score + 10 WHERE emp_id = 2;
COMMIT;
-- 更好:用 IN 一次性更新,MySQL 内部会排序后统一加锁
START TRANSACTION;
UPDATE employees SET score = score + 10 WHERE emp_id IN (1, 2);
COMMIT;
结果解读:UPDATE ... WHERE emp_id IN (1, 2) 比两条独立的 UPDATE 更安全,因为 InnoDB 会一次性确定所有需要加锁的行,并按顺序加锁,减少中间状态被其他事务插入的机会。
策略三:缩短事务长度
-- 不好:事务中夹杂业务逻辑、网络请求、用户交互
START TRANSACTION;
UPDATE employees SET score = 100 WHERE emp_id = 1;
-- 这里调用外部 API,耗时 3 秒
UPDATE employees SET score = 90 WHERE emp_id = 2;
COMMIT;
-- 更好:事务只包含纯数据库操作
START TRANSACTION;
UPDATE employees SET score = 100 WHERE emp_id = 1;
UPDATE employees SET score = 90 WHERE emp_id = 2;
COMMIT;
-- 外部 API 在事务外调用
结果解读:事务持有锁的时间越长,与其他事务冲突的概率越大。将非数据库操作移到事务外,是降低死锁率最有效的手段之一。
策略四:降低隔离级别(权衡一致性)
-- 如果业务允许不可重复读和幻读,使用 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM employees WHERE emp_id = 1 FOR UPDATE;
COMMIT;
结果解读:READ COMMITTED 下 InnoDB 不使用间隙锁(Gap Lock),只加记录锁。这消除了大量由间隙锁导致的死锁,但会引入幻读问题。适合对一致性要求不极端、并发量大的场景。
策略五:设置合理的锁等待超时
-- 查看当前超时设置
SELECT @@innodb_lock_wait_timeout;
| @@innodb_lock_wait_timeout |
|---|
| 50 |
-- 业务场景允许时,适当缩短超时,避免死锁检测关闭时长等待
SET SESSION innodb_lock_wait_timeout = 10;
结果解读:innodb_lock_wait_timeout 控制事务等待锁的最长时间(秒)。如果关闭死锁检测(innodb_deadlock_detect = OFF),这个参数是唯一的"逃生通道"。即使开启死锁检测,缩短超时也能让非死锁的单纯锁等待更快失败,减少连接堆积。
常见误区
| 误区 | 正解 |
|---|---|
| "死锁是 MySQL 的 bug" | 不是。死锁是并发事务正常交互的结果,任何支持事务的数据库都可能发生。 |
| "死锁检测关闭后就不会死锁" | 不是。死锁仍然会发生,只是 MySQL 不再自动检测和回滚,事务会等到超时。 |
| "死锁只发生在 UPDATE 上" | 不是。SELECT ... FOR UPDATE、DELETE、INSERT(间隙锁冲突)都可能死锁。 |
| "牺牲者事务一定是后启动的" | 不是。MySQL 选择牺牲者的依据是 undo 量(回滚代价),通常是修改行数少、事务轻的那个,与启动顺序无关。 |
| "死锁日志里的 hex 值看不懂" | hex 00000001 就是主键值 1 的 4 字节大端表示。可以用 SELECT CONV('00000001', 16, 10) 验证。 |
面试考点
Q:死锁的四个必要条件?
互斥、持有并等待、不可抢占、循环等待。在 MySQL 中,破坏循环等待最有效的方式是按固定顺序访问记录(如按主键升序);破坏持有并等待的方式是一次性申请所有锁或缩短事务。
Q:SHOW ENGINE INNODB STATUS 死锁日志怎么看?
找
LATEST DETECTED DEADLOCK段落。看(1) HOLDS THE LOCK(S)和(1) WAITING FOR THIS LOCK分别表示事务 1 持有的锁和等待的锁;(2)同理。WE ROLL BACK TRANSACTION (N)表示回滚了哪个事务。lock_mode X是排他锁,S是共享锁,locks rec but not gap是记录锁,locks gap before rec是间隙锁。
Q:高并发下死锁检测为什么消耗 CPU?
死锁检测通过遍历等待图(Wait-For Graph)找环实现。当大量事务并发访问同一批热点行时,等待关系复杂,检测算法的时间复杂度上升,导致 CPU 飙升。此时可考虑关闭
innodb_deadlock_detect,依赖innodb_lock_wait_timeout,但会牺牲响应速度。
Q:间隙锁死锁和普通行锁死锁有什么区别?
行锁死锁通常是双方已持有不同行的 X 锁,再请求对方持有的行。间隙锁死锁更隐蔽:双方先获得兼容的间隙锁(不互斥),然后都试图在对方锁住的间隙内插入记录,插入意向锁与间隙锁冲突,形成死锁。降低隔离级别到
READ COMMITTED可消除间隙锁死锁。
小结
- 死锁四条件:互斥、持有并等待、不可抢占、循环等待。破坏循环等待和持有并等待是 SQL 层最有效的手段
- 按固定顺序(如主键升序)访问记录,可彻底消除交叉等待型死锁
SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段落是分析死锁的唯一可靠来源- 缩短事务长度、一次性申请所有锁、适当降低隔离级别,都是降低死锁率的有效策略
- 高并发热点行场景下,
innodb_deadlock_detect可能成为 CPU 瓶颈,需权衡检测与超时策略
清理测试数据:
DROP TABLE IF EXISTS employees;
DROP TABLE IF EXISTS employees_gap;
下一章引子:死锁是并发控制的极端情况,而日常开发中更常见的是锁等待导致的慢查询。Performance Schema 和 sys Schema 提供了系统化的锁等待诊断工具。