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

MySQL InnoDB 隔离级别并发更新为什么出现间隙锁等待怎样完成一个可复用的最小示例

来源:17golang原创

时间:2026-11-10 00:11:46 141浏览 收藏

在订单、库存或任务表上做并发更新时,最让人困惑的现象往往是:两条 SQL 改的不是同一行,第二个事务却一直等待。很多时候,等待的对象不是已经存在的记录,而是索引记录之间的“空档”。这就是 InnoDB 间隙锁参与后的典型表现。

本文只解决一个边界问题:怎样用一个可重复的最小示例看清隔离级别、范围条件和索引之间的关系。结论先说清楚:默认 REPEATABLE READ 下,范围更新或锁定读可能拿到 next-key lock,它由记录锁和前面的间隙锁组成;如果业务允许,可以评估 READ COMMITTED,但不能把降低隔离级别当成无条件修复。

官方参考:https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html

先把“更新不同记录”变成可观察的等待

准备一个隔离测试库和两个客户端会话。示例使用 slot_no 作为非唯一索引,故意让两个事务访问同一个索引范围中的不同值。代码只描述复现材料,不代表已经在本文环境执行。

-- 建议在独立测试库执行,避免触碰业务表
CREATE TABLE inventory_gap_demo (
  id BIGINT NOT NULL PRIMARY KEY,
  slot_no INT NOT NULL,
  state VARCHAR(16) NOT NULL,
  quantity INT NOT NULL,
  KEY idx_slot_no (slot_no)
) ENGINE=InnoDB;

-- 插入有空档的索引值,方便观察范围前后的锁覆盖
INSERT INTO inventory_gap_demo (id, slot_no, state, quantity) VALUES
  (1, 10, 'ready', 8),
  (2, 20, 'ready', 6),
  (3, 30, 'ready', 4);

-- 固定当前会话的隔离级别,避免复现时沿用未知连接状态
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

在会话 A 中先锁住一个范围,再保持事务不提交:

-- 会话 A:范围更新会沿 idx_slot_no 查找并加锁
START TRANSACTION;
UPDATE inventory_gap_demo
SET state = 'reserved'
WHERE slot_no >= 20 AND slot_no 

在会话 B 中更新范围内的另一个候选值:

-- 会话 B:不同 SQL 不代表一定没有索引范围冲突
START TRANSACTION;
UPDATE inventory_gap_demo
SET quantity = quantity - 1
WHERE slot_no = 25;

-- 如果正在等待,可在另一个管理会话查看等待链
SELECT OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
       LOCK_TYPE, LOCK_MODE, LOCK_STATUS
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'inventory_gap_demo';

这个构造的关键不是某个固定超时数字,而是“会话 A 保持范围锁,会话 B 试图在同一索引次序中写入或更新”。如果数据分布、优化器选取的索引或版本配置不同,等待细节也会不同,不能把一条示例输出当成所有生产场景的固定结果。

MySQL InnoDB 索引记录、范围条件与间隙锁覆盖的静态关系说明图
图1:索引记录、范围谓词和锁对象的静态说明图,不是截图或运行证据;重点看记录之间的空档为何也会成为并发边界。

为什么范围更新会锁住记录之间的空档

InnoDB 的记录锁锁住的是索引记录,而间隙锁锁住的是索引记录之间的范围。官方文档把 next-key lock 定义为“记录锁 + 记录前面的间隙锁”。在默认的 REPEATABLE READ 下,搜索或扫描索引时会使用这种组合来防止幻读。

因此,会话 A 的范围不是简单的“只改一条已有记录”。它包含一个索引搜索边界:符合条件的记录需要被保护,边界内可能出现的新索引记录也需要被阻止。会话 B 即使准备写入当前不存在的 slot_no = 25,仍可能触碰会话 A 持有的间隙。

有三个概念不要混在一起:

  • 记录锁:保护已经存在的索引记录。
  • 间隙锁:抑制其他事务向索引空档插入,间隙锁彼此可以共存。
  • next-key lock:把记录锁和前方间隙锁组合起来,常见于可重复读下的范围扫描。

这也是“更新的是不同业务对象,为什么还会等待”的原因:数据库竞争的是索引范围,不只是业务主键的相等值。

唯一等值条件为什么通常不会扩大成间隙锁

把范围条件和唯一索引上的唯一等值条件放在一起比较,通常更容易定位根因。官方文档明确指出,使用唯一索引搜索唯一行时不需要间隙锁;但联合唯一索引只使用部分列时,仍可能发生间隙锁。

-- 用唯一主键等值访问单行,锁的边界通常更窄
START TRANSACTION;
UPDATE inventory_gap_demo
SET quantity = quantity - 1
WHERE id = 2;

-- 用范围谓词访问索引区间,不能按“只改不同 id”推断没有冲突
UPDATE inventory_gap_demo
SET quantity = quantity - 1
WHERE slot_no >= 20 AND slot_no 

这里的对比不是说主键等值更新永远不会等待:如果另一个事务已经锁住同一条记录,记录锁仍然会冲突。准确说法是,唯一索引上的唯一等值访问通常不会额外锁住搜索范围的间隙,等待对象更容易缩小到实际记录。

生产排查时要同时看谓词和执行路径。列上有索引,并不等于一定使用了你想象的索引;非唯一索引、联合索引的前缀条件和范围扫描都可能改变锁覆盖。

把 REPEATABLE READ 与 READ COMMITTED 放在同一张决策表

READ COMMITTED 可以显式关闭搜索和索引扫描的间隙锁,官方文档也说明外键检查和重复键检查仍可能使用间隙锁。它减少的是一类锁覆盖,不是把所有并发写冲突清零。

观察点REPEATABLE READREAD COMMITTED判断重点
范围搜索可能使用 next-key lock搜索和扫描通常不使用间隙锁是否依赖阻止幻读
同一已有记录仍可能发生记录锁等待仍可能发生记录锁等待是否改到了同一行
外键/重复键可能涉及间隙锁仍可能涉及间隙锁是否属于约束检查
业务语义快照一致性更强读取到已提交的新版本机会更多是否能接受读视图变化

不要为了消除一次等待就全局改隔离级别。先确认业务是否需要同一事务内的稳定读视图,再在单个连接或明确的工作负载上评估。隔离级别是并发语义选择,不是锁等待参数。

用锁对象和事务边界判断修复方向

实际处理时,可以按“范围是否必要、索引是否匹配、事务是否过长、隔离语义是否可降级”四个问题做决策。

-- 先在等待发生时查看当前锁对象,不要凭 SQL 文本猜测
SELECT ENGINE_TRANSACTION_ID, OBJECT_SCHEMA, OBJECT_NAME,
       INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS,
       LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = DATABASE()
  AND OBJECT_NAME = 'inventory_gap_demo';

-- 只为当前排查会话设置超时,避免把超时当成根因修复
SET SESSION innodb_lock_wait_timeout = 5;

-- 事务只包住必要的读写,完成后尽快释放锁
COMMIT;
  1. 必须阻止范围内新行吗?如果库存占位、号段分配等逻辑需要防止并发插入,间隙锁可能是语义的一部分,不能简单删除。
  2. 能否改成唯一键等值访问?如果业务对象有稳定唯一标识,优先让更新走唯一条件,避免用宽范围谓词代替精确定位。
  3. 索引是否覆盖真实过滤条件?检查联合索引顺序和访问条件,避免因为缺索引而扫描更宽范围;索引调整仍需单独评估写入成本。
  4. 事务是否夹带了慢操作?把网络调用、人工确认和无关查询移出事务,缩短持锁时间,通常比盲目调大等待超时更直接。
  5. 能否在局部使用 READ COMMITTED?只有在读视图和幻读语义都可接受时,才在限定连接或工作负载中改用,并补上回归检查。

如果日志里看到的是 locks gap before rec、范围索引或 next-key 相关信息,先确认锁对象和持有事务,再决定改 SQL、改索引、缩短事务还是调整隔离级别。不要只看“第二条 UPDATE 等了几秒”这个表象。

MySQL 间隙锁排查中隔离级别、索引、事务边界与修复选择的静态关系图
图2:修复决策的静态边界图,不是截图或运行证据;它把业务语义、访问条件、持锁时间和隔离级别放到同一判断面上。

哪些做法看似有效,实际上没有解决根因

  • 只把 innodb_lock_wait_timeout 调大:它延长等待,不会改变谁持有范围锁。
  • 只给非唯一范围列加一个索引:索引可能让扫描更精准,也可能改变访问路径,但是否减少锁覆盖仍要看谓词和唯一性。
  • 看到两个值不同就认定互不冲突:如果它们位于同一个被保护的索引区间,仍会共享竞争边界。
  • 直接全局切换 READ COMMITTED:可能改变快照读取和业务一致性,必须先确认应用语义。
  • 让事务长期保持打开:即使 SQL 本身很快,锁也会被后续业务动作拖长。

结论:先认清锁住的是“范围”,再选择缩小边界

InnoDB 并发更新出现间隙锁等待,通常不是数据库随机锁住了无关数据,而是范围谓词、索引顺序和隔离级别共同决定了保护范围。默认 REPEATABLE READ 下,next-key lock 会同时关注索引记录和前方间隙;唯一索引上的唯一等值访问通常可以把边界缩小到记录本身;READ COMMITTED 可减少搜索和扫描的间隙锁,但仍保留记录锁及约束检查相关的竞争。

可复用的排查顺序是:先在隔离库还原两个会话,再查看实际锁对象,确认索引和谓词,最后结合业务一致性选择唯一等值访问、合适索引、更短事务或局部隔离级别调整。

相关问题

  • 为什么唯一索引的部分列条件仍可能产生间隙锁?
  • InnoDB 记录锁等待和死锁应该分别看哪些字段?
  • 把范围更新改成先查主键再按主键更新有什么代价?
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>