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 排除常见原因
- 排除主键行锁冲突:如果是主键锁冲突,阻塞应发生在 UPDATE/DELETE 同主键行的阶段;本次报错在 INSERT 阶段,且主键全新不重复,因此排除。
- 排除唯一索引冲突等待:该联合索引为普通索引,非唯一索引,不存在唯一键校验等待。
- 特殊场景验证:若方法 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 完整阻塞链路
- 方法 A 开启事务,执行 UPDATE,WHERE 条件走普通联合二级索引。
- InnoDB 在该二级索引上生成 Next-Key 临键锁,锁住目标索引记录 + 该记录后面的索引间隙。
- 方法 A 进入长时间业务校验逻辑,事务不提交,临键锁持续持有。
- 方法 B 执行 INSERT,新记录的二级索引字段值,刚好落在临键锁的间隙范围内。
- INSERT 申请插入意向锁,与方法 A 持有的临键锁互斥,进入阻塞等待。
- 等待时长超过
innodb_lock_wait_timeout,抛出锁等待超时异常。
4. 本地复现完整教程
4.1 前置准备
-
确认数据库隔离级别为 RR:
SELECT @@transaction_isolation;
-
调小锁等待超时,加速复现(一般默认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 必须同时满足的基础前提
- 存储引擎 = InnoDB:MyISAM 只有表锁,无行锁 / 间隙锁概念。
- 隔离级别 = RR:RC 隔离级别下 InnoDB 关闭了间隙锁,业务层面几乎不会出现。
- SQL 为当前读 :
SELECT ... FOR UPDATE、UPDATE、DELETE;普通 SELECT 快照读永远不会产生间隙锁。 - 执行计划走索引:间隙锁加在索引树的间隙上;全表扫描会导致全表所有间隙都被加锁,锁范围极大。
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 依然会被阻塞,很容易误导排查方向。
解决这类问题的核心思路有两个维度:
- 时间维度:缩小事务边界,缩短锁的持有时间。
- 空间维度:让更新走唯一索引,缩小锁的范围,消除间隙锁。
理解 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锁机制原理、二级索引性能与并发优化