登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  MySQL

MySQL gap lock 为什么阻塞范围外的插入

来源:17golang原创

时间:2026-10-28 23:23:00 191浏览 收藏

MySQL gap lock 阻塞“范围外”插入,通常是因为插入值虽然不满足 WHERE 条件,却落在同一个被锁住的索引间隙里。InnoDB 按索引记录及记录之间的空隙组织这类锁,不能把 SQL 条件两端的数字直接当作锁的精确边界。

官方文档地址:https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html

本文说明 MySQL 8.4 InnoDB 现有锁机制的设计理由和使用影响,不将其描述为某个新版本刚引入的变化。示例用于读者在独立测试库操作,没有本机执行结果或真实线上故障记录。

为什么空结果也可能保护一段间隙?

在 REPEATABLE READ 下,范围锁定读可能使用 gap lock 或 next-key lock,限制其他事务向相关索引间隙插入,以支持相应的并发隔离语义。记录锁针对索引记录,gap lock 针对间隙;next-key lock 则同时包含记录及其前方间隙。

代价是并发插入可能受影响。返回零行只意味着没有现有记录符合查询,不代表“没有需要保护的插入位置”。普通一致性读取与 SELECT ... FOR UPDATE 不是同一种读法,排查时应先确认实际 SQL。

假设主键索引只有 10 和 30 两个值。查询 id >= 18 AND id 没有命中行,但目标位于相邻记录 10、30 之间。若锁定读保护了间隙 (10,30),插入 12 也会进入这个间隙;12 不满足查询条件,仍可能等待。

查询条件与相邻索引记录之间间隙的静态范围关系
结构说明图:条件范围位于索引间隙内部,插入值 12 虽在条件之外,仍属于间隙 (10,30)。图示解释关系,不是锁监控截图。

先把示例的版本、索引与事务固定下来

使用同一 MySQL 实例上的两个独立连接 A、B,必要时再开一个只读监控连接 C。选择已有的独立测试库,创建下表;不能使用会话临时表,因为另一个连接需要访问它。不要在业务库直接实验。

-- 准备会话:在独立测试库执行一次,表名不得指向真实业务表。
SELECT VERSION();
CREATE TABLE gap_scope_demo (
    id INT NOT NULL PRIMARY KEY,
    note VARCHAR(30) NOT NULL
) ENGINE = InnoDB;

-- 只保留两个相邻主键,便于解释中间间隙。
INSERT INTO gap_scope_demo (id, note)
VALUES (10, '左端记录'), (30, '右端记录');

接着在 A 中执行以下片段。隔离级别在事务开始前设置;FORCE INDEX (PRIMARY) 只是让这个演示明确沿主键索引查找,不是建议在生产 SQL 中随意强制索引。

-- 会话 A:设置下一事务隔离级别,再开启显式事务。
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- 空范围锁定读;执行完后暂时不要提交或回滚。
SELECT id
FROM gap_scope_demo FORCE INDEX (PRIMARY)
WHERE id >= 18 AND id 

A 的查询按样本应返回空集。接下来到 B 中执行插入:它不满足 A 的条件,却处于相邻索引记录之间。正常示例条件下可观察到等待;实际锁形态应结合工具链版本、计划和锁信息判断,不能照抄一份固定锁输出作为证据。

-- 会话 B:限制测试等待时间,防止把等待误认为客户端无响应。
SET SESSION innodb_lock_wait_timeout = 30;
START TRANSACTION;

-- 12 不在查询区间 [18,22),但在索引间隙 (10,30) 中。
INSERT INTO gap_scope_demo (id, note)
VALUES (12, '插入候选');

等待期间不要在同一个被阻塞的连接里继续提交监控语句。应使用连接 C 读取锁信息,或者先在 A 中结束事务。若 B 已超时,先显式回滚 B;需要重新观察时,从清理后的事务状态重做,不要不断叠加操作。

怎样证明它在等哪把锁?

有相应 Performance Schema 查询权限时,在 C 中把等待表与锁表关联。下面同时展示请求方与阻塞方的索引、锁模式及锁数据,避免只看“有一条 X 锁”就认定原因。

-- 会话 C:只读定位等待关系,ENGINE 也作为关联条件。
SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
       w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
       r.OBJECT_SCHEMA, r.OBJECT_NAME, r.INDEX_NAME,
       r.LOCK_MODE AS requested_mode,
       r.LOCK_DATA AS requested_data,
       b.LOCK_MODE AS blocking_mode,
       b.LOCK_DATA AS blocking_data
FROM performance_schema.data_lock_waits AS w
JOIN performance_schema.data_locks AS r
  ON r.ENGINE = w.ENGINE
 AND r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
JOIN performance_schema.data_locks AS b
  ON b.ENGINE = w.ENGINE
 AND b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID
WHERE r.OBJECT_NAME = 'gap_scope_demo';

结合实际库名、索引和事务信息,确认等待属于这次实验。插入方可能显示带 INSERT_INTENTION 的请求,阻塞方要结合是否包含 GAP 语义解释。锁数据里的某个索引记录值也可能用于表示其前方间隙,不能看到 30 就理解成“只锁住 id=30”。锁数据缺失时也不能凭空补全锁范围。

这里的等待关系是某一时刻的快照。事务结束、超时或被选作死锁牺牲者后,相关等待可能消失,因此事后查不到行不能证明此前没有等待。还要区分事务内部 ID、Performance Schema 线程 ID 与客户端连接 ID,不能拿到一种 ID 就直接当作另一种使用。

请求锁阻塞锁以及等待表之间的静态关联结构
结构说明图:data_lock_waits 保存依赖,data_locks 提供锁详情;ENGINE 与锁 ID 共同关联请求锁和阻塞锁,不表示执行顺序。

释放等待后,怎样调整业务代码?

实验中先在 A 执行 ROLLBACK。若 B 的插入随后成功,再在 B 回滚,避免改变样本数据;如果 B 已超时,也要清理其事务。最后确认两边事务都结束,再删除示例表。

-- 在会话 A 中执行:结束持有范围锁的事务。
ROLLBACK;
-- 在会话 B 中执行:插入完成或超时报错后,都显式清理事务。
ROLLBACK;
-- 两个事务结束后,在测试库清理演示表。
DROP TABLE gap_scope_demo;

业务修复优先检查事务是否夹带了网络请求、人工等待或无关计算,缩短持锁时间;再检查索引与谓词是否让扫描范围超出需求。唯一索引完整键值且命中现有单行的查找,与范围查找、未命中查找不同;不能为了减少等待把“查空并准备插入”的语义误改成不加保护的判断。

READ COMMITTED 下,普通搜索和索引扫描通常不使用 gap locking,但外键检查与重复键检查仍会使用。改变隔离级别会改变并发语义,必须按业务不变量设计,不应仅因示例不再等待就直接改全局配置。对于“同一业务键只能创建一次”,仍应靠合适的唯一约束和正确错误处理承接并发结果。

发现的情况下一步
插入值在 WHERE 外确认它在实际索引顺序中属于哪个间隙
锁定读返回空集检查间隙锁,不以返回行数推断锁数
换成普通 SELECT 后不等待先确认是否丢失业务需要的锁定语义
调整隔离级别同时评估约束、读一致性及重试策略

两个事务都拿到 gap lock,为什么还会卡?

gap lock 之间可以共存,它们的主要作用是阻止向间隙插入。两个事务随后都尝试插入时,仍可能出现等待甚至死锁;“都取得间隙锁”不等于后续写入一定成功。

只看 EXPLAIN 能确定锁范围吗?

不能。EXPLAIN 能帮助解释访问路径,但实际锁还依赖事务隔离级别、索引数据分布和运行时行为。应把实际 SQL、表定义、事务状态及等待依赖放在一起判断。

参考:InnoDB 锁类型与间隙锁、data_lock_waits、data_locks。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>