MySQL InnoDB RR 隔离下二级索引临键锁引发 INSERT 锁超时 全链路解析

MySQL InnoDB RR 隔离下二级索引临键锁引发 INSERT 锁超时 全链路解析

前言

本文源自一次真实生产故障排查:批处理长事务执行 UPDATE 后,页面手动触发的 INSERT 操作报 Lock wait timeout exceeded。排查过程中先后排除了主键冲突、唯一索引冲突等常见原因,最终定位到 RR 隔离级别 + 普通二级索引产生的临键锁 这一相对隐蔽的根因。

本文将完整还原从报错现象、根因推导、本地复现、原理拆解到最终优化方案的全流程,同时整理间隙锁触发条件、常见锁场景对比等核心知识点。


1. 问题现象与业务背景

1.1 报错信息

生产环境抛出异常,核心报错如下:

复制代码
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: 
Lock wait timeout exceeded; try restarting transaction

报错 SQL 为 INSERT INTO INDX_INFO,即阻塞发生在插入阶段,而非更新 / 删除阶段。

1.2 业务场景
  • 方法 A(批处理任务) :方法上加 @Transactional 开启事务,内部先按业务条件执行 UPDATE,随后进行长时间的数据校验、业务计算,事务迟迟不提交。
  • 方法 B(页面手动新增):无事务注解,单条 SQL 自动提交,向同一张表插入新数据。
  • 并发场景:方法 A 长事务持锁期间,方法 B 执行插入,触发锁等待超时。
1.3 初步疑问

表主键由全局 ID 生成器生成,理论上不会重复;且方法 B 插入的是全新主键的数据。为什么主键不重复,INSERT 依然会被锁住?


2. 环境与表结构

2.1 表索引信息
索引名 索引类型 字段 说明
PRIMARY 主键索引 DATAID 全局生成,唯一不重复
INDX_INFO_02 普通联合二级索引 DEL_FLAG, INDX_CODE, TRADE_DATE 非唯一索引,用于业务条件查询更新
2.2 数据库配置
  • 存储引擎:InnoDB
  • 事务隔离级别:REPEATABLE READ(RR,可重复读,MySQL 默认)
  • 锁等待超时参数:innodb_lock_wait_timeout = 50s(默认)
2.3 操作特征
  • 方法 A 的 UPDATE 语句,WHERE 条件为 DEL_FLAG + INDX_CODE + TRADE_DATE,走上述普通联合二级索引。
  • 方法 B 的 INSERT 语句,DEL_FLAG、INDX_CODE、TRADE_DATE 字段值,与方法 A 更新的记录完全一致。

3. 根因深度解析:二级索引临键锁阻塞 INSERT

3.1 排除常见原因
  1. 排除主键行锁冲突:如果是主键锁冲突,阻塞应发生在 UPDATE/DELETE 同主键行的阶段;本次报错在 INSERT 阶段,且主键全新不重复,因此排除。
  2. 排除唯一索引冲突等待:该联合索引为普通索引,非唯一索引,不存在唯一键校验等待。
  3. 特殊场景验证:若方法 B 是「先 DELETE 旧记录、再 INSERT 同主键新记录」,DELETE 按主键执行可成功,但 INSERT 依然会阻塞 ------ 进一步说明阻塞点在二级索引层面,而非主键。
3.2 核心概念铺垫
① 当前读 vs 快照读
  • 快照读 :普通 SELECT 语句,通过 MVCC 读取历史版本数据,不加任何锁。
  • 当前读 :SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT,读取最新数据,会加锁。
② 临键锁(Next-Key Lock)

RR 隔离级别下,InnoDB 为防止幻读,引入临键锁,它由「行锁 + 间隙锁」组成:

  • 行锁:锁定索引树上已存在的记录本身。
  • 间隙锁(Gap Lock):锁定索引记录之间的空隙,防止其他事务向间隙中插入新数据。
③ 插入意向锁(Insert Intention Lock)

INSERT 执行时,会先申请插入意向锁,用于声明「准备向该索引间隙插入数据」。

  • 插入意向锁之间互不冲突,多个事务可同时向同一间隙插入不同记录。
  • 插入意向锁与间隙锁 / 临键锁互斥:只要间隙被锁住,新的插入就会阻塞。
3.3 不同索引的加锁规则(RR 隔离)
索引类型 场景 锁类型 是否产生间隙锁
唯一索引(主键 / 唯一键) 等值查询,精准命中记录 仅行锁 ❌ 无
唯一索引 等值查询,未命中记录 纯间隙锁 ✅ 有
唯一索引 范围查询 临键锁 ✅ 有
普通二级索引 等值查询,命中记录 临键锁(行锁 + 后间隙) ✅ 有
普通二级索引 等值查询,未命中记录 纯间隙锁 ✅ 有
普通二级索引 范围查询 临键锁 ✅ 有

本次问题的核心:普通二级索引,等值查询命中记录,依然会产生临键锁(包含间隙锁)。这是和唯一索引最关键的区别。

3.4 完整阻塞链路
  1. 方法 A 开启事务,执行 UPDATE,WHERE 条件走普通联合二级索引。
  2. InnoDB 在该二级索引上生成 Next-Key 临键锁,锁住目标索引记录 + 该记录后面的索引间隙。
  3. 方法 A 进入长时间业务校验逻辑,事务不提交,临键锁持续持有。
  4. 方法 B 执行 INSERT,新记录的二级索引字段值,刚好落在临键锁的间隙范围内。
  5. INSERT 申请插入意向锁,与方法 A 持有的临键锁互斥,进入阻塞等待。
  6. 等待时长超过 innodb_lock_wait_timeout,抛出锁等待超时异常。

4. 本地复现完整教程

4.1 前置准备
  1. 确认数据库隔离级别为 RR:

    SELECT @@transaction_isolation;

  2. 调小锁等待超时,加速复现(一般默认50s,也可以在代码业务层sleep长时间验证):

    SET innodb_lock_wait_timeout = 5;

4.2 复现踩坑:事务失效的常见原因

很多人复现失败,本质是 @Transactional 注解失效了。

  • 错误写法 :在测试类内部通过 this.方法名()调用带事务注解的方法。

    Spring 声明式事务基于 AOP 动态代理实现,同类内部 this调用会绕过代理对象,事务注解完全不生效,SQL 执行完自动提交,锁立刻释放,自然无法复现长事务持锁。

  • 正确写法 :将数据库操作封装到独立的 Service Bean 中,测试类通过 @Autowired 注入后调用,代理对象才会生效。

4.3 完整复现代码
① 业务 Service
复制代码
@Service
public class LockTestService {

    @Autowired
    private IndxInfoMapper mapper;

    /**
     * 长事务A:按普通二级索引条件更新,产生临键锁
     */
    @Transactional
    public void longTxUpdate() throws InterruptedException {
        // WHERE条件走普通联合二级索引,触发临键锁
        LambdaUpdateWrapper<IndxInfo> wrapper = Wrappers.lambdaUpdate();
        wrapper.eq(IndxInfo::getDelFlag, 0)
               .eq(IndxInfo::getIndxCode, "INDX01")
               .eq(IndxInfo::getTradeDate, LocalDate.of(2026, 10, 20))
               .set(IndxInfo::getLastUdtTms, LocalDateTime.now());
        mapper.update(null, wrapper);

        // 模拟长事务:业务校验、数据处理,事务不提交,锁持续持有
        TimeUnit.SECONDS.sleep(60);
    }

    /**
     * 方法B:插入全新主键、相同二级索引值的记录
     */
    public void bizInsert() {
        IndxInfo entity = new IndxInfo();
        entity.setDataId(IdGenerator.nextId()); // 全新主键,不重复
        entity.setDelFlag(0);
        entity.setIndxCode("INDX01");
        entity.setTradeDate(LocalDate.of(2026, 10, 20));
        mapper.insert(entity);
    }
}
② 测试类(双线程并发)
复制代码
@SpringBootTest
public class LockReproduceTest {

    @Autowired
    private LockTestService lockTestService;

    @Test
    void testGapLockBlock() throws InterruptedException {
        // 线程A:长事务更新,持有临键锁
        Thread threadA = new Thread(() -> {
            try {
                lockTestService.longTxUpdate();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "thread-A");

        threadA.start();
        TimeUnit.SECONDS.sleep(1); // 等待A先拿到锁

        // 线程B:执行插入,触发锁等待
        Thread threadB = new Thread(() -> {
            try {
                lockTestService.bizInsert();
            } catch (Exception e) {
                // 捕获 Lock wait timeout exceeded
                e.printStackTrace();
            }
        }, "thread-B");

        threadB.start();
        threadA.join();
        threadB.join();
    }
}
4.4 验证锁类型

在线程 A 休眠期间,执行 SQL 查看当前锁信息,确认是二级索引临键锁:

复制代码
SELECT
  OBJECT_SCHEMA,
  OBJECT_NAME,
  INDEX_NAME,
  LOCK_MODE,
  LOCK_DATA
FROM performance_schema.data_locks;

预期结果:

  • INDEX_NAME = INDX_INFO_02(目标二级索引)
  • LOCK_MODE = X,Next-Key(临键锁,包含间隙锁)

5. 间隙锁触发的完整条件

5.1 必须同时满足的基础前提
  1. 存储引擎 = InnoDB:MyISAM 只有表锁,无行锁 / 间隙锁概念。
  2. 隔离级别 = RR:RC 隔离级别下 InnoDB 关闭了间隙锁,业务层面几乎不会出现。
  3. SQL 为当前读 :SELECT ... FOR UPDATE、UPDATE、DELETE;普通 SELECT 快照读永远不会产生间隙锁。
  4. 执行计划走索引:间隙锁加在索引树的间隙上;全表扫描会导致全表所有间隙都被加锁,锁范围极大。
5.2 关键结论
  • 唯一索引等值命中记录 → 只有行锁,无间隙锁。
  • 普通二级索引等值命中记录 → 临键锁,带间隙锁。
  • INSERT 本身不产生间隙锁,只会申请插入意向锁;它是间隙锁的「被阻塞方」。

6. 解决方案与优化思路(按优先级排序)

方案 1:缩小事务边界(首选,根治)

将耗时的业务校验、文件 IO、远程调用全部移出事务,事务仅包裹纯数据库操作,最大限度缩短锁的持有时间。

复制代码
// 外层无事务,执行耗时逻辑
public void batchProcess() {
    // 所有耗时业务校验,放在事务外执行
    heavyDataValidate();
    // 仅数据库操作放入短事务
    doDbUpdate();
}

@Transactional
public void doDbUpdate() {
    mapper.update(...);
}
方案 2:UPDATE 改走主键 / 唯一索引(推荐,缩小锁范围)

先通过业务条件查询出主键,再按主键执行 UPDATE。主键是唯一索引,等值命中仅加行锁,不会产生间隙锁,从锁范围上彻底规避问题。

复制代码
@Transactional
public void updateByPrimaryKey() {
    // 普通快照读查询主键,不加锁
    List<Long> dataIds = mapper.selectIdByCondition(delFlag, indxCode, tradeDate);
    // 按主键更新,仅行锁,无间隙锁
    for (Long id : dataIds) {
        mapper.updateById(id, updateEntity);
    }
}
方案 3:调整隔离级别为 RC(谨慎评估)

读已提交(RC)隔离级别下,InnoDB 关闭间隙锁,仅对真实存在的记录加行锁,从根本上消除间隙锁阻塞。

注意:RC 无法防止幻读,金融、账务等对数据一致性要求高的场景需充分评估业务风险。

方案 4:业务侧错开索引区间(临时应急)

让批处理任务和页面新增的二级索引值(如指标编码、交易日)落在不同区间,避免命中同一个索引间隙。仅作为临时规避手段,不推荐长期使用。

6.1 重要误区澄清

❌ 误区:让索引失效、不走二级索引,就能避免间隙锁。

✅ 事实:索引失效会导致全表扫描,RR 隔离下全表所有记录都会被加临键锁,相当于锁住全表所有间隙,所有写入都会阻塞,问题会被严重放大。


7. MySQL 常见锁场景对比

场景 触发原因 阻塞阶段 核心特征
主键行锁冲突 两个事务操作同一主键行的 UPDATE/DELETE UPDATE/DELETE 阶段 走主键索引,仅锁单行
唯一索引插入等待 两个事务插入相同唯一索引值 INSERT 阶段 等待结束后要么成功、要么报重复键
二级索引临键锁阻塞 INSERT RR + 普通二级索引当前读,产生临键锁 INSERT 阶段 主键不重复,仅二级索引区间冲突
死锁(Deadlock) 两个事务互相持有对方需要的锁,循环等待 任意 DML 阶段 InnoDB 主动检测并回滚其中一个事务
全表扫描大面积锁 UPDATE/DELETE 无有效索引,全表扫描 全表写入均阻塞 近似表锁效果,所有行 + 间隙都被锁

8. 总结

本次故障的本质,是 RR 隔离级别下「普通二级索引的临键锁」与「INSERT 插入意向锁」的互斥冲突。它的隐蔽性在于:主键完全不重复,INSERT 依然会被阻塞,很容易误导排查方向。

解决这类问题的核心思路有两个维度:

  1. 时间维度:缩小事务边界,缩短锁的持有时间。
  2. 空间维度:让更新走唯一索引,缩小锁的范围,消除间隙锁。

理解 InnoDB 的加锁规则,才能从根源上避免长事务 + 普通索引组合带来的并发锁风险。

AI 快速理解版:问题结构化摘要

复制代码
【文档类型】MySQL InnoDB 锁问题故障排查与解决方案汇总
【核心报错】com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
【报错语句】INSERT INTO INDX_INFO

【环境信息】
1. 表名:INDX_INFO
2. 索引:
   - 主键索引:PRIMARY (DATAID),全局ID生成,无重复
   - 普通联合二级索引:INDX_INFO_02 (DEL_FLAG, INDX_CODE, TRADE_DATE),非唯一
3. 数据库:
   - 引擎:InnoDB
   - 隔离级别:REPEATABLE READ (RR,默认)
   - innodb_lock_wait_timeout:默认50s
4. 业务场景:
   - 方法A:批处理,@Transactional长事务,按DEL_FLAG+INDX_CODE+TRADE_DATE条件执行UPDATE,后续长时间业务校验不提交
   - 方法B:页面新增,无事务注解,单SQL提交,插入数据的二级索引字段与方法A一致,主键全新

【根因结论】
属于「普通二级索引临键锁(Next-Key Lock)阻塞INSERT」,非主键锁、非唯一索引冲突。
1. RR隔离下,UPDATE走普通二级索引,命中记录时生成Next-Key临键锁(行锁 + 记录后间隙锁)
2. INSERT执行需申请插入意向锁,插入意向锁与间隙锁/临键锁互斥
3. 长事务不提交,临键锁持续持有;新INSERT落入锁区间,阻塞等待超时
4. 特殊场景验证:先DELETE同主键行再INSERT同主键,DELETE按主键可成功,但INSERT仍会阻塞------证明阻塞点在二级索引

【复现关键】
1. 数据库配置:set innodb_lock_wait_timeout=5;确认隔离级别为RR
2. 代码规范:事务方法必须放在独立Service Bean中,通过@Autowired注入调用
3. 失效坑点:同类内部this调用会绕过AOP代理,@Transactional失效,无法复现
4. 数据构造:事务A按二级索引条件update后sleep;事务B插入相同二级索引值、不同主键的记录

【验证方法】
长事务持锁期间执行:
SELECT OBJECT_SCHEMA,OBJECT_NAME,INDEX_NAME,LOCK_MODE,LOCK_DATA FROM performance_schema.data_locks;
预期:INDEX_NAME为目标二级索引,LOCK_MODE=X,Next-Key

【间隙锁触发条件】
1. 基础前提:InnoDB引擎 + RR隔离级别 + 当前读(UPDATE/DELETE/加锁SELECT) + 执行计划走索引
2. 索引规则:
   - 唯一索引等值命中:仅行锁,无间隙锁
   - 普通二级索引等值命中:临键锁,带间隙锁
   - 未命中记录:纯间隙锁
   - 范围查询:临键锁
3. INSERT本身不产生间隙锁,只申请插入意向锁,是被阻塞方

【解决方案(优先级从高到低)】
1. 缩小事务边界:耗时业务逻辑移出事务,缩短锁持有时间(根治首选)
2. 改走主键/唯一索引更新:先查主键再按主键更新,仅行锁,消除间隙锁
3. 调整隔离级别为RC:关闭间隙锁,需评估幻读业务风险
4. 业务错开索引区间:临时应急,不推荐长期使用

【常见误区】
- 误区1:索引失效可以避免间隙锁 → 事实:全表扫描会导致全表加临键锁,问题更严重
- 误区2:主键不重复INSERT就不会阻塞 → 事实:二级索引间隙锁依然会阻塞插入
- 误区3:INSERT会产生间隙锁 → 事实:INSERT只产生插入意向锁,间隙锁由UPDATE/DELETE/加锁查产生

【同类常见锁场景】
1. 主键行锁冲突:操作同一主键,阻塞在UPDATE/DELETE
2. 唯一索引插入等待:插入相同唯一索引值
3. 二级索引临键锁阻塞INSERT:本文核心场景
4. 死锁:两事务循环等待锁,InnoDB主动回滚一个
5. 全表扫描锁:无索引导致大面积加锁,近似表锁

【适用排查方向】
MySQL并发写入超时、长事务引发锁问题、InnoDB锁机制原理、二级索引性能与并发优化
相关推荐
yuniko-n1 小时前
【JUC】JMM(Java 内存模型)
java·开发语言
Gh0st_Lx2 小时前
【2】MySQL 为什么使用 B+树
数据库·b树·mysql
我不会插花弄玉2 小时前
8. 复合查询【由浅入深-MySQL】
android·数据库·mysql
专业程序开发源2 小时前
springboot校企合作双选会一站式预约与面试系统16010-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·面试·php·课程设计
北风toto2 小时前
数据库模式分解:如何判定“保持函数依赖”?
数据库
郑州光合科技余经理2 小时前
同城商城源码:商品库和订单库怎么随包交付
android·java·开发语言·后端·uni-app·php·ai编程
\光辉岁月/2 小时前
4.String类
java·面向对象·stringbuilder·string·stringbuffer·字符串类
极限挑战哥2 小时前
深度拆解团购多商户分账系统:规则引擎 + 账户体系 + 逆向清算全解析
网络·数据库·人工智能
数据狐(Datafox)3 小时前
一文读懂京东商品详情 API:返回字段解读与 JSON 样例
开发语言·数据库·爬虫·json