死锁的产生、检测与避免
在上一篇中我们见证了 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 GraphInnoDB 内部维护了一个等待图数据结构节点每个事务。有向边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)ENGINEInnoDB;INSERTINTOdeadlock_testVALUES(1,100),(2,200);5.2 制造死锁时间线同时操作两个会话步骤会话 A会话 B1START TRANSACTION;START TRANSACTION;2UPDATE deadlock_test SET valval1 WHERE id1;— 获得 id1 的 X 锁3UPDATE deadlock_test SET valval1 WHERE id2;— 获得 id2 的 X 锁4UPDATE deadlock_test SET valval1 WHERE id2;—等待B 释放 id2 的锁5UPDATE deadlock_test SET valval1 WHERE id1;—等待A 释放 id1 的锁6死锁被检测到其中一方回滚另一方成功执行具体操作会话 ASTARTTRANSACTION;UPDATEdeadlock_testSETvalval1WHEREid1;-- 第1步会话 BSTARTTRANSACTION;UPDATEdeadlock_testSETvalval1WHEREid2;-- 第2步会话 AUPDATEdeadlock_testSETvalval1WHEREid2;-- 第3步等待会话 BUPDATEdeadlock_testSETvalval1WHEREid1;-- 第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总是先锁id1再锁id2就不会形成循环等待。实际做法对于关联表的更新统一先更新主表再更新子表。对于多条记录的更新先按主键排序再依次更新。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 → BB → A 并发分析是否可能死锁并给出避免方案。参考资料MySQL 8.0 Reference Manual - Deadlocks in InnoDBMySQL 8.0 Reference Manual - SHOW ENGINE INNODB STATUSMySQL 8.0 Reference Manual - InnoDB Startup Options and System Variables