MySQL锁机制中的死锁是如何产生的
以下内容将从死锁的基本概念入手,结合 MySQL InnoDB 行级锁的工作原理,通过具体的代码示例、ASCII 图解和详细说明,帮助你系统地理解 MySQL 锁机制中的死锁是如何产生的,以及如何检测和解决死锁。
1. 什么是死锁
- 死锁(Deadlock):指在并发环境下,多个事务各自持有部分资源并相互等待对方释放资源,从而形成无限等待的状态,导致无法继续执行。
- 在 MySQL InnoDB 引擎中,资源通常是某些行级锁、间隙锁或表锁。当事务 A 持有资源 R1,等待资源 R2;同时事务 B 持有资源 R2,等待资源 R1,就形成了最典型的死锁。
1.1 死锁与普通锁等待的区别
- 普通锁等待:事务 A 请求锁时,如果资源正在被事务 B 持有,A 会挂起等待,直到 B 提交/回滚并释放锁。等待过程可被唤醒继续执行。
- 死锁:如果等待关系形成环(环路),例如 A 等待 B,B 又等待 A,两者都永远得不到所需资源,系统就无法继续。InnoDB 会检测到这种环路后,选择其中一个事务回滚,从而解除死锁。
2. InnoDB 中的锁类型概览
在分析死锁形成前,先简单回顾 InnoDB 常见锁类型:
记录锁(Record Lock)
- 作用于索引上的单条记录,用来防止并发修改同一行。
间隙锁(Gap Lock)
- 锁定索引值之间的空隙,阻止其他事务向间隙中插入新行,用于防止“幻读”。
临键锁(Next-key Lock)
- 记录锁 + 间隙锁,既锁住记录,也锁住它左侧的间隙,用于 REPEATABLE READ 隔离下防止幻读。
意向锁(Intention Lock)
- 用于表级上标记“此事务意向在表的某个行上加共享锁(IS)或排他锁(IX)”,便于上层快速检测冲突。
死锁往往由多个事务对同一个或多个行 (或间隙) 以不一致顺序地加锁所引起。下面通过示例演示最常见的两种死锁场景。
3. 示例一:两条记录互相更新导致死锁
3.1 场景描述
假设有一张 InnoDB 表 accounts
,用于模拟两个账户之间转账场景。表结构与初始数据如下:
CREATE DATABASE IF NOT EXISTS test_deadlock;
USE test_deadlock;
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance INT
) ENGINE=InnoDB;
INSERT INTO accounts (id, balance) VALUES
(1, 1000),
(2, 1000);
这时,假设有两个并发事务:
- 事务 A 想把 账户 1 的 100 元转到账户 2;
- 事务 B 想把 账户 2 的 200 元转到账户 1;
如果两者在不同会话中执行操作顺序不当,就可能产生死锁。
3.2 具体代码演示
以下演示在两个不同终端或会话中分别执行事务 A 和事务 B。
会话 A(终端 1)
-- 会话 A
USE test_deadlock;
START TRANSACTION;
-- Step A1: 锁定 accounts id=1 行
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- (模拟业务处理延迟)
-- DO SLEEP or 等待会话 B 先执行
-- Step A2: 尝试锁定 accounts id=2 行
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
会话 B(终端 2)
-- 会话 B
USE test_deadlock;
START TRANSACTION;
-- Step B1: 锁定 accounts id=2 行
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
-- (模拟业务处理延迟)
-- DO SLEEP or 等待会话 A 已经执行第 A1 步
-- Step B2: 尝试锁定 accounts id=1 行
UPDATE accounts SET balance = balance - 200 WHERE id = 2;
UPDATE accounts SET balance = balance + 200 WHERE id = 1;
COMMIT;
并发执行顺序
- 会话 A 执行
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
,锁住id=1
的记录。 - 会话 B 执行
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
,锁住id=2
的记录。 - 会话 A 继续到
UPDATE ... WHERE id = 2;
,此时需要锁住id=2
,但已被会话 B 锁住,A 被阻塞,等待 B 释放锁。 - 会话 B 继续到
UPDATE ... WHERE id = 1;
,此时需要锁住id=1
,但已被会话 A 锁住,B 被阻塞,等待 A 释放锁。
此时 A 等待 B,B 等待 A,形成等待环,InnoDB 将检测到死锁。
3.3 ASCII 图解(记录锁交叉)
+-------------------------+ +-------------------------+
| 会话 A | | 会话 B |
|-------------------------| |-------------------------|
| START TRANSACTION; | | START TRANSACTION; |
| FOR UPDATE id=1 --------|------------> |
| (锁住 Record(1) ) | | FOR UPDATE id=2 --------|------------>
| | | (锁住 Record(2) ) |
| 更新 id=1 | | |
| 尝试锁 id=2 <-----------|------------| UPDATE id=2 |
| | | 尝试锁 id=1 <-----------|
+-------------------------+ +-------------------------+
↑等待 B释放 id=2 ↑等待 A释放 id=1
│ │
└────────────── 死锁环路 ────────────────┘
Record(1)
和Record(2)
分别表示两条记录的行锁。- 互相等待对方持有的记录锁,从而形成死锁。
3.4 InnoDB 死锁检测与回滚
当 InnoDB 检测到这样的等待环时,会从以下两方面做处理:
- 选择牺牲者:InnoDB 会根据“回滚成本”(例如修改行数、加锁深度等),选择其中一个事务作为“死锁受害者”进行回滚(默认一般回滚后执行 SQL 的事务)。例如,可能是会话 B 被回滚。
通知客户端:被回滚的事务会返回类似如下错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
应用收到后需要代码层面捕获此错误,并重试事务或采取补偿措施。
4. 示例二:基于范围查询的间隙锁死锁
除了记录锁互相等待,间隙锁与临键锁也可能导致死锁,尤其在多个事务对同一范围的插入/更新产生冲突时。下面演示一个“基于范围插入”的死锁场景。
4.1 场景描述
继续使用表 accounts
,在表中只关注 id
列作索引。现在有两个事务:
- 事务 A 想插入
id=25
; - 事务 B 想插入
id=15
;
但它们都使用SELECT ... FOR UPDATE
预先锁定了一段范围,导致互相阻塞。
4.2 具体代码演示
会话 A(终端 1)
-- 会话 A
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- A1: 对 id BETWEEN 20 AND 30 范围加临键锁
SELECT * FROM accounts WHERE id BETWEEN 20 AND 30 FOR UPDATE;
-- 锁定 id=20,30 记录及 (20,30) 间隙
-- A2: 尝试插入 id=25
INSERT INTO accounts (id,balance) VALUES (25, 500);
-- 会被阻塞,因 (20,30) 间隙已被锁定
会话 B(终端 2)
-- 会话 B
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- B1: 对 id BETWEEN 10 AND 20 范围加临键锁
SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁定 id=10,20 记录及 (10,20) 间隙
-- B2: 尝试插入 id=15
INSERT INTO accounts (id,balance) VALUES (15, 700);
-- 会被阻塞,因 (10,20) 间隙已被锁定
并发执行顺序
会话 A 执行
SELECT ... WHERE id BETWEEN 20 AND 30 FOR UPDATE;
- 锁定
(20,30)
区间的间隙,以及边界记录id=20
、id=30
。
- 锁定
会话 B 执行
SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE;
- 锁定
(10,20)
区间的间隙,以及边界记录id=10
(假设存在)和id=20
。此时id=20
已被会话 A 锁定,会话 B 等待会话 A 释放id=20
的记录锁。
- 锁定
会话 A 继续执行
INSERT INTO ... id=25
,因(20,30)
区间被会话 A 自己锁,但这里只是自己事务,不冲突;实际上插入也会请求(20,30)
区间的插入许可,因它已经把(20,30)
锁住,允许自己插入,所以 A 的 INSERT 可以执行成功。- 插入完成后,A 执行
COMMIT
,释放对(20,30)
的锁。
- 插入完成后,A 执行
- 会话 B 仍在等待
id=20
的记录锁,一旦 A 提交,B 获得id=20
锁,然后尝试INSERT id=15
,此时(10,20)
已被 B 自己锁,允许插入,继续执行成功。
注意:严格地说,此示例并未形成死锁环,因为会话 A 和会话 B 争用的资源并不完全互为环路。要演示真正的间隙锁死锁,需要双方同时持有对方欲插入区间的部分锁。下面再补充一个更典型的例子。
4.3 典型间隙锁死锁示例
假设初始表中有 id=10,30
,我们准备两个事务,分别锁两个相邻区间再尝试插入对方区间的值,形成死锁。
初始数据
DELETE FROM accounts;
INSERT INTO accounts (id,balance) VALUES (10,1000),(30,1000);
此时,索引节点为:
10 (10,30) 30
会话 A
-- 会话 A
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- A1: 锁定范围 (10,30),即 SELECT id BETWEEN 10 AND 30
SELECT * FROM accounts WHERE id BETWEEN 10 AND 30 FOR UPDATE;
-- 会对 id=10,30 加记录锁,对 (10,30) 加间隙锁
-- 锁定如下:
-- [10:X] [(10,30):LOCKED] [30:X]
会话 B
-- 会话 B
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- B1: 锁定范围 (10,30),但使用反向范围,比如 id > 20 AND id < 40
SELECT * FROM accounts WHERE id > 20 AND id < 40 FOR UPDATE;
-- 会对 id=30 加记录锁,对 (20,30) 与 (30,40) 加间隙锁
-- 其中 (20,30) 属于 (10,30) 的子区间,会与 A 的间隙锁冲突吗?
-- 先看效果:B 尝试锁定时,发现 id=30 已被 A 锁住,B 等待
到此,B 已经无法获得对 id=30
的记录锁,需要等待 A 提交或回滚。
接着,A 尝试插入会话 B 想插入的记录:
-- 会话 A 继续
INSERT INTO accounts (id,balance) VALUES (20,500);
-- 由于 (10,30) 区间被 A 自己锁定,允许插入 20
-- 执行成功后,A 提交
COMMIT;
此时,B 获得 id=30
记录锁,再进入 INSERT
步骤:
-- 会话 B 继续
INSERT INTO accounts (id,balance) VALUES (25,400);
-- B 已锁定 (20,30) 区间,允许插入 25
COMMIT;
依然没有死锁。要让死锁真正形成,需要两个事务同时锁定相互重叠、但方向相反的区间,并都在等待对方锁释放。以下是一个可以复现间隙锁死锁的更精确示例:
会话 A(典型死锁版)
-- 会话 A
USE test_deadlock;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- A1: 锁定 id BETWEEN 10 AND 20(虽然 id=20 不存在,仍会对间隙 (10,20) 加锁)
SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁定 (10,20) 区间
会话 B
-- 会话 B
USE test_deadlock;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- B1: 锁定 id BETWEEN 20 AND 30(id=20 不存在,id=30 存在,先锁录 id=30,再锁 (20,30))
SELECT * FROM accounts WHERE id BETWEEN 20 AND 30 FOR UPDATE;
-- 锁定 (20,30) 区间以及 id=30
此时锁状态如下(方括号代表锁定):
... [10] [(10,20):A_LOCK] (20) [(20,30):B_LOCK] [30] ...
- 会话 A 拥有
(10,20)
间隙锁;会话 B 拥有(20,30)
间隙锁和id=30
记录锁。
会话 A 继续
-- 会话 A
-- 尝试插入 id=25,属于 (20,30) 区间;但 B 已经锁定 (20,30),A 阻塞
INSERT INTO accounts (id,balance) VALUES (25, 500);
A 等待 B 释放 (20,30)
间隙锁。
会话 B 继续
-- 会话 B
-- 尝试插入 id=15,属于 (10,20) 区间;但 A 已经锁定 (10,20),B 阻塞
INSERT INTO accounts (id,balance) VALUES (15, 700);
B 等待 A 释放 (10,20)
间隙锁。
此时 A 等待 B,B 等待 A,就形成了真正的环路死锁:
A 拥有 (10,20) B 拥有 (20,30)
↑ | ↑ |
| ↓ | ↓
等待 (20,30) 等待 (10,20)
InnoDB 检测到这个环路后,会回滚成本较低的事务(假设回滚 A),并抛出死锁错误给会话 A,B 得到锁后自动继续执行。
5. MySQL 中检测与解决死锁
5.1 查看最近一次死锁信息
MySQL 的 InnoDB 会将死锁诊断信息记录在错误日志以及 SHOW ENGINE INNODB STATUS\G
输出的 “LATEST DETECTED DEADLOCK” 段中。
-- 执行后查看死锁信息
SHOW ENGINE INNODB STATUS\G
其中会包含类似如下的内容:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-10-10 12:00:00 0x7f8d9c0a4840
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 101, OS thread handle 140392312033024, query id 4567 localhost user update
INSERT INTO accounts (id,balance) VALUES (25,500)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12345 lock_mode X locks rec but not gap
*** (1) WALKS INTO LOCKS
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 00000019; asc ;;
1: len 4; hex 00000001; asc ;;
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 1 sec selecting
mysql tables in use 1, locked 1
10 lock struct(s), heap size 1136, 5 row lock(s)
MySQL thread id 102, OS thread handle 140392312045136, query id 4568 localhost user update
INSERT INTO accounts (id,balance) VALUES (15,700)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 1 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 0000000a; asc ;;
1: len 4; hex 00000001; asc ;;
*** (2) WALKS INTO LOCKS
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 00000019; asc ;;
1: len 4; hex 00000001; asc ;;
*** WE ROLL BACK TRANSACTION (1)
- 上述输出说明事务
12345
(会话 A)与12346
(会话 B)之间存在死锁,InnoDB 选择回滚事务(1)
。 - 其中两者分别持有的锁与等待的锁都被列出,直观显示了死锁原因。
5.2 应用层面捕获死锁并重试
在应用代码中,遇到死锁错误时(错误码 1213
, SQLState 40001
),通常需捕获异常并重试该事务。例如,伪代码流程:
MAX_RETRY = 3
for i in 1..MAX_RETRY:
START TRANSACTION
try:
执行业务逻辑更新/插入...
COMMIT
break -- 成功退出循环
except DeadlockError: -- 捕获 “1213: Deadlock” 错误
ROLLBACK
if i == MAX_RETRY:
raise -- 超过重试次数,抛出错误
else:
# 等待随机短延迟后重试,防止活锁
sleep(random small milliseconds)
except OtherError:
ROLLBACK
raise -- 其他错误直接抛出
- 重试时可加随机延迟(“退避”机制),降低并发冲突概率。
- 在设计高并发事务逻辑时,应尽量简化事务提交前所持锁的数量与时长,减少死锁概率。
6. 实战演练:多种死锁场景汇总
除了上面两个常见场景,还有以下几种死锁容易出现的场景,建议在开发时多加注意。
6.1 场景一:更新同一张表的两条不同行,顺序不同导致死锁
-- 初始数据
DELETE FROM accounts;
INSERT INTO accounts (id, balance) VALUES (100,1000),(200,2000);
-- 会话 A
START TRANSACTION;
SELECT * FROM accounts WHERE id = 100 FOR UPDATE;
-- (等待 B1 不释放时继续)
UPDATE accounts SET balance = balance - 100 WHERE id = 200;
-- 会话 B
START TRANSACTION;
SELECT * FROM accounts WHERE id = 200 FOR UPDATE;
-- (等待 A1 不释放时继续)
UPDATE accounts SET balance = balance - 200 WHERE id = 100;
-- 此时相互等待,形成死锁
要点:两条事务对同一两条记录加锁顺序不同,导致环路等待。
6.2 场景二:插入唯一索引键值导致死锁
假设表 users
有唯一索引 uname
,存在 ('alice')
与 ('bob')
:
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
uname VARCHAR(20) UNIQUE,
active TINYINT(1)
) ENGINE=InnoDB;
INSERT INTO users (uname,active) VALUES ('alice',1),('bob',1);
会话 A 执行:
START TRANSACTION; SELECT * FROM users WHERE uname BETWEEN 'a' AND 'c' FOR UPDATE; -- 锁定 (alice,bob) 的临键锁与间隙锁
会话 B 执行:
START TRANSACTION; SELECT * FROM users WHERE uname BETWEEN 'b' AND 'd' FOR UPDATE; -- 锁定 (bob) 的临键锁与相应间隙
此时两者都锁住了对方需要插入某个唯一值的间隙,如果接着插入新的 uname
值就可能产生死锁。具体细节类似前面间隙锁演示。
6.3 场景三:插入与更新同时对间隙锁产生冲突
假设表中只有 id=100,200
两条记录,应用中一个事务 A 要更新 id=100
并插入 id=150
,另一个事务 B 要更新 id=200
并插入 id=150
,在 REPEATABLE READ 下也会死锁。
-- 会话 A
START TRANSACTION;
SELECT * FROM accounts WHERE id = 100 FOR UPDATE; -- 锁记录100并加 (100,∞) 间隙锁
INSERT INTO accounts (id,balance) VALUES (150,500); -- 需要锁 (100,200) 区间
-- 会话 B
START TRANSACTION;
SELECT * FROM accounts WHERE id = 200 FOR UPDATE; -- 锁记录200并加 (100,200),(200,∞) 间隙锁
INSERT INTO accounts (id,balance) VALUES (150,400); -- 需要锁 (100,200) 区间,等待A
- 事务 A 已锁住
(100,∞)
,要插入 150 时需要(100,150)
及(150,200)
区间; - 事务 B 已锁住
(100,200)
的一部分间隙; - 双方等待对方释放,产生死锁。
7. 预防与解决死锁的实用技巧
统一访问顺序
- 尽量让并发事务对同一张表的多行加锁时,按照相同顺序(如按照主键升序)访问,避免并发事务交叉加锁。
缩短事务时长
- 只在必要的业务逻辑中才开启事务,尽量减少事务内的查询或计算时间,快速提交并释放锁。
使用较低隔离级别
- 如果业务能容忍“幻读”,可将隔离级别设为
READ COMMITTED
,此时 InnoDB 不会对范围查询加间隙锁,减少死锁可能性。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
- 如果业务能容忍“幻读”,可将隔离级别设为
合理设计索引与 WHERE 条件
- 避免无索引的全表扫描式更新或范围查询,因为此时 InnoDB 会对整张表或大范围加锁,增加死锁风险。
- 对常用查询字段加索引,保证加锁粒度尽量小。
捕获死锁并自动重试
- 在应用层捕获死锁错误(MySQL 错误码
1213
),并简单重试。大多数死锁都是“概率性”问题,通过重试便能成功。 - 可为核心业务逻辑设置重试上限,避免持续重试导致响应延迟。
- 在应用层捕获死锁错误(MySQL 错误码
监控死锁频率
- 可查询系统状态变量
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
查看死锁总次数。 - 将该指标纳入监控告警,一旦死锁频繁发生,说明并发冲突严重,需要优化业务或索引设计。
- 可查询系统状态变量
8. 小结
- 死锁产生的本质:并发事务因不一致的加锁顺序或重叠的间隙加锁,形成环路等待,InnoDB 检测到后会回滚其中一个事务。
- 典型诱因:两个或多个事务交叉对相同或相邻记录加锁(记录锁、间隙锁、临键锁),并尝试获取已被对方持有的锁,造成等待环。
解决思路:
- 统一加锁顺序:保证多个事务以相同顺序访问相同表的行。
- 减少并发冲突范围:尽量使用精确的索引条件,减少范围锁的使用。
- 缩短事务时长:让加锁时间尽量短。
- 使用较低隔离级别:在可接受的业务场景下采用
READ COMMITTED
,避开间隙锁。 - 捕获并重试:应用层捕获死锁错误并自动重试,减轻业务感知影响。
通过本文的代码示例和 ASCII 图解,你应能直观地看到 MySQL InnoDB 中不同锁类型是如何互相等待、形成死锁环的,也清楚地了解如何检测和优化以降低死锁概率。
评论已关闭