MySQL间隙锁怎么防止幻读及对INSERT操作的阻塞机制
深入解析MySQL InnoDB引擎在可重复读隔离级别下如何通过间隙锁与临键锁防止幻读,详细剖析间隙锁与插入意向锁的冲突判定过程、不同查询条件下的加锁范围退化规律及并发实验验证方法。
排查线上数据库慢查询或锁等待超时的时候,常会遇到这样一种现象:一张业务表里明明只存了 id = 10 和 id = 20 等几行离散的数据,一个事务执行了针对 id = 15 的更新,自己什么也没改到,却把另一个试图写入 id = 16 的 INSERT 语句硬生生卡住了。要弄清楚这背后的逻辑,核心在于理解 InnoDB 在可重复读(REPEATABLE READ)隔离级别下是如何通过间隙锁(Gap Lock)锁住索引记录之间的空隙,并借助它与插入意向锁(Insert Intention Lock)的冲突检测来阻止新记录落入查询范围,从而在当前读场景下防止幻读。
只锁住已有的数据行,为什么挡不住幻读
幻读(Phantom Read)指的是在同一个事务内部,先后用相同的条件执行两次查询,后一次查询看到了前一次查询时还不存在的新插入行。如果不区分读取方式,很容易把普通查询与加锁查询混为一谈。在 InnoDB 中,不带锁的普通 SELECT 走的是多版本并发控制(MVCC),事务在第一次读取时生成一致性读视图(Read View),后续普通查询都基于这份快照读取历史版本,自然看不到其他事务新提交的记录。
问题出在当前读(Current Read)上。当事务执行 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE(或在 MySQL 8.0 中使用 FOR SHARE)、UPDATE 以及 DELETE 时,InnoDB 必须读取最新提交的数据并对扫描到的索引加锁。假设只使用记录锁(Record Lock)锁住当前已经存在的行,比如表中只有 id = 10 和 id = 20,事务 A 查询 id BETWEEN 10 AND 20 FOR UPDATE,只把 10 和 20 这两条记录锁死,那么事务 B 依然可以顺利插入一条 id = 16 的新记录并提交。等事务 A 再次执行相同的 FOR UPDATE 查询或批量更新时,这条原本不存在的 id = 16 就会凭空冒出来。
为了堵住这个漏洞,InnoDB 引入了间隙锁(Gap Lock)。间隙锁不锁具体的记录本身,专门锁住两条索引记录之间的开区间,或者第一条记录之前、最后一条记录之后的开区间。在实际加锁过程中,InnoDB 默认将记录锁与该记录前的间隙锁组合成临键锁(Next-Key Lock),形成一个前开后闭区间。只要把这个区间封住,其他事务就无法往这个空当里插入新数据,幻读也就失去了产生的源头。
间隙锁阻塞 INSERT 的底层主角:插入意向锁
很多人初次了解间隙锁时,会直接把它理解成一种排他锁,觉得只要一个事务在 (10, 20) 上加了间隙锁,其他任何事务都不能再碰这个区间。但如果去数据库里实际跑一下就会发现,两个不同的事务可以同时在 (10, 20) 这个区间上成功加上间隙锁,哪怕一个是共享间隙锁(S, GAP),另一个是排他间隙锁(X, GAP),它们之间也完全不会发生阻塞。
原因并不复杂:间隙锁存在的唯一目的是防止有新记录插入到这个间隙中,而不是阻止其他事务也来保护这个间隙。事务 A 不希望 (10, 20) 里多出一行数据,事务 B 也不希望 (10, 20) 里多出一行数据,两者的诉求完全一致,因此间隙锁与间隙锁之间天生兼容。
那么,当另一个事务执行 INSERT 时,究竟是什么锁触发了等待?答案是插入意向锁(Insert Intention Lock)。插入意向锁本质上也是一种特殊的间隙锁,在 MySQL 8.0 的锁监控表 performance_schema.data_locks 中显示为 locks_mode: X,INSERT_INTENTION。每当一个事务准备把新行写入某个索引间隙之前,InnoDB 都会先按顺序完成以下判定:
先根据待插入数据的索引键值定位到它所属的下一条记录(例如插入 id = 16,定位到它后面的第一条现有记录 id = 20),然后检查 id = 20 这条记录上是否已经被其他事务持有了间隙锁(GAP)或临键锁(默认即包含间隙的排他/共享锁)。如果发现已有其他事务锁住了 (10, 20) 这个间隙,当前事务就必须在 id = 20 上申请一把插入意向锁并进入等待状态;只有当没有其他事务持有该区间的间隙锁时,插入操作才能顺利获取插入意向锁,并在新插入的记录上加上普通记录锁。
这种把“锁住间隙”和“申请插入意向”拆开的设计非常巧妙:如果某段间隙根本没有被查询事务加间隙锁,那么两个并发的 INSERT 语句即使往同一个 (10, 20) 区间里分别插入 id = 16 和 id = 17,它们各自申请的插入意向锁之间也不会互相阻塞,从而保证了高并发写入时的吞吐量。
动手验证:复现间隙锁防幻读与阻塞 INSERT 全过程
光看概念容易停留在纸面上,我们可以在本地 MySQL 8.0 环境中开启两个命令行终端窗口(记为会话 A 和会话 B),一步步观察间隙锁是如何生成的,又是如何把 INSERT 挡在门外的。
1. 准备测试表与初始数据
在任意一个 MySQL 客户端中确认当前事务隔离级别为默认的 REPEATABLE-READ,然后创建一张包含主键和普通二级索引的订单库存测试表 warehouse_stock,并写入 3 条间隔开的记录:
-- 确认隔离级别为 REPEATABLE-READ
SELECT @@transaction_isolation;
CREATE TABLE warehouse_stock (
id INT NOT NULL PRIMARY KEY,
sku_code INT NOT NULL,
quantity INT NOT NULL,
KEY idx_sku_code (sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO warehouse_stock (id, sku_code, quantity) VALUES
(10, 100, 50),
(20, 200, 80),
(30, 300, 120);
此时主键索引上有 10、20、30 三条记录,它们把主键空间切分出了 (-∞, 10)、(10, 20)、(20, 30) 和 (30, +∞) 四个间隙。
2. 在会话 A 中执行当前读并触发间隙锁
在会话 A 中显式开启事务,对 id = 15 执行加排他锁的查询(或者执行范围查询 WHERE id > 10 AND id < 20 FOR UPDATE):
-- 【会话 A】执行
BEGIN;
SELECT * FROM warehouse_stock WHERE id = 15 FOR UPDATE;
由于表中并不存在 id = 15 的记录,这条查询返回空结果集(Empty set)。虽然没有查到任何行,但为了保证在当前事务结束前不会有其他事务突然插入一条 id = 15(或者在范围查询下插入 10 到 20 之间的任意值),InnoDB 已经把 id = 15 所在的间隙 (10, 20) 锁住了。
3. 在会话 B 中尝试插入区间内的记录
保持会话 A 不提交,切换到会话 B,尝试往 (10, 20) 区间内插入一条 id = 16 的记录:
-- 【会话 B】执行
BEGIN;
INSERT INTO warehouse_stock (id, sku_code, quantity) VALUES (16, 160, 30);
敲下回车后可以看到,会话 B 的光标会一直停留在等待状态,语句无法立即完成。如果超过 innodb_lock_wait_timeout(默认 50 秒)会话 A 仍未提交,会话 B 就会收到如下报错:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
4. 查看 performance_schema 中的真实持锁与等待记录
在会话 B 处于阻塞等待的期间,我们可以在会话 A(或开启第三个监控窗口)中查询 performance_schema.data_locks,直接看清底层锁的类型与挂载位置:
-- 在会话 B 阻塞期间执行,查看当前行锁明细
SELECT
ENGINE_TRANSACTION_ID AS trx_id,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'warehouse_stock'
AND LOCK_TYPE = 'RECORD';
+--------+------------+-----------+------------------------+-------------+-----------+
| trx_id | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+--------+------------+-----------+------------------------+-------------+-----------+
| 4812 | PRIMARY | RECORD | X,GAP | GRANTED | 20 |
| 4813 | PRIMARY | RECORD | X,GAP,INSERT_INTENTION | WAITING | 20 |
+--------+------------+-----------+------------------------+-------------+-----------+
从这份输出里可以读出两个非常关键的细节:
第一,间隙锁总是挂载在间隙右端点的那条记录上。会话 A(事务 4812)要锁住的是 (10, 20) 这个开区间,因此在 data_locks 表里,它的 LOCK_DATA 显示为右边界 20,LOCK_MODE 显示为 X,GAP,状态为 GRANTED(已授予)。
第二,会话 B(事务 4813)想要插入 id = 16,定位到 (10, 20) 的右边界同样是 20,于是也在 LOCK_DATA = 20 上申请一把 X,GAP,INSERT_INTENTION(插入意向锁),由于与事务 4812 已持有的 X,GAP 冲突,它的 LOCK_STATUS 停留在 WAITING。
performance_schema.data_locks 的输出后,在会话 A 中执行 COMMIT;,可以看到会话 B 原本挂起的 INSERT 语句会立刻解除阻塞并返回 Query OK, 1 row affected。随后记得在会话 B 中也执行 COMMIT; 结束事务。
为什么有时候锁单行,有时候却锁住前后一大片?
理解了间隙锁阻塞 INSERT 的原理后,实际业务中最常让人困惑的是加锁范围的边界:为什么同样是等值条件,有时候完全不阻塞旁边的插入,有时候却把前后两个间隙全都封死了?
这取决于查询命中的索引是不是唯一索引(含主键),以及查询条件有没有命中已存在的记录。InnoDB 在加锁时默认以临键锁(Next-Key Lock)为单位扫描索引,但在能明确排除幻读风险的场景下,会将临键锁自动退化(降级)为更小粒度的记录锁或纯间隙锁。
1. 唯一索引(主键)等值查询:命中与未命中的差异
如果查询走的是主键或唯一索引的等值匹配,比如在我们的测试表里执行 SELECT * FROM warehouse_stock WHERE id = 20 FOR UPDATE;,由于主键具有唯一性,只要 id = 20 这一行已经存在,其他事务无论如何都不可能再插入第二条 id = 20 的记录。此时防幻读根本不需要封锁前后的间隙,InnoDB 会把临键锁 (10, 20] 退化为单纯的记录锁(X, REC_NOT_GAP),只锁住 id = 20 这一行。其他事务此时插入 id = 19 或 id = 21 都畅通无阻。
相反,如果等值查询的值在唯一索引中不存在,比如前面实验中的 WHERE id = 15 FOR UPDATE,InnoDB 扫描到 id = 20 时发现已经超过了 15,于是需要在 id = 20 上加锁以防止别的事务随后插入 id = 15。但因为查询的目标并不是 20,完全没必要锁住 id = 20 记录本身,所以临键锁 (10, 20] 会退化为纯间隙锁 (10, 20)(即 X, GAP)。
2. 非唯一二级索引等值查询:前后两个间隙都会被锁
当查询条件落在普通二级索引(如测试表中的 idx_sku_code)上时,情况就完全不同了。假设我们在会话 A 中执行:
-- 【会话 A】对非唯一索引 sku_code = 200 进行当前读
BEGIN;
SELECT * FROM warehouse_stock WHERE sku_code = 200 FOR UPDATE;
因为 idx_sku_code 不是唯一索引,即使表中已经有了一条 sku_code = 200(对应主键 id = 20)的记录,其他事务依然有可能再插入一条 sku_code = 200 的新行。为了彻底堵死新增 sku_code = 200 的可能,InnoDB 会沿着二级索引从 sku_code = 200 继续向右扫描,直到碰到第一条不等于 200 的记录(即 sku_code = 300)才停下来:
在二级索引 idx_sku_code 上,记录 (sku_code=200, id=20) 会被加上临键锁 (100, 200],而下一条记录 (sku_code=300, id=30) 上会被加上退化后的间隙锁 (200, 300);同时,回表对应的主键索引 id = 20 上还会加上一把记录锁(X, REC_NOT_GAP)。
这里有一个非常值得留意的边界细节:在二级索引中,索引项是按照“二级索引列值 + 主键值”联合排序的。当两个二级索引值相同时,会按主键值从小到大排列。因此,在 sku_code = 200 被锁定的状态下,如果另一个事务尝试插入 (id = 12, sku_code = 100),因为 (100, 12) 排在既有记录 (100, 10) 的右边,正好落入了 ((100, 10), (200, 20)] 这个临键锁覆盖的区间内,这条看似只写 sku_code = 100 的 INSERT 也会被阻塞;反之,如果插入的是 (id = 8, sku_code = 100),由于 (100, 8) 排在 (100, 10) 的左边,不落入 (100, 200) 的间隙,就能立即插入成功。
| 查询条件类型(REPEATABLE READ) | 示例 SQL(表内含 10、20、30) | 实际加锁模式与范围 | 会被阻塞的 INSERT 范围 |
|---|---|---|---|
| 唯一索引等值查询(记录存在) | WHERE id = 20 FOR UPDATE |
退化为记录锁:[20](X, REC_NOT_GAP) |
仅阻塞 id = 20 的主键冲突写入,旁边的 19、21 均可插入 |
| 唯一索引等值查询(记录不存在) | WHERE id = 15 FOR UPDATE |
退化为间隙锁:(10, 20)(挂载在 20 上的 X, GAP) |
阻塞 id ∈ (10, 20) 的所有 INSERT |
| 非唯一索引等值查询(记录存在) | WHERE sku_code = 200 FOR UPDATE |
二级索引 (100, 200] 临键锁 + (200, 300) 间隙锁 + 主键 [20] 记录锁 |
阻塞 sku_code ∈ (100, 300) 的插入,以及边界上落入该区间的主键组合 |
| 无索引列条件查询 | WHERE quantity = 80 FOR UPDATE |
走聚簇索引全表扫描,所有记录与所有间隙(含 Supremum)均加临键锁 |
整张表任何位置的 INSERT 均被阻塞 |
容易忽略的死角:先快照读、后当前读引发的“幻影行”
既然间隙锁能把区间内的 INSERT 牢牢拦住,是不是只要处在 REPEATABLE READ 隔离级别下,事务里就绝对不可能看到其他事务新插入的行?
并非如此。间隙锁只在执行当前读时才会加上。如果一个事务在开头先用不带锁的普通 SELECT(快照读)查了一次,此时 InnoDB 只生成了 Read View,并没有在索引上加任何间隙锁;接着另一个事务插入了一条新记录并提交(因为没有间隙锁拦截,插入会立刻成功);随后第一个事务对该新记录所在范围执行了一次 UPDATE 或 SELECT ... FOR UPDATE。由于 UPDATE 属于当前读,它会无视早先的 Read View,直接修改新插入的那行数据,并把这一行的最新事务 ID(DB_TRX_ID)改写成当前事务自己的 ID。紧接着当前事务哪怕再执行普通的快照读 SELECT,因为看到这条记录的 DB_TRX_ID 就是自己,也会把这行原本看不见的新数据查出来。
可以通过下面这组交错步骤看清这个现象:
-- 1. 【会话 A】开启事务,先做一次普通快照读(此时未加任何间隙锁)
BEGIN;
SELECT * FROM warehouse_stock WHERE id BETWEEN 10 AND 20;
-- 返回 id=10 和 id=20 两行
-- 2. 【会话 B】插入 id=18 并立即提交(因会话 A 未加间隙锁,不会阻塞)
BEGIN;
INSERT INTO warehouse_stock (id, sku_code, quantity) VALUES (18, 180, 99);
COMMIT;
-- 3. 【会话 A】执行一条范围 UPDATE(当前读,会直接更新刚提交的 id=18)
UPDATE warehouse_stock SET quantity = quantity + 1 WHERE id BETWEEN 10 AND 20;
-- 提示 Rows matched: 3 Changed: 3
-- 4. 【会话 A】再次执行与第 1 步完全相同的普通 SELECT
SELECT * FROM warehouse_stock WHERE id BETWEEN 10 AND 20;
-- 此时返回 id=10、id=18、id=20 三行,id=18 显现出来
SELECT 探路,而是从第一次查询起就直接带上 FOR UPDATE 或 FOR SHARE。只有从一开始就加上临键锁和间隙锁,才能把并发的 INSERT 挡在事务周期之外。
与间隙锁相关的两个常见后续问题
为什么两个事务只是先查询再插入,却报了 Deadlock 死锁而不是锁等待超时?
这正是“间隙锁之间互相兼容,但间隙锁阻塞插入意向锁”这一特性带来的典型副作用。假设会话 A 和会话 B 都采用“先按主键 SELECT ... WHERE id = 15 FOR UPDATE 检查记录是否存在,不存在再执行 INSERT”的写法。因为 id = 15 不存在,会话 A 和会话 B 都会顺利在 (10, 20) 上拿到一把 X,GAP 间隙锁(两者兼容,谁也不会拦谁)。紧接着,会话 A 执行 INSERT,需要申请插入意向锁,但被会话 B 持有的间隙锁挡住;几乎同一时刻,会话 B 也执行 INSERT,同样被会话 A 持有的间隙锁挡住。双方都在等对方释放间隙锁,InnoDB 的死锁检测器就会立刻介入,回滚其中一个事务并抛出 ERROR 1213 (40001): Deadlock found when trying to get lock。
遇到这类“存在即更新、不存在即插入”的场景,应避免在事务里先对不存在的记录执行 FOR UPDATE 再插入,可以直接改用 INSERT INTO ... ON DUPLICATE KEY UPDATE,或者依靠唯一索引直接执行 INSERT 并捕获唯一键冲突异常。
把隔离级别改成 READ COMMITTED 后,还有间隙锁吗?
如果将事务隔离级别改为读已提交(READ COMMITTED),InnoDB 在执行普通的范围查询、UPDATE 或 DELETE 时确实会关闭间隙锁,只对命中的实际记录加记录锁(REC_NOT_GAP),从而大幅减少 INSERT 被阻塞和发生间隙死锁的概率。不过需要注意一处例外:即便在 READ COMMITTED 级别下,当发生外键约束检查(Foreign Key Check)或唯一键重复值检查(Duplicate Key Check)时,例如两个并发事务先后插入相同唯一键但前一个事务尚未提交,InnoDB 依然会在内部临时使用间隙锁或临键锁来保证约束校验的准确性。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













