两个 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,触发了死锁。

事务 1 和事务 2 都在 phone 唯一索引上持有间隙锁,等待插入意向锁——一个经典的间隙锁死锁。
解析 — 死锁日志逐行拆解
间隙锁是什么
间隙锁是 InnoDB 在 REPEATABLE READ 隔离级别下为了防止幻读(Phantom Read)而使用的锁机制。它锁的不是某条记录,而是索引记录之间的间隙。

以我们的 phone 唯一索引为例,已有记录 1380001 和 1380003,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 种锁模式:

| 锁类型 | 锁什么 | 典型触发条件 |
|---|---|---|
| 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 的输出可以清晰看到:
| 事务 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。
方案决策矩阵

选型指南:
| 你的场景 | 推荐方案 | 理由 |
|---|---|---|
| 简单唯一键检查后 INSERT | B (ON DUPLICATE KEY) | 代码改动最小,不需要碰数据库配置 |
| 需要先查其他表再做 INSERT | A (RC) | SELECT FOR UPDATE 在其他表上也安全 |
| 必须 RR 隔离级别 | C (串行化) | 保留 RR 的幻读保护 |
| 批量插入新数据(无冲突) | B | 不需要 SELECT 检查,直接 INSERT |
| 旧系统,动隔离级别风险大 | C | 不改数据库,只改业务代码 |
标记 — 🗝 检测同类 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拆代码的曹操」