1 mysql 死锁场景和避免
1.1 什么是死锁?
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当这种情况发生时,没有一个事务能继续执行下去。
幸运的是,InnoDB 存储引擎有自动的死锁检测机制。当它检测到死锁循环时,会主动选择一个“代价”最小的事务(通常是回滚undo log最少的事务)进行回滚,让其他事务得以继续执行。应用层会收到一个错误,例如 Error: 1213 SQLSTATE: 40001 (ER_LOCK_DEADLOCK) Message: Deadlock found when trying to get lock; try restarting transaction
。
1.2 一、 经典的死锁场景
1.2.1 场景一:交叉更新导致的死锁(最经典)
这是最直观的死锁场景。两个事务试图以相反的顺序更新两行或多行数据。
前提:有一个 accounts
表,其中有 id = 1
和 id = 2
两条记录。
时间点 | 事务 A | 事务 B | 说明 |
---|---|---|---|
T1 | BEGIN; UPDATE accounts SET balance = balance - 10 WHERE id = 1; |
事务A成功锁定 id=1 的行。 |
|
T2 | BEGIN; UPDATE accounts SET balance = balance - 20 WHERE id = 2; |
事务B成功锁定 id=2 的行。 |
|
T3 | UPDATE accounts SET balance = balance + 10 WHERE id = 2; |
事务A尝试锁定 id=2 的行,但该行已被事务B锁定,于是事务A进入等待状态。 |
|
T4 | UPDATE accounts SET balance = balance + 20 WHERE id = 1; |
事务B尝试锁定 id=1 的行,但该行已被事务A锁定,于是事务B也进入等待状态。 |
此时,死锁形成:
-
事务 A 等待事务 B 释放
id=2
的锁。 -
事务 B 等待事务 A 释放
id=1
的锁。
InnoDB 检测到这个循环后,会回滚其中一个事务,另一个事务则继续执行。
1.2.2 场景二:间隙锁 (Gap Lock) 导致的死锁
在 REPEATABLE READ
隔离级别下,间隙锁是导致死锁的常见原因,尤其是在执行插入 (INSERT
) 操作时。
前提:有一个 products
表,id
是主键,已有数据 10, 20, 30
。
时间点 | 事务 A | 事务 B | 说明 |
---|---|---|---|
T1 | BEGIN; SELECT * FROM products WHERE id = 25 FOR UPDATE; |
由于 id=25 不存在,InnoDB 在 (20, 30) 这个间隙上加了间隙锁。 |
|
T2 | BEGIN; SELECT * FROM products WHERE id = 15 FOR UPDATE; |
由于 id=15 不存在,InnoDB 在 (10, 20) 这个间隙上加了间隙锁。 |
|
T3 | INSERT INTO products(id) VALUES (16); |
事务A想插入16。该位置在 (10, 20) 间隙内,此间隙已被事务B锁定。事务A需要等待事务B释放间隙锁。但为了插入,它需要先申请一个插入意向锁,这个申请与事务B的间隙锁不冲突,但它仍需等待。 |
|
T4 | INSERT INTO products(id) VALUES (26); |
事务B想插入26。该位置在 (20, 30) 间隙内,此间隙已被事务A锁定。事务B也需要申请插入意向锁并等待。 |
此时,死锁可能形成(具体取决于内部锁的转换和请求顺序):
-
事务 A 持有
(20, 30)
的间隙锁,等待向(10, 20)
间隙插入(等待事务 B 的间隙锁)。 -
事务 B 持有
(10, 20)
的间隙锁,等待向(20, 30)
间隙插入(等待事务 A 的间隙锁)。
1.2.3 场景三:唯一索引检查导致的死锁
当向带有唯一索引的表并发插入数据时,即使插入的值不同,也可能因为唯一性检查而触发死锁。
前提:users
表有一个唯一索引 uk_email
。
-
事务 A
INSERT INTO users (email) VALUES ('a@test.com');
-
事务 B
INSERT INTO users (email) VALUES ('c@test.com');
-
事务 C
INSERT INTO users (email) VALUES ('b@test.com');
假设这三个事务并发执行。在插入前,InnoDB 需要检查唯一性,这个检查会为即将插入位置所在的索引间隙加上一个共享的读间隙锁 (S-Gap Lock)。
-
如果 ‘a’, ‘b’, ‘c’ 都在同一个索引间隙内(例如,索引中目前没有 a, b, c 开头的邮件),那么三个事务都会成功获得该间隙的共享锁。
-
接下来,它们都试图将自己的共享锁“升级”为排他的写锁 (X-Lock) 以执行真正的插入。
-
由于共享锁互相兼容,但任何一个事务想升级为排他锁时,都必须等待其他事务释放共享锁。这就导致了循环等待,形成死锁。
1.3 二、 如何避免死锁
虽然无法 100% 杜绝死锁,但通过良好的设计和编码规范,可以极大地降低其发生频率。
1.3.1 1. 保持一致的加锁顺序(黄金法则)
这是最重要也是最有效的避免死锁的方法。确保所有事务在需要获取多个锁时,都以相同的、预先定义好的顺序来获取。在场景一中,如果所有事务都规定必须先锁 id
小的记录,再锁 id
大的记录,那么死锁就不会发生。
1.3.2 2. 事务尽可能简短,“小而快”
将长事务拆分成多个短事务。事务持有锁的时间越短,与其他事务发生冲突的概率就越低。尽快提交事务,尽早释放锁资源。
1.3.3 3. 使用合适的索引
-
为查询条件添加索引:如果
WHERE
子句中的列没有索引,InnoDB 可能会执行全表扫描,锁定大量不必要的行,甚至锁住整张表,这会大大增加死锁的风险。 -
索引区分度要高:索引的区分度越高,查询时扫描的范围就越小,锁定的范围也越小,冲突概率自然降低。
1.3.4 4. 降低隔离级别
如果业务场景可以容忍“幻读”,可以将数据库的隔离级别从 REPEATABLE READ
降至 READ COMMITTED
。在此级别下,数据库禁用了间隙锁(仅在少数情况如外键约束检查时使用),从而可以避免绝大多数由间隙锁导致的死锁。
1.3.5 5. 使用更低粒度的锁
尽量使用行级锁,而不是表级锁。幸运的是,InnoDB 默认就是行级锁。但要避免因索引问题或SQL写法问题导致行级锁“退化”为表级锁。
1.3.6 6. 在应用层实现重试机制(最后防线)
既然死锁有时不可避免,那么最好的办法就是在应用代码中加入重试逻辑。当捕获到死锁错误(如 error code 1213
)时,等待一个随机的短暂时间,然后重新执行整个事务。这是一种务实且必要的兜底策略。
// 伪代码
int retries = 3;
while (retries > 0) {
try {
// 执行整个事务的数据库操作...
// ...
// 成功则跳出循环
break;
} catch (DeadlockLoserDataAccessException e) {
// 捕获死锁异常
retries--;
if (retries <= 0) {
// 达到最大重试次数,向上抛出异常
throw e;
}
// 等待随机时间后重试
Thread.sleep(new Random().nextInt(100) + 50);
}
}
1.3.7 7. 使用显式锁定来控制顺序
在复杂的业务中,可以使用 SELECT ... FOR UPDATE
来预先、显式地按照你规定的顺序锁定需要的行,然后再执行 UPDATE
或 DELETE
操作,从而确保加锁顺序的正确性。
1.4 三、 死锁的诊断
当发生死锁时,可以通过 SHOW ENGINE INNODB STATUS;
命令来查看详细信息。在输出结果的 LATEST DETECTED DEADLOCK
部分,会详细列出死锁涉及的事务、它们持有的锁、正在等待的锁以及最后被回滚的事务,这是诊断死锁根源的最有力工具。
2 关于插入意向锁
2.1 核心定义
插入意向锁 (Insert Intention Lock,简称 II-Lock) 是一种特殊的间隙锁 (Gap Lock)。它不是由事务显式获取的,而是在 INSERT
操作执行之前,由事务为即将插入的索引间隙自动设置的一种意图锁。
它的核心思想是:一个事务在准备插入一条记录时,需要先表达一个“我准备要在这个间隙里放东西了”的意图。
你可以把它理解为一个**“入门许可”**。
2.2 存在的目的:提高并发性
插入意向锁存在的唯一目的,就是提高 INSERT
操作的并发能力。
想象一下,如果没有插入意向锁,当一个事务想要在 (10, 20)
这个间隙中插入数据时,它就必须先锁住整个 (10, 20)
的间隙。这样一来,在它完成插入并提交之前,其他任何想在这个间隙(哪怕是插入一个完全不同的值)的事务都必须等待。这会极大地降低系统的插入性能。
而插入意向锁完美地解决了这个问题。
2.3 工作机制与特性
-
它是一种间隙锁:它的锁定目标是索引记录之间的“间隙”,而不是具体的行。
-
由
INSERT
触发:当你执行一条INSERT
语句时,在真正写入数据前,InnoDB会为这条新记录即将插入的位置申请一个插入意向锁。 -
核心特性:意向锁之间不互斥
这是理解它的关键!多个不同的事务可以同时在同一个间隙上持有插入意向锁,它们之间不会互相阻塞。
例如,事务A想插入 id=12,事务B想插入 id=15。如果它们都落在 (10, 20) 这个间隙里,它们都可以同时成功获得在 (10, 20) 上的插入意向锁,谁也不用等谁。
-
与普通间隙锁的冲突
插入意向锁虽然自己内部很“和谐”,但它与“大佬”——普通的间隙锁 (Gap Lock) 和 临键锁 (Next-Key Lock) 是互斥的。
-
如果一个事务已经持有了
(10, 20)
的间隙锁(例如通过SELECT ... WHERE id=18 FOR UPDATE
获得)。 -
那么,其他任何事务想要在这个间隙里插入数据(即想要获取插入意向锁),都必须等待,直到那个间隙锁被释放。
-
2.4 一个生动的比喻:预定会议室
把索引的一个间隙 (10, 20)
想象成一间开放的会议室。
-
常规间隙锁 (Gap Lock):就像张三把这间会议室整个包了下来。在他离开之前,任何人都不能进入,甚至不能在门口排队。
-
插入意向锁 (II-Lock):就像李四想进去开个5分钟的短会,王五也想进去打个电话。
-
在会议室没被包场(没有Gap Lock)的前提下,李四和王五都可以走到会议室门口,表达“我想进去”的意图。他们俩在门口排队互不影响。这就是插入意向锁的互相兼容。
-
但是,如果张三(Gap Lock)已经包下了这间屋子,那么李四和王五连走到门口表达意图的资格都没有,必须在走廊外等着张三出来。这就是插入意向锁与间隙锁的互斥。
-
当李四和王五都站在门口(都持有了插入意向锁),谁先进去(谁先完成插入),就相当于谁先把门从里面锁上了(获得了对新插入行的排他记录锁)。
-
2.5 实例说明
前提:products
表有 id=10
和 id=20
两条记录。
场景一:插入意向锁之间不冲突
事务 A | 事务 B |
---|---|
BEGIN; INSERT INTO products(id) VALUES (12); |
|
(此时,A 获得 (10, 20) 间隙的插入意向锁) | |
BEGIN; INSERT INTO products(id) VALUES (15); |
|
(B 也想在 (10, 20) 间隙插入,它也能成功获得插入意向锁,不会被A阻塞) |
场景二:插入意向锁与间隙锁冲突
事务 A | 事务 B |
---|---|
BEGIN; SELECT * FROM products WHERE id=18 FOR UPDATE; |
|
(由于18不存在,A 获得 (10, 20) 的间隙锁) | |
BEGIN; INSERT INTO products(id) VALUES (15); |
|
(B 尝试在 (10, 20) 间隙插入,需要获取插入意向锁,但该间隙已被A的间隙锁占据,因此 B 被阻塞,进入等待) |
2.6 总结
特性 | 描述 |
---|---|
锁类型 | 间隙锁 (Gap Lock) 的一种特殊形式 |
触发操作 | INSERT |
核心目的 | 提高并发插入的性能 |
兼容性 | 插入意向锁之间互相兼容 |
冲突性 | 与常规的间隙锁 (Gap/Next-Key) 互斥 |
与死锁的关系 | 它本身不直接导致死锁,但它允许多个事务同时“进入”同一个危险区域,从而为后续因其他资源(如行锁)而产生的死锁循环创造了条件。 |