间隙锁导致死锁的典型场景
一句话速记
间隙锁死锁的本质:两个事务各自加了覆盖同一区间的 Gap Lock,然后都想在该区间插入数据,形成循环等待。最常见场景:并发 INSERT 相同范围的记录时,INSERT 意向锁与 Gap Lock 冲突——事务 A 和 B 都持有某范围的间隙锁,然后都想插入,互相等对方释放,死锁。
关键细节
1)经典死锁场景:并发 INSERT 不存在的记录
-- 表结构:CREATE TABLE t(id INT PRIMARY KEY);
-- 现有数据:id = 1, 5, 10
-- 时间线:
-- T1 事务 A T2 事务 B
BEGIN; BEGIN;
-- A 查询 id=7(不存在),加 Gap Lock (5, 10)
SELECT * FROM t WHERE id=7 FOR UPDATE;
-- 加 Next-Key Lock: Gap Lock (5, 10)
-- B 查询 id=8(不存在),也想加 Gap Lock (5, 10)
SELECT * FROM t WHERE id=8 FOR UPDATE;
-- Gap Lock 兼容!(两个事务的 Gap Lock 互相兼容)
-- B 也成功获得 Gap Lock (5, 10)
-- A 想插入 id=7(INSERT 意向锁)
INSERT INTO t VALUES(7);
-- INSERT 需要加 Insert Intention Lock(特殊的 Gap Lock)
-- 与 B 的 Gap Lock 冲突!→ A 等待 B 释放
-- B 想插入 id=8
INSERT INTO t VALUES(8);
-- 与 A 的 Gap Lock 冲突!→ B 等待 A 释放
-- A 等 B,B 等 A → DEADLOCK!
-- InnoDB 选 victim(通常是 undo log 小的那个),回滚其中一个为什么两个 Gap Lock 兼容?:
Gap Lock 的兼容性:
Gap Lock 之间互相兼容(都是"不让别人在这个间隙插入"的标记)
但 Insert Intention Lock(插入意向锁)与 Gap Lock 不兼容
事务 A 的 Gap Lock (5,10) + 事务 B 的 Gap Lock (5,10) → 兼容,两者都可获得
事务 A 的 Insert Intention Lock (id=7) + 事务 B 的 Gap Lock (5,10) → 冲突
事务 B 的 Insert Intention Lock (id=8) + 事务 A 的 Gap Lock (5,10) → 冲突
2)另一个经典场景:非唯一索引的并发 DELETE + INSERT
-- 表:user_status(user_id, status, idx on status)
-- 数据:status=1(id=1), status=3(id=2), status=5(id=3)
-- 事务 A:删除 status=2(不存在,锁间隙)
DELETE FROM user_status WHERE status=2;
-- 加 Gap Lock (1, 3)(status 索引上)
-- 事务 B:删除 status=4(不存在,锁间隙)
DELETE FROM user_status WHERE status=4;
-- 加 Gap Lock (3, 5)(status 索引上)
-- 事务 A:INSERT status=4
INSERT INTO user_status VALUES(4, 4);
-- 需要 Insert Intention Lock on (3,5) → 等 B 的 Gap Lock (3,5) → 等待
-- 事务 B:INSERT status=2
INSERT INTO user_status VALUES(5, 2);
-- 需要 Insert Intention Lock on (1,3) → 等 A 的 Gap Lock (1,3) → 等待
-- 死锁!3)批量操作的死锁:不同顺序加锁
-- 常见于业务代码:对多行做 UPDATE,但不同请求的行顺序不同
-- 事务 A:UPDATE id=1, id=3, id=5(按业务顺序)
-- 事务 B:UPDATE id=3, id=1, id=5(另一种顺序)
-- A 锁了 id=1,B 锁了 id=3
-- A 等 id=3(被 B 持有),B 等 id=1(被 A 持有)→ 死锁
-- 解法:统一排序后按固定顺序加锁
UPDATE t WHERE id IN (1,3,5) ORDER BY id; -- 保证加锁顺序一致4)如何定位死锁
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
-- 输出中找 LATEST DETECTED DEADLOCK 部分:
-- 包含:两个事务各自持有的锁、等待的锁、victim 选择
-- 开启死锁日志记录
SET GLOBAL innodb_print_all_deadlocks=ON;
-- → 每次死锁都记录到 error log(生产谨慎,日志量大)
-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
SELECT * FROM information_schema.INNODB_LOCKS;
-- MySQL 8.0+ 用:
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;5)避免死锁的工程实践
原则 1:统一加锁顺序
批量操作多行时,按主键或固定字段排序后处理
→ 避免"A 锁 id=1 等 id=3,B 锁 id=3 等 id=1"
原则 2:缩短事务
事务越短,持锁时间越短,冲突窗口越小
不要在事务内做 HTTP 调用、等待用户输入等耗时操作
原则 3:降低隔离级别到 RC
RC 不加 Gap Lock → 消除间隙锁死锁
代价:可能幻读,需业务层处理(幂等、唯一键兜底)
原则 4:避免大范围 SELECT ... FOR UPDATE
范围越大,加的 Gap Lock 越多,冲突面越大
精确匹配(等值查询 + 唯一索引)→ 退化为 Record Lock
原则 5:应用层重试
死锁是可恢复的(InnoDB 自动选 victim 回滚)
捕获 MySQL Error Code 1213(ER_LOCK_DEADLOCK),做指数退避重试
// Java 示例:死锁重试
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
doBusinessLogic(); // 包含数据库操作
break;
} catch (CannotAcquireLockException e) { // Spring 的死锁异常
if (i == maxRetries - 1) throw e;
Thread.sleep(50 * (i + 1)); // 指数退避
}
}延伸追问
- Q:Insert Intention Lock 是什么,和普通 Gap Lock 有什么区别? → Insert Intention Lock 是一种特殊的 Gap Lock,表示”我要在这个间隙插入记录”。多个事务可以同时持有同一区间的 Insert Intention Lock(如果各自插入不同位置),但 Insert Intention Lock 与 Gap Lock 冲突——有 Gap Lock 的区间不允许其他事务加 Insert Intention Lock。
- Q:唯一索引的等值查询为什么不加 Gap Lock?
→ 唯一约束保证了不会有重复值插入——如果 id=7 不存在,在唯一索引上
WHERE id=7 FOR UPDATE无法防止别人插入 id=7,所以 InnoDB 会对不存在的记录加 Gap Lock(锁 id=7 附近的间隙)。如果 id=7 存在,只加 Record Lock(唯一性已保证,无需锁间隙)。 - Q:降低隔离级别到 RC 后,Binlog 格式有什么要求? → RC 下 Binlog 必须使用 ROW 格式,不能用 STATEMENT 格式。因为 SBR 记录的是 SQL 语句,RC 下不同 slave 重放顺序可能不同,导致主从数据不一致;ROW 格式记录具体行变化,与隔离级别无关,主从一致。
我的记法
- Gap Lock 之间互相兼容,Insert Intention Lock 与 Gap Lock 冲突
- 经典场景:两个事务各查询不存在的记录(各持 Gap Lock),然后各自 INSERT → 死锁
- 降到 RC 消除间隙锁死锁(推荐),或统一加锁顺序
- 应用层必须有死锁重试(Error Code 1213)
- 一句话:「间隙锁死锁 = 两人各占半条路,然后都想去对方那里」
状态
- 已背速记
- 能画出并发 INSERT 死锁时间线
- 能说避免死锁的 5 个原则