两个 INSERT 同时死了,凶手是间隙锁

以下分析基于 MySQL 8.0.32。8.0 的 data_locks 表和 5.7 结构不同,见文中标注。 场景:注册接口每秒报 200 次 DeadlockFoundWhenCommitting,两个并发事务插入同一个手机号 路径:错误日志 → SHOW ENGINE INNODB STATUS → LATEST DETECTED DEADLOCK → 间隙锁拆解 → 隔离级别调整

上篇讲了行锁升级陷阱——一条 UPDATE 锁了 5 万行,效果和表锁一样。这次我们从"堵"到"死",看一种更诡异的 MySQL 死锁:两个并发 INSERT 都死了。

团队群里的死锁讨论

-- 注册接口,30ms,每秒调用 500 次
INSERT INTO users (phone, name) VALUES ('13800020001', '王五');
-- 然后应用报了:Deadlock found when trying to get lock; try restarting transaction

每秒 200 次死锁回滚。不是数据量大——是间隙锁把插入意向锁堵了。


现象 — 死锁现场

监控面板 — 死锁率飙升

业务代码先检查手机号是否已注册,再 INSERT——典型的"先查后插"模式。表结构如下:

CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  phone VARCHAR(20) UNIQUE KEY,
  name VARCHAR(50)
);

只有两条预设数据,phone 有一个唯一索引。两个用户同时注册 13800020002,触发了死锁。

死锁现场 — 应用错误日志与 LATEST DETECTED DEADLOCK

事务 1 和事务 2 都在 phone 唯一索引上持有间隙锁,等待插入意向锁——一个经典的间隙锁死锁。


解析 — 死锁日志逐行拆解

间隙锁是什么

间隙锁是 InnoDB 在 REPEATABLE READ 隔离级别下为了防止幻读(Phantom Read)而使用的锁机制。它锁的不是某条记录,而是索引记录之间的间隙

B+ 树索引间隙示意图 — phone 唯一索引的叶节点与间隙

以我们的 phone 唯一索引为例,已有记录 13800011380003,B+ 树的叶子节点按排序组织。

索引记录之间有 3 个间隙:

间隙 范围
负无穷 ~ 1380001 (不涉及本例)
1380001 ~ 1380003 ← 本例的间隙
1380003 ~ supremum (不涉及本例)

SELECT * FROM users WHERE phone = '1380002' FOR UPDATE 执行时,InnoDB 通过唯一索引查找 1380002,发现它落在 (1380001, 1380003) 这个间隙里,于是对这个间隙加了一个间隙锁

锁类型对比:Record Lock vs Gap Lock vs Next-Key Lock

间隙锁不是孤立存在的。InnoDB 在 REPEATABLE READ 下有 4 种锁模式:

InnoDB 锁类型对比 — 间隙场景

锁类型 锁什么 典型触发条件
Record Lock 单条索引记录 WHERE id = 1 FOR UPDATE(查到记录时)
Gap Lock 索引记录之间的间隙 WHERE phone = '新值' FOR UPDATE(查不到时)
Next-Key Lock 记录 + 间隙 WHERE id > 5 FOR UPDATE(范围查询时)
Insert Intention Lock 间隙(INSERT 专用) INSERT INTO ...

"先查后插"模式的死锁,本质上是 Gap Lock 与 Insert Intention Lock 不兼容导致的。Insert Intention Lock 是一种特殊的间隙锁,它告诉 InnoDB:"我要在这个间隙插入数据了,请在间隙锁释放后再执行。"

锁等待图:为什么两个 INSERT 会互相锁

死锁等待图 — 间隙锁与插入意向锁的互相等待

关键洞察在这里:间隙锁是兼容的,但插入意向锁不兼容间隙锁。

锁类型 Gap Lock Insert Intention Lock
Gap Lock ✅ 兼容 ❌ 不兼容
Insert Intention Lock ❌ 不兼容 ✅ 兼容

两个事务可以同时持有同一个间隙上的间隙锁(SELECT ... FOR UPDATE 查不存在的记录),这本身就是反直觉的——大多数开发者以为 SELECT FOR UPDATE 会互斥。

但 INSERT 需要的插入意向锁,必须等该间隙上所有的间隙锁释放。两个事务互相等对方的间隙锁 → 死锁。

InnoDB 选择了事务 1 回滚,事务 2 继续。

间隙锁变种:不仅仅是唯一索引

间隙锁死锁不只发生在唯一索引的"先查后插"场景。以下情况也会触发:

变种 1:非唯一索引的范围查询

-- 假设 phone 上没有唯一索引,只有普通索引
-- 事务 1:
SELECT * FROM users WHERE phone > '1380000' AND phone < '1380004' FOR UPDATE;
-- → Next-Key Lock 锁定范围 (1380001, 1380003] 的间隙

-- 事务 2:
INSERT INTO users (phone) VALUES ('1380002');
-- → 插入意向锁被间隙阻塞 → 死锁

非唯一索引的范围查询产生的是 Next-Key Lock(记录锁 + 间隙锁),间隙比唯一索引查不到记录时更大,死锁概率更高。

变种 2:主键范围插入

-- 表 t 有主键 id
-- 事务 1:锁定一个主键范围
SELECT * FROM t WHERE id > 100 AND id < 200 FOR UPDATE;
-- → Next-Key Lock 覆盖 (100, 200) 间隙

-- 事务 2:在同一个间隙插入
INSERT INTO t (id) VALUES (150);
-- → 插入意向锁等待 → 如果两个事务都 SELECT 再 INSERT → 死锁

变种 3:自增主键下的外键约束检查

外键约束的检查也会产生间隙锁。当父表做范围更新时,子表的 INSERT 可能会被父表的间隙锁阻塞。

数据分布:间隙大小与冲突概率

本例中间隙 (1380001, 1380003) 本来不大,但当表中有大量预置数据时,间隙分布会极不均匀:

phone 索引记录间隙分布:
1380001 ─── 1380003 ─── 1380010 ─── 1380100 ─── 1381000
  │  2 个   │  7 个   │  90 个   │  900 个  │

间隙越大,两个并发线程落在同一个间隙的概率就越高。注册接口的核心矛盾就在这里:手机号是唯一的,业务必须检查重复;但检查重复用的 SELECT ... FOR UPDATE 恰恰创造了间隙锁。


路径 — 🔍 排查路径

排查路径 — 死锁分析五步法

死锁排查关键在锁类型的识别和业务代码的定位。排查命令打包:

-- 检查当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';

-- 查看最近一次死锁日志(8.0 专用)
SHOW ENGINE INNODB STATUS\G

-- 查看当前锁等待情况
SELECT * FROM performance_schema.data_locks\G

data_locks 表可以精确看到每个事务持有什么锁、在等什么锁:

data_locks 锁等待查询

data_locks 的输出可以清晰看到:

事务 ID 锁状态 含义
5278693 GRANTED + X,GAP 持有间隙锁
5278693 WAITING + X,GAP,INSERT_INTENTION 等待插入意向锁(被对方堵住)
5278694 GRANTED + X,GAP 持有间隙锁
5278694 WAITING + X,GAP,INSERT_INTENTION 等待插入意向锁(被对方堵住)

四个锁都指向同一个 LOCK_DATA = 1380003, 2,说明两个事务在同一个间隙上发生了冲突。


重构 — 锁策略优化

死锁的根因是:REPEATABLE READ + 先查后插 + 唯一索引 + 并发冲突落在同一个间隙。三种方案分别打不同的点:

方案 A:降隔离级别到 READ COMMITTED(推荐)

READ COMMITTED 级别下,InnoDB 对唯一索引的锁定只做记录锁(Record Lock),不加间隙锁

SET SESSION transaction_isolation = 'READ-COMMITTED';

BEGIN;
SELECT * FROM users WHERE phone = '1380002' FOR UPDATE;
-- RC 下:找不到记录,不加锁
INSERT INTO users (phone, name) VALUES ('1380002', '王五');
-- 正常插入(另一个事务也在 insert,靠唯一约束冲突来解决)
COMMIT;

影响分析

维度 REPEATABLE READ READ COMMITTED
间隙锁
INSERT 并发 有死锁风险 无死锁(唯一约束冲突报错)
幻读 有(注册业务不关心)
binlog 格式 STATEMENT/ROW 必须 ROW

大多数场景下,注册接口不关心幻读,RC 是最优解。唯一约束冲突(Duplicate Key Error)由业务层 catch 处理即可——比死锁回滚成本低得多。

方案 B:用 INSERT ... ON DUPLICATE KEY UPDATE

跳过 SELECT,直接 INSERT,让 MySQL 处理唯一键冲突:

-- before(先查后插):
SELECT * FROM users WHERE phone = '1380002' FOR UPDATE;
INSERT INTO users (phone, name) VALUES ('1380002', '王五');

-- after(直接插入 + 冲突处理):
INSERT INTO users (phone, name) VALUES ('1380002', '王五')
ON DUPLICATE KEY UPDATE name = VALUES(name);

不用 SELECT ... FOR UPDATE,就没有间隙锁。并发 INSERT 只会在唯一索引检查时短暂锁冲突,不会死锁。

局限:如果 INSERT 之前需要复杂业务校验(如查其他表),方案 B 不适用。

方案 C:应用层串行化

如果必须保持 RR 隔离级别且业务逻辑太复杂:

-- 应用层:同一个手机号的注册请求走同一个锁
// Java 伪代码
String phone = "13800020002";
String lockKey = "register:" + phone;
redisLock.lock(lockKey, 5, TimeUnit.SECONDS);
try {
    userService.register(phone, name);
} finally {
    redisLock.unlock(lockKey);
}

适用场景:对同一个 key 的并发改操作为主。不适用:批量操作或范围 INSERT。

方案决策矩阵

方案对比 — SQL 改写与隔离级别调整

选型指南

你的场景 推荐方案 理由
简单唯一键检查后 INSERT B (ON DUPLICATE KEY) 代码改动最小,不需要碰数据库配置
需要先查其他表再做 INSERT A (RC) SELECT FOR UPDATE 在其他表上也安全
必须 RR 隔离级别 C (串行化) 保留 RR 的幻读保护
批量插入新数据(无冲突) B 不需要 SELECT 检查,直接 INSERT
旧系统,动隔离级别风险大 C 不改数据库,只改业务代码

标记 — 🗝 检测同类 SQL

在你的项目里搜这种「先查后插」模式:

检测命令 — 搜索同类 SQL 模式

除了 grep,还可以用 MySQL 的 performance_schema 做死锁监控:

-- 开启死锁日志打印(MySQL 8.0)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 从 performance_schema 查死锁事务(需要 events_transactions 开启)
SELECT THREAD_ID, EVENT_NAME, SQL_TEXT, STATE
FROM performance_schema.events_statements_history
WHERE SQL_TEXT LIKE '%INSERT%'
ORDER BY THREAD_ID;

-- 检查 data_locks 是否有等待的插入意向锁
SELECT COUNT(*) AS waiting_insert_intentions
FROM performance_schema.data_locks
WHERE LOCK_MODE LIKE '%INSERT_INTENTION%'
  AND LOCK_STATUS = 'WAITING';

在代码 Review 中标记

SELECT ... FOR UPDATE 后面跟 INSERT。 如果查询条件用了唯一索引,且隔离级别是 REPEATABLE READ——这是一个间隙锁死锁的重灾区。

改成 INSERT ... ON DUPLICATE KEY UPDATE,或者降隔离级别到 READ COMMITTED


间隙锁不是用来防并发 INSERT 的——是防幻读的。但 REPEATABLE READ + 唯一索引 + 先查后插,三个条件凑在一起,间隙锁就从安全屏障变成了死锁制造机。

下篇我们聊 MVCC 的可见性判断——一条查询在不同隔离级别下的结果差异,比你想的复杂得多。


📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」