明明只是删除了一行不存在的数据,却导致另一个事务的插入操作被阻塞?
或者明明两个事务操作的是不同的行,却报了
Deadlock found when trying to get lock?这背后的“幽灵”,往往就是——**间隙锁(Gap Lock)**。
今天我们就来扒一扒间隙锁的“前世今生”,以及在生产环境中可能遇到的那些“坑”。
---
📅 第一部分:前世——它是为了解决什么而生?
要理解间隙锁,必须先回到数据库的**隔离级别**。
在 SQL 标准中,**可重复读(Repeatable Read, RR)** 隔离级别通常只需要保证“在同一个事务内,多次读取同一行数据,结果是一样的”。它并不要求解决**幻读(Phantom Read)**问题。
什么是幻读?
假设表中只有
id=1 和 id=5 两条记录。1. 事务 A 查询 select * from table where id > 1,看到了 id=5。
2. 事务 B 此时插入了一条 id=3 的数据并提交。
3. 事务 A 再次查询 select * from table where id > 1,结果看到了 id=3 和 id=5。
凭空“变”出了一条数据,这就是幻读。
InnoDB 的野心
MySQL 的 InnoDB 引擎比较“卷”,它决定在 RR 隔离级别下,就把幻读问题解决掉。
怎么解决?
如果只锁住
id=5 这一行(行锁),是挡不住别人插入 id=3 的。于是,**间隙锁(Gap Lock)** 诞生了。它的使命非常纯粹:**锁住两条记录之间的空隙,防止其他事务在这个空隙中“加塞”(Insert)数据。**
---
🛠 第二部分:今生——它到底锁住了哪里?
在 InnoDB 的 RR 级别下,间隙锁通常和行锁结合使用,合称为 **Next-Key Lock**(临键锁)。
锁的范围图解
假设我们有一个表
t,id 是主键,现有数据:5, 10, 15, 20。这些数据将数轴切分成了以下几个区间:
- (-∞, 5]
- (5, 10]
- (10, 15]
- (15, 20]
- (20, +∞)
触发间隙锁的三种典型场景
1. 唯一索引的“等值查询”,但记录不存在(纯粹的间隙锁)
执行
DELETE FROM t WHERE id = 7;- 现状:id=7 不存在,它落在 (5, 10) 这个区间。
- 结果:InnoDB 会加上一个 Gap Lock,锁住 (5, 10)。
- 影响:此时,任何试图插入 id=6, id=8, id=9 的操作都会被阻塞,直到 delete 事务提交。
2. 普通索引(非唯一)的“等值查询”(Next-Key Lock)
假设
col 是普通索引,且有一行 col=10。执行
SELECT * FROM t WHERE col = 10 FOR UPDATE;- 机制:为了防止幻读(比如别人再插一个 col=10),InnoDB 必须锁住 col=10 及其前后的间隙。
- 结果:它会锁住 (5, 10](Next-Key Lock)以及 (10, 15)(Gap Lock)。
3. 范围查询
执行
SELECT * FROM t WHERE id > 15 FOR UPDATE;- 结果:锁住 (15, 20] 和 (20, +∞)。
---
💣 第三部分:那些年踩过的坑(避坑指南)
间隙锁虽然保证了数据一致性,但它也是生产环境中**死锁(Deadlock)**的频发地带。
坑位一:间隙锁是“共享”的,但插入意向锁是“独占”的
这是最违反直觉的一点:**两个事务可以同时持有同一个间隙锁。**
- 场景:
事务 A 执行 DELETE FROM t WHERE id = 7(记录不存在)。A 获得了 (5, 10) 的间隙锁。
- 事务 B 执行 SELECT * FROM t WHERE id = 8 FOR UPDATE(记录也不存在)。B 也能获得 (5, 10) 的间隙锁。
- 结果:A 和 B 相安无事,互不阻塞。
**真正的危机**:
- 此时,事务 A 想执行 INSERT INTO t (id) VALUES (7)。它发现 B 持有间隙锁,于是 A 被阻塞,等待 B 释放。
- 接着,事务 B 想执行 INSERT INTO t (id) VALUES (8)。它发现 A 持有间隙锁,于是 B 被阻塞,等待 A 释放。
- BOOM!死锁发生。 MySQL 回滚其中一个事务。
> 启示:不要认为 SELECT ... FOR UPDATE 或 DELETE 不存在的记录是无害的,它们埋下了死锁的种子。
坑位二:全表被锁(无索引或索引失效)
如果你的
DELETE 或 UPDATE 语句没有走索引:DELETE FROM t WHERE non_indexed_col = 'abc';- 后果:InnoDB 无法判断哪些行需要锁定,为了安全起见,它会扫描全表,并锁住所有的记录和所有的间隙。
- 现象:整张表基本无法写入,相当于被锁死了。生产环境发生这种情况通常就是 P0 级事故。
坑位三:LIMIT 语句的误导
你可能以为加上
LIMIT 就能减小锁范围:DELETE FROM t WHERE col = 10 LIMIT 1;虽然
LIMIT 减少了被删除的行数,但在加锁阶段,如果索引利用不当,MySQL 依然可能申请比你想象中更大的间隙锁范围。---
🛡️ 总结与建议
间隙锁是 InnoDB 在 RR 级别下为了数据准确性而付出的代价(牺牲并发换取一致性)。要避免被它“坑”到,建议遵循以下原则:
1. 尽量使用主键或唯一索引进行更新和删除操作。如果是精确匹配且记录存在,间隙锁就会退化为行锁,并发性能最好。
2. 监控死锁日志:使用 SHOW ENGINE INNODB STATUS 查看死锁详情,如果发现大量 lock_mode X locks gap before rec 字样,基本就是间隙锁惹的祸。
3. 考虑隔离级别:如果业务允许(不需要解决幻读),可以将隔离级别调整为 Read Committed (RC)。在 RC 级别下,默认没有间隙锁(外键检查等特殊情况除外),这能极大地减少死锁概率。
4. 避免长事务:事务越长,持有锁的时间越长,发生冲突的概率呈指数级上升。
---
希望这篇文章能帮你彻底看清“间隙锁”的真面目。下次再遇到奇怪的阻塞问题,不妨问问自己:“是不是掉进间隙里了?”