Press "Enter" to skip to content

MySQL 间隙锁(Gap Lock):前世今生与避坑指南

在使用 MySQL(InnoDB 引擎)时,你是否遇到过这样的灵异事件:
明明只是删除了一行不存在的数据,却导致另一个事务的插入操作被阻塞?
或者明明两个事务操作的是不同的行,却报了 Deadlock found when trying to get lock

这背后的“幽灵”,往往就是——间隙锁(Gap Lock)

今天我们就来扒一扒间隙锁的“前世今生”,以及在生产环境中可能遇到的那些“坑”。


📅 第一部分:前世——它是为了解决什么而生?

要理解间隙锁,必须先回到数据库的隔离级别

在 SQL 标准中,可重复读(Repeatable Read, RR) 隔离级别通常只需要保证“在同一个事务内,多次读取同一行数据,结果是一样的”。它并不要求解决**幻读(Phantom Read)**问题。

什么是幻读?

假设表中只有 id=1id=5 两条记录。

  1. 事务 A 查询 select * from table where id > 1,看到了 id=5
  2. 事务 B 此时插入了一条 id=3 的数据并提交。
  3. 事务 A 再次查询 select * from table where id > 1,结果看到了 id=3id=5

凭空“变”出了一条数据,这就是幻读。

InnoDB 的野心

MySQL 的 InnoDB 引擎比较“卷”,它决定在 RR 隔离级别下,就把幻读问题解决掉。

怎么解决?
如果只锁住 id=5 这一行(行锁),是挡不住别人插入 id=3 的。
于是,间隙锁(Gap Lock) 诞生了。它的使命非常纯粹:锁住两条记录之间的空隙,防止其他事务在这个空隙中“加塞”(Insert)数据。


🛠 第二部分:今生——它到底锁住了哪里?

在 InnoDB 的 RR 级别下,间隙锁通常和行锁结合使用,合称为 Next-Key Lock(临键锁)。

锁的范围图解

假设我们有一个表 tid 是主键,现有数据: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 UPDATEDELETE 不存在的记录是无害的,它们埋下了死锁的种子。

坑位二:全表被锁(无索引或索引失效)

如果你的 DELETEUPDATE 语句没有走索引:
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. 避免长事务:事务越长,持有锁的时间越长,发生冲突的概率呈指数级上升。

希望这篇文章能帮你彻底看清“间隙锁”的真面目。下次再遇到奇怪的阻塞问题,不妨问问自己:“是不是掉进间隙里了?”

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注