死锁的产生、检测与避免

在上一篇中,我们见证了 Next-Key Lock 如何阻止幻读。但锁是一把双刃剑------它保护数据一致性的同时,也带来了新的风险:死锁。当两个或多个事务互相持有对方需要的锁资源,形成循环等待时,所有参与者都无法继续执行,就像堵死在十字路口的车流。

本文将深入分析死锁的方方面面:

  • 死锁的四个必要条件(及破坏方法)
  • InnoDB 的死锁检测机制(等待图)
  • 死锁超时参数的作用与局限
  • 如何从 SHOW ENGINE INNODB STATUS 日志中解读死锁信息
  • 实战:亲手构造一个死锁场景并分析回滚结果
  • 避免死锁的编码与设计建议

读完本文,你将不仅能解释死锁的产生原理,还能在项目中主动规避和诊断死锁问题。


1. 死锁的四个必要条件

死锁并非数据库独有的概念,它是并发系统中普遍存在的问题。根据计算机科学的经典定义,死锁必须同时满足四个条件:

  1. 互斥(Mutual Exclusion):资源一次只能被一个进程(事务)持有。数据库中的 X 锁天然具有互斥性。
  2. 持有并等待(Hold and Wait):一个事务已经持有至少一个资源,又在等待其他事务释放的资源。
  3. 不可剥夺(No Preemption):已分配给事务的资源不能被强制夺走,只能由持有者自己释放。
  4. 循环等待(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 STATUSLATEST 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 准备

sql 复制代码
USE library_db;

CREATE TABLE deadlock_test (
    id INT PRIMARY KEY,
    val INT
) ENGINE=InnoDB;

INSERT INTO deadlock_test VALUES (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

sql 复制代码
START TRANSACTION;
UPDATE deadlock_test SET val = val + 1 WHERE id = 1;   -- 第1步

会话 B

sql 复制代码
START TRANSACTION;
UPDATE deadlock_test SET val = val + 1 WHERE id = 2;   -- 第2步

会话 A

sql 复制代码
UPDATE deadlock_test SET val = val + 1 WHERE id = 2;   -- 第3步,等待

会话 B

sql 复制代码
UPDATE deadlock_test SET val = val + 1 WHERE id = 1;   -- 第4步,死锁触发

在几秒内(通常在步骤 4 执行后),其中一方会报错:

复制代码
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

没有被回滚的一方可以正常 COMMIT

5.3 分析死锁日志

立即执行:

sql 复制代码
SHOW ENGINE INNODB STATUS\G

找到 LATEST DETECTED DEADLOCK 部分,你会看到类似前面示例的信息,明确指出了两个事务各自持有和等待的锁,以及最终的牺牲品。

5.4 清理

sql 复制代码
DROP TABLE deadlock_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 STATUSLATEST DETECTED DEADLOCK 包含完整死锁现场,通过"持有 + 等待"的对账可以定位问题 SQL。
  • 亲手构造:我们以不同顺序更新两行,成功触发死锁,并解读了日志。
  • 规避策略:固定访问顺序、缩短事务、精确索引、降低隔离级别、应用重试。

下一篇我们将进入 MVCC 多版本并发控制,解开 InnoDB 最优雅的设计之一------无锁读背后的秘密,理解 ReadView 和版本链如何让读写互不阻塞。

思考题

  1. 如果关闭 innodb_deadlock_detect,死锁会发生什么?如何被处理?
  2. 在你的系统中查看 SHOW ENGINE INNODB STATUS,是否有历史死锁记录?尝试解读。
  3. 设计一个简单的转账流程(A → B,B → A 并发),分析是否可能死锁,并给出避免方案。

参考资料


相关推荐
C137的本贾尼1 小时前
事务入门:确保数据的一致性与持久性
数据库
我爱吃土豆11 小时前
Agent 的记忆机制
开发语言·数据库·人工智能
AOwhisky1 小时前
MySQL 学习笔记(第五期):用户管理与权限控制
linux·运维·数据库·笔记·学习·mysql
梦想的颜色1 小时前
Redis数据类型全解析:从底层原理到生产实战
运维·数据库·redis·缓存·高并发·分布式锁·数据类型
C137的本贾尼1 小时前
InnoDB 的物理世界:表空间、段、区与页
数据库
JdSnE27zv1 小时前
EF Code First学习笔记:数据库创建
数据库·笔记·学习
我是一颗柠檬2 小时前
【Redis】Redis性能优化Day14(2026年)
数据库·redis·性能优化
程序员老油条2 小时前
用 AI 生成复杂 SQL:LangChain4j + 本地模型实践
数据库·人工智能·sql
IT邦德2 小时前
Oracle 26ai RAC 通过gold image RU打补丁
数据库·oracle