在上一篇中,我们见证了 Next-Key Lock 如何阻止幻读。但锁是一把双刃剑——它保护数据一致性的同时,也带来了新的风险:死锁。当两个或多个事务互相持有对方需要的锁资源,形成循环等待时,所有参与者都无法继续执行,就像堵死在十字路口的车流。
本文将深入分析死锁的方方面面:
- 死锁的四个必要条件(及破坏方法)
- InnoDB 的死锁检测机制(等待图)
- 死锁超时参数的作用与局限
- 如何从
SHOW ENGINE INNODB STATUS日志中解读死锁信息 - 实战:亲手构造一个死锁场景并分析回滚结果
- 避免死锁的编码与设计建议
读完本文,你将不仅能解释死锁的产生原理,还能在项目中主动规避和诊断死锁问题。
1. 死锁的四个必要条件
死锁并非数据库独有的概念,它是并发系统中普遍存在的问题。根据计算机科学的经典定义,死锁必须同时满足四个条件:
- 互斥(Mutual Exclusion):资源一次只能被一个进程(事务)持有。数据库中的 X 锁天然具有互斥性。
- 持有并等待(Hold and Wait):一个事务已经持有至少一个资源,又在等待其他事务释放的资源。
- 不可剥夺(No Preemption):已分配给事务的资源不能被强制夺走,只能由持有者自己释放。
- 循环等待(Circular Wait):存在事务的循环链:T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,…,Tn 等待 T1 持有的资源。
破坏任意一个条件即可预防死锁:
- 破坏“互斥”:对于数据库锁资源不可能,因为数据一致性需要互斥。
- 破坏“持有并等待”:一次性申请所有需要的锁(如
LOCK TABLES,但并发度极差)。 - 破坏“不可剥夺”:超时回滚事务,强制释放锁。
- 破坏“循环等待”:按固定顺序访问资源(如总是先锁表 A 再锁表 B)。
InnoDB 实际采用的方法是检测死锁并回滚(而非预防),同时提供超时机制作为补充。
2. InnoDB 的死锁检测机制
2.1 等待图(Wait-for Graph)
InnoDB 内部维护了一个等待图数据结构:
- 节点:每个事务。
- 有向边:T1 → T2 表示“T1 正在等待 T2 释放的锁”。
每当一个事务因为锁而阻塞时,InnoDB 会将这条边加入等待图,然后运行**深度优先搜索(DFS)**检查是否出现了环。如果发现了环,就说明发生了死锁。
2.2 死锁解决策略
检测到死锁后,InnoDB 必须让至少一个事务回滚,以打破循环。选择牺牲品的原则是:回滚代价最小的事务——即修改行数最少的事务(由undo log的大小估算)。被选中的事务会收到错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction此时,应用层应捕获这个错误,并在合适的时机重试整个事务。
2.3 死锁检测的开关与开销
死锁检测由参数innodb_deadlock_detect控制(默认ON)。当并发线程非常多(数百上千)时,等待图会很大,每次检测的 DFS 开销会显著消耗 CPU。在极端高并发场景(如秒杀),可以考虑临时关闭死锁检测,依赖innodb_lock_wait_timeout来处理锁等待超时。
3. 锁等待超时参数
如果死锁检测被关闭,或者等待的锁并不构成死锁(而是长时间等待),InnoDB 通过超时机制避免事务无限等待。
关键参数:
innodb_lock_wait_timeout:一个事务等待行锁的最长时间(秒),默认50秒。超时后事务回滚,报错:ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction- 设置过短:可能导致正常排队等待的事务被回滚(尤其在长事务场景)。
- 设置过长:死锁时(若关闭检测)需要等很久才会被处理。
生产环境中,建议根据业务特点调整该值(如 5~20 秒),并对超时错误进行重试逻辑。
4. 如何从日志中分析死锁
当死锁发生时,InnoDB 会将死锁的详细信息记录到SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK部分,以及 MySQL 错误日志中。
关键信息解读:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-06-07 10:30:00 0x7f8b2c001700 *** (1) TRANSACTION: TRANSACTION 4212345, ACTIVE 10 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 8, OS thread handle 140234567890, query id 1234 localhost root updating UPDATE books SET stock = stock - 1 WHERE id = 1 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `library_db`.`books` trx id 4212345 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 6; ... *** (2) TRANSACTION: TRANSACTION 4212346, ACTIVE 8 sec starting index read mysql tables in use 1, locked 1 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 9, OS thread handle 140234567891, query id 1235 localhost root updating UPDATE books SET stock = stock - 1 WHERE id = 2 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `library_db`.`books` trx id 4212346 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 6; ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 5 n bits 72 index PRIMARY of table `library_db`.`books` trx id 4212346 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 6; ... *** WE ROLL BACK TRANSACTION (2)解读要点:
(1) TRANSACTION和(2) TRANSACTION分别列出了两个死锁参与者的事务 ID、执行的 SQL、持有和等待的锁。HOLDS THE LOCK(S):当前持有的锁。WAITING FOR THIS LOCK TO BE GRANTED:正在等待的锁。- 最后一句
WE ROLL BACK TRANSACTION (2)说明 InnoDB 选择了事务 2 作为牺牲品。 lock_mode X locks rec but not gap表示记录锁(不是间隙锁)。
通过分析这两个事务持有和等待的锁,我们可以反向推导出业务逻辑哪里出现了循环等待。
5. 实战:构造死锁并分析
我们来亲手制造一个典型的死锁场景:两个事务以不同顺序更新相同的两行。
5.1 准备
USElibrary_db;CREATETABLEdeadlock_test(idINTPRIMARYKEY,valINT)ENGINE=InnoDB;INSERTINTOdeadlock_testVALUES(1,100),(2,200);5.2 制造死锁
时间线(同时操作两个会话):
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | START TRANSACTION; | START TRANSACTION; |
| 2 | UPDATE deadlock_test SET val=val+1 WHERE id=1;— 获得 id=1 的 X 锁 | |
| 3 | UPDATE deadlock_test SET val=val+1 WHERE id=2;— 获得 id=2 的 X 锁 | |
| 4 | UPDATE deadlock_test SET val=val+1 WHERE id=2;—等待B 释放 id=2 的锁 | |
| 5 | UPDATE deadlock_test SET val=val+1 WHERE id=1;—等待A 释放 id=1 的锁 | |
| 6 | 死锁被检测到,其中一方回滚 | 另一方成功执行 |
具体操作:
会话 A:
STARTTRANSACTION;UPDATEdeadlock_testSETval=val+1WHEREid=1;-- 第1步会话 B:
STARTTRANSACTION;UPDATEdeadlock_testSETval=val+1WHEREid=2;-- 第2步会话 A:
UPDATEdeadlock_testSETval=val+1WHEREid=2;-- 第3步,等待会话 B:
UPDATEdeadlock_testSETval=val+1WHEREid=1;-- 第4步,死锁触发在几秒内(通常在步骤 4 执行后),其中一方会报错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction没有被回滚的一方可以正常COMMIT。
5.3 分析死锁日志
立即执行:
SHOWENGINEINNODBSTATUS\G找到LATEST DETECTED DEADLOCK部分,你会看到类似前面示例的信息,明确指出了两个事务各自持有和等待的锁,以及最终的牺牲品。
5.4 清理
DROPTABLEdeadlock_test;6. 避免死锁的编码与设计建议
知道了死锁的成因,我们可以在设计和编码层面主动规避。
6.1 固定访问顺序
如果所有事务都按照相同的顺序访问资源(如总是先操作表 A 再操作表 B,总是先锁id=1再锁id=2),就不会形成循环等待。
实际做法:
- 对于关联表的更新,统一先更新主表,再更新子表。
- 对于多条记录的更新,先按主键排序,再依次更新。
6.2 缩短事务
长事务持有锁的时间更长,与其他事务冲突的概率越大。应该:
- 将非数据库操作(如远程 API 调用、文件读写)移出事务。
- 先准备好数据,最后开启事务执行写入。
- 避免在事务中等待用户交互。
6.3 减小锁范围
- 使用精确的 WHERE 条件,确保走索引,避免全表扫描导致的锁膨胀。
- 对于只读查询,使用快照读(普通
SELECT)而非SELECT ... FOR SHARE。 - 在 RC 隔离级别下,间隙锁被禁用,可以降低死锁概率(但需注意幻读风险)。
6.4 使用低隔离级别
RC 隔离级别不使用间隙锁,锁范围更小,死锁概率低于 RR。对于大多数互联网业务,RC 是足够且更高效的选择。前提是应用程序能处理不可重复读,且复制格式使用 ROW 模式。
6.5 添加合适的索引
如果没有索引,一个UPDATE可能会锁住全表所有行(实际是扫描过程中对每行加锁再释放不符合条件的)。良好的索引让 InnoDB 能精确锁定目标行,大幅减少锁冲突。
6.6 重试机制
无论怎样预防,死锁仍可能发生。应用层必须实现死锁重试逻辑:
- 捕获死锁异常(
SQLSTATE 40001或 error code 1213) - 等待一小段随机时间(退避)
- 重新开始事务
大多数数据库框架(Spring、MyBatis 等)都提供了声明式或编程式的重试支持。
7. 小结
死锁是并发控制的阴暗面,但有规律可循:
- 四个必要条件:互斥、持有并等待、不可剥夺、循环等待。缺一则不成立。
- InnoDB 检测:维护等待图,DFS 发现环 → 回滚代价最小的事务。
- 超时参数:
innodb_lock_wait_timeout是保底机制,防止无限等待。 - 日志分析:
SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK包含完整死锁现场,通过“持有 + 等待”的对账可以定位问题 SQL。 - 亲手构造:我们以不同顺序更新两行,成功触发死锁,并解读了日志。
- 规避策略:固定访问顺序、缩短事务、精确索引、降低隔离级别、应用重试。
下一篇我们将进入MVCC 多版本并发控制,解开 InnoDB 最优雅的设计之一——无锁读背后的秘密,理解 ReadView 和版本链如何让读写互不阻塞。
思考题:
- 如果关闭
innodb_deadlock_detect,死锁会发生什么?如何被处理? - 在你的系统中查看
SHOW ENGINE INNODB STATUS,是否有历史死锁记录?尝试解读。 - 设计一个简单的转账流程(A → B,B → A 并发),分析是否可能死锁,并给出避免方案。
参考资料
- MySQL 8.0 Reference Manual - Deadlocks in InnoDB
- MySQL 8.0 Reference Manual - SHOW ENGINE INNODB STATUS
- MySQL 8.0 Reference Manual - InnoDB Startup Options and System Variables