【第22天】每天一个MySQL知识点,百日打怪升级
事务死锁的成因与避免 —— 死锁检测、锁等待超时
大家好,我是一名拥有10年以上经验的DBA老兵(没有那多)。
做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。
让我们开始今天的第22天内容。
昨天聊了 MVCC,有人问了个很实操的问题:
"线上突然出现
Deadlock found when trying to get lock是什么情况?怎么处理?"
这个问题问得好。死锁是 MySQL 进阶路上绕不过去的坎。从 DBA 的角度看——死锁不是 bug,如果事务加锁顺序不一致,并发越高死锁概率越大。区别在于:有人能在几秒内定位并解决,有人却说要重启数据库😂。
死锁的官方定义很绕:两个或多个事务互相等待对方释放锁,导致所有事务都无法继续。 说白了就是——事务 A 锁住了行 1 等行 2,事务 B 锁住了行 2 等行 1,两边都不让。
今天就把这东西彻底讲清楚。
死锁的四个必要条件
先看一眼理论。死锁要发生,必须同时满足四个条件,缺一不可:
| 条件 | 含义 | MySQL 场景 |
|---|---|---|
| 互斥 | 一个资源同时只能被一个事务占用 | 行锁本身就是互斥的 |
| 持有并等待 | 事务占着一个资源不释放,又去请求另一个资源 | 先 UPDATE 行 1,再 UPDATE 行 2 |
| 不可剥夺 | 已经获得的资源不能被强行拿走 | 行锁只能等事务主动提交或回滚释放 |
| 循环等待 | 两个事务互相等着对方释放资源 | A 等 B,B 等 A |
理论归理论,工作中你不需要背这四个条件。你只需要记住:当两个事务以不同的顺序锁住相同的资源时,死锁就容易发生。
看一个最经典的例子:
-- 事务 A:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 锁住 id=1
-- 等事务 B 释放 id=2...
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 事务 B:
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2; -- 锁住 id=2
-- 等事务 A 释放 id=1...
UPDATE account SET balance = balance + 50 WHERE id = 1;
这就是死锁。 两个转账事务都锁住了自己更新的第一行,然后等对方释放第二行。MySQL 检测到死锁后,会选择一个"代价最小"的事务作为受害者,回滚它,让另一个事务继续。
📝 面试解答:
Q: 死锁和锁等待是一回事吗?
不是。锁等待是事务 B 等事务 A 释放锁,A 提交后 B 就能继续——最终能结束。报错信息是:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction死锁是 A 等 B、B 等 A,没人会主动释放。MySQL 必须强行中止其中一个事务才能打破这个循环。报错信息是:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction两个报错的区别很明显:1205 是等超时了,1213 是检测到死锁了。
Q: MySQL 怎么选择"受害者"?
InnoDB 会计算每个事务已经修改的行数(Undo 量),选择修改行数最少的事务回滚。如果修改行数相同,则选择后加锁的事务回滚。注意这个选择不是"最不重要的事务",而是"回滚代价最小的事务"。
Q: 死锁一定等超时才会处理吗?
不一定。InnoDB 默认开启死锁检测(
innodb_deadlock_detect=ON),通过等待图(wait-for graph)算法实时检测循环等待。检测到死锁后立即回滚一个事务,不需要等到innodb_lock_wait_timeout超时。只有死锁检测关闭时,才需要等到 50 秒超时(默认值)才能解开。
InnoDB 的死锁检测机制
InnoDB 内部维护了一个等待图(wait-for graph),节点是事务,边是"一个事务在等另一个事务释放锁"。每当事务遇到锁等待时,InnoDB 就往这个图里加一条边,然后检查图里有没有环——有环就是死锁。
这个检测不是轮询的,而是在每个锁等待事件发生时立即触发。所以死锁的响应速度非常快——实际发生和检测到之间的时间差通常在微秒级。
但问题来了:如果并发特别高(几千个事务同时争抢同一行),等待图会变得非常大,每次锁等待都要做一轮图检测,CPU 消耗会急剧上升。
MySQL 5.7 之前这个问题很明显——高并发场景下,死锁检测本身就成了性能瓶颈。MySQL 8.0 做了优化(主要是等待图算法的局部优化),但如果你的业务场景是"每秒几千个事务更新同一行",死锁检测的 CPU 开销还是不容忽视。
解决方案:
- 如果确认业务逻辑不会产生死锁(比如所有事务都以相同的顺序访问行),可以关闭死锁检测:
innodb_deadlock_detect=OFF。这样数据库不再做图检测,锁等待就靠innodb_lock_wait_timeout超时(默认50秒)来硬扛 - 关闭死锁检测后,数据库不再主动检测死锁,锁等待事务需等到 innodb_lock_wait_timeout 超时后才报错回滚,间接打破死锁循环。所以只适合你确信"不会产生死锁"的场景。
常见死锁场景
场景一:转账类(不同加锁顺序)
上面那个转账例子就是典型。解决方案最简单:所有事务都按同一个顺序加锁。
-- 不管转出还是转入,都先锁 id 小的行,再锁 id 大的行
-- 比如统一按 id 升序加锁:
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
这样就不会出现"A 锁 1 等 2,B 锁 2 等 1"的循环。
场景二:外键与主键的隐式锁
这是一个我踩过的坑。假设有父子表:
CREATE TABLE parent (
id INT PRIMARY KEY
);
CREATE TABLE child (
id INT PRIMARY KEY,
parent_id INT,
FOREIGN KEY (parent_id) REFERENCES parent(id)
);
在 RR 级别下,对 child 表的 INSERT 或 UPDATE 会触发外键检查——需要检查 parent_id 在 parent 表中是否存在。这个检查会对 parent 表的对应记录加锁。
事务 A:INSERT INTO child VALUES (1, 100); -- 对 parent.id=100 加 S 锁
事务 B:DELETE FROM parent WHERE id = 100; -- 需要 X 锁,但被 A 的 S 锁阻塞
事务 A:继续执行其他操作...(还没提交)
如果事务 A 再对 parent 表做操作,就可能和事务 B 形成死锁。隐式锁最难排查——你盯着自己的 SQL 看半天,根本没写过 LOCK 关键字。
场景三:Gap Lock 导致的死锁
RR 级别下,Gap Lock 会把死锁的可能性放大。经典的构造如下:
-- 表:t(id INT PRIMARY KEY)
-- 数据:id=1, id=10
-- 事务 A:
BEGIN;
SELECT * FROM t WHERE id = 5 FOR UPDATE; -- 在(1,10)加 Gap Lock(id=5 不存在)
-- 事务 B:
BEGIN;
SELECT * FROM t WHERE id = 6 FOR UPDATE; -- 也在(1,10)加 Gap Lock,不冲突
注意这里 SELECT ... FOR UPDATE 加的是排他的 Next-Key Lock,但它内部的 Gap Lock 组件之间不互斥——间隙锁的目的是阻止插入,而非排斥其他间隙锁持有者。所以两个事务都能获取到 Gap Lock,此时一切正常,谁都不等谁。
然后矛盾在 INSERT 时爆发:
-- 事务 A 继续:
INSERT INTO t VALUES (6); -- 需要插入意向锁,等待 B 的 Gap Lock
-- 事务 B 继续:
INSERT INTO t VALUES (5); -- 也需要插入意向锁,等待 A 的 Gap Lock
插入意向锁(insert intention lock) 是 InnoDB 的一种特殊锁,在 INSERT 真正执行前获取。它和 Gap Lock 冲突——如果有其他事务持有同一间隙的 Gap Lock,INSERT 必须等它释放。这样就形成了"A 等 B 的 Gap Lock,B 等 A 的 Gap Lock"的死锁。
RR 级别下用 SELECT ... FOR UPDATE 做"是否存在"判断时尤其容易踩这个坑。
死锁的定位与诊断
线上出现死锁了,第一步可千万别重启数据库,也完全不需要,而是查死锁日志。
-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G
重点关注输出中的 LATEST DETECTED DEADLOCK 章节。它会告诉你这几件事:
| 信息 | 含义 |
|---|---|
事务 A(WAITING FOR THIS LOCK TO BE GRANTED) |
A 在等什么锁 |
事务 A HOLDS THE LOCK |
A 持有什么锁 |
事务 B(WAITING FOR THIS LOCK TO BE GRANTED) |
B 在等什么锁 |
事务 B HOLDS THE LOCK |
B 持有什么锁 |
WE ROLL BACK TRANSACTION (2) |
谁被回滚了(这里回滚了事务 2) |
WAITING FOR THIS LOCK TO BE GRANTED |
具体等的是哪行、什么类型的锁 |
但 SHOW ENGINE INNODB STATUS 有个问题——它只保留最近一次死锁的信息。 如果死锁频率高,历史记录会被覆盖。
更靠谱的持久化方案:开启死锁日志到 MySQL 错误日志。
# my.cnf
innodb_print_all_deadlocks = 1
开启后,每次死锁都会完整记录到 MySQL 错误日志(一般是主机上的 error.log),不会丢失。生产环境建议默认启用,性能影响极小。
此外,MySQL 8.0 的 performance_schema 中 events_transactions_current 表也能辅助定位死锁,但最实时的办法还是三个:
SHOW ENGINE INNODB STATUS— 看最近的死锁详情innodb_print_all_deadlocks=ON— 每条死锁都记日志SELECT * FROM performance_schema.data_lock_waits— 看当前谁在等锁(如果死锁还没被检测到)
死锁避免的最佳实践
死锁不可能完全消灭,但可以大幅降低发生频率。以下几条是经过生产验证的:
1. 统一加锁顺序
不管业务逻辑多复杂,所有事务都以相同的顺序访问表和行。这是最有效的死锁预防手段。
-- 坏:不固定顺序
IF a.id < b.id THEN
UPDATE a ...; UPDATE b ...;
ELSE
UPDATE b ...; UPDATE a ...;
END IF;
-- 好:按 id 升序
UPDATE a ...; -- 先小的
UPDATE b ...; -- 再大的
2. 减少事务持有锁的时间
事务越小、越快提交,死锁窗口越窄:
- 大事务拆成小事务
- 不要在事务中做耗时的外部调用(RPC、HTTP 请求、等待用户输入)
- 大批量更新拆成多个批次
3. 选择合适的隔离级别
如果业务允许,用 RC 代替 RR。RC 下普通 DML 不再加 Gap Lock,死锁概率大幅降低(外键和唯一键检查仍可能触发 Gap Lock)。MySQL 8.0 的默认级别还是 RR,但很多互联网公司线上跑的是 RC,原因之一就是死锁更少。
4. 关键表加索引
无索引的 UPDATE 会锁住所有行(第20天的内容),这等于把锁竞争的范围从"几行"放大到"整张表"。死锁概率指数级上升。
5. 真正高并发的场景考虑用 Redis
MySQL 里的版本号乐观锁虽然能避免显式加锁,但本质上还是在用 MySQL 的行锁做更新——只是把"先查再改"变成了"条件更新+重试"。高并发下冲突率高了,重试次数飙升,MySQL 的 CPU 和行锁竞争压力反而更大。
对于真正的高并发原子操作(秒杀扣库存、账户余额变更),更好的做法是把工具换掉——Redis。
Redis 单线程执行命令,天然没有锁竞争的问题:
# 扣库存:Redis单线程原子操作
DECR stock:10001
# 读取余额然后扣减:事务保证原子性
WATCH account:1:balance
balance = GET account:1:balance
if balance >= 100
MULTI
DECRBY account:1:balance 100
EXEC
Redis 的原子操作、Lua 脚本、加上 WATCH/MULTI/EXEC 的事务机制,在高并发下比 MySQL 的乐观锁重试高效得多。而 MySQL 更适合保证最终一致性——比如用 Redis 扣完库存后,异步写回 MySQL 作为可靠落盘。
不是所有锤子都能解决所有钉子。高并发原子操作 → 用 Redis;数据可靠性和复杂查询 → 用 MySQL。
🤖 AI实战工具箱:让AI帮你造死锁测试
死锁是最难手工复现的问题之一,但 AI 最擅长生成"对称时序"的脚本。
场景一:经典循环等待死锁
把下面这段粘给 AI:
帮我写一组 MySQL 测试 SQL,演示两个事务互相等待的死锁场景。建两张表(account_a 和 account_b,各有一条记录 id=1, balance=100)。终端 A 先 UPDATE account_a 再 SLEEP,然后 UPDATE account_b;终端 B 先 UPDATE account_b 再 SLEEP,然后 UPDATE account_a。用 SLEEP 精确控制时序,让两个事务形成循环等待。最后用 SHOW ENGINE INNODB STATUS 查看死锁信息,代码中标注谁被回滚了。
场景二:Gap Lock 导致的死锁
帮我写一组 MySQL 测试 SQL(RR 隔离级别),演示 Gap Lock 导致的死锁。建表 t(id INT PRIMARY KEY),已有 id=1 和 id=10。
终端 A:SELECT * FROM t WHERE id=5 FOR UPDATE;—— 锁定间隙 (1,10),获取 Gap Lock。
终端 B:SELECT * FROM t WHERE id=6 FOR UPDATE;—— 也锁定同一间隙,Gap Lock 不互斥,不阻塞。
然后 A 执行INSERT INTO t VALUES (6);,B 执行INSERT INTO t VALUES (5);。观察死锁是否发生,最后用SHOW ENGINE INNODB STATUS查看。注释每一步说明插入意向锁与 Gap Lock 的冲突。
场景三:分析自己的死锁日志
下面是我数据库的 SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 输出,帮我分析:1. 哪两个事务在死锁?2. 分别持有和等待什么锁?3. 谁被回滚了?4. 如何修改代码避免这个死锁?[粘贴你的日志]
第三个场景最实用——线上抓到死锁日志,贴给 AI,几秒钟就能把锁的竞争关系拆清楚。
思考题
🤔 互动时间:
- 如果两个事务都 UPDATE 同一行,会发生死锁吗?
- 为什么把隔离级别从 RR 改成 RC 能减少死锁?RC 下还有 Gap Lock 吗?
innodb_deadlock_detect=OFF有什么风险?什么场景下才适合关闭?
总结
🎯 面试考点
- 死锁的本质:两个及以上事务循环等待对方释放锁,InnoDB 通过等待图检测
- 四个必要条件:互斥、持有并等待、不可剥夺、循环等待(理论只需了解,实操看循环等待)
- 最有效的预防:所有事务统一加锁顺序,这是最简单且最有效的方法
- 各隔离级别的影响:RR 有 Gap Lock → 死锁概率高;RC 无 Gap Lock → 死锁概率低
- 死锁诊断:
SHOW ENGINE INNODB STATUS(最近一次)+innodb_print_all_deadlocks=ON(持久化) - InnoDB 的选择:回滚 Undo 量最小的事务,而不是"最不重要的那个"
- 死锁 vs 锁等待:死锁立即检测回滚;锁等待靠超时释放
下期预告:慢查询日志分析 —— 面试必问!
有问题欢迎评论区交流,明天见!