间隙锁导致死锁的典型场景

一句话速记

间隙锁死锁的本质:两个事务各自加了覆盖同一区间的 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 个原则