在高并发MySQL场景中,锁竞争往往是导致性能瓶颈的核心原因之一。InnoDB作为MySQL默认的事务存储引擎,其锁机制与事务隔离级别深度绑定------不同隔离级别下,相同查询的加锁范围可能截然不同,直接影响系统的并发能力。本文基于实际实验,详细拆解读已提交(RC) 和可重复读(RR) 两种常用隔离级别下的加锁逻辑,帮助开发者避开锁冲突陷阱。
一、实验基础:测试表结构与核心概念
所有实验基于InnoDB引擎,测试表包含主键索引、唯一索引和普通索引,模拟真实业务中常见的索引场景。以RC隔离级别的实验表t16为例,结构如下(RR实验表t17结构类似,仅数据略有调整):
sql
use martin;
-- 测试表t16(RC隔离级别实验用)
drop table if exists t16;
CREATE TABLE `t16` (
`id` int NOT NULL AUTO_INCREMENT, -- 主键索引(聚集索引)
`a` int NOT NULL, -- 唯一索引(uniq_a)
`b` int NOT NULL, -- 无索引字段
`c` int NOT NULL, -- 普通索引(idx_c,非唯一)
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_a` (`a`) USING BTREE,
KEY `idx_c` (`c`)
) ENGINE=InnoDB CHARSET=utf8mb4;
-- 插入测试数据
insert into t16(a,b,c) values (1,1,1),(2,2,2),(3,3,3),(4,4,3);
核心概念提前明确:
- 当前读 :
select ... for update/select ... lock in share mode属于当前读,会对查询到的记录加锁,确保数据一致性; - 排他锁(X锁) :实验中
for update加的是排他锁,禁止其他事务加X锁或S锁; - 间隙锁(Gap Lock):仅RR隔离级别存在,锁定记录之间的"间隙",防止插入新记录导致幻读。
二、RC(读已提交)隔离级别下的加锁实验
RC是很多互联网业务的默认隔离级别(如电商订单场景),其特点是"只能读取已提交的事务数据",但可能存在幻读。以下通过三个实验拆解其加锁逻辑。
2.1 实验1:非索引字段查询(where b=1)
实验步骤
| session1(事务1) | session2(事务2) |
|---|---|
set session transaction_isolation='READ-COMMITTED'; |
set session transaction_isolation='READ-COMMITTED'; |
begin;(开启事务) |
- |
select * from t16 where b=1 for update;(查询无索引字段b=1) |
- |
| - | select * from t16 where b=2 for update;(等待锁释放) |
commit;(提交事务) |
select * from t16 where b=2 for update;(立即返回结果) |
实验分析
由于b字段无索引,InnoDB无法通过索引快速定位数据,只能走聚集索引(主键索引)全表扫描:
- 存储引擎层会对全表所有记录加排他锁;
- 扫描完成后,将数据返回给Server层,Server层过滤出
b=1的记录,释放其他记录的锁?
不! 实际结果是:全表记录均被加锁 ,直到session1提交。
原因:InnoDB的锁由存储引擎层控制,当条件无法通过索引过滤时,存储引擎会先对所有扫描到的记录加锁,再交给Server层过滤------此时锁已加上,无法释放,导致全表锁冲突。
2.2 实验2:非唯一索引查询(where c=3)
c字段是普通索引(非唯一),测试数据中c=3对应2条记录(id=3、id=4)。
实验步骤
| session1 | session2 | session3 |
|---|---|---|
set session transaction_isolation='READ-COMMITTED'; |
同左 | 同左 |
begin; |
- | - |
select * from t16 where c=3 for update; |
- | - |
| - | select * from t16 where a=1 for update;(正常返回) |
select * from t16 where a=2 for update;(正常返回) |
| - | select * from t16 where a=3 for update;(等待) |
select * from t16 where a=4 for update;(等待) |
commit; |
select * from t16 where a=3 for update;(立即返回) |
select * from t16 where a=4 for update;(立即返回) |
实验分析
非唯一索引的加锁逻辑是"索引覆盖+主键回表":
- 首先对普通索引
idx_c中c=3的所有记录加排他锁; - 通过普通索引的
id值(回表),对聚集索引(主键)中对应的记录(id=3、id=4)加排他锁; - 未涉及的索引记录(如
a=1、a=2对应id=1、id=2)无锁,因此session2、3可正常查询。
结论:RC下非唯一索引查询,仅锁定"索引匹配的记录+对应的主键记录"。
2.3 实验3:唯一索引查询(where a=1)
a字段是唯一索引(uniq_a),唯一索引确保查询结果最多1条。
实验步骤
| session1 | session2 |
|---|---|
set session transaction_isolation='READ-COMMITTED'; |
同左 |
begin; |
- |
select * from t16 where a=1 for update; |
- |
| - | select * from t16 where a=2 for update;(正常返回) |
| - | select * from t16 where a=1 for update;(等待) |
commit; |
select * from t16 where a=1 for update;(立即返回) |
实验分析
唯一索引的加锁范围最小:
- 直接通过唯一索引
uniq_a定位到a=1的记录,加排他锁; - 回表到聚集索引,对
id=1的记录加排他锁; - 其他唯一索引记录(如
a=2)无锁,因此session2可正常查询。
结论:RC下唯一索引查询,仅锁定"唯一索引匹配的记录+对应的主键记录",锁范围最小,并发能力最强。
三、RR(可重复读)隔离级别下的加锁实验
RR是MySQL默认隔离级别,通过间隙锁 解决了快照读的幻读问题,但也可能导致锁范围扩大。以下实验基于表t17(结构与t16一致,数据调整为id=1,2,4,6,避免连续id,突出间隙锁)。
bash
use martin;
drop table if exists t17;
CREATE TABLE `t17` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int NOT NULL,
`b` int NOT NULL,
`c` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_a` (`a`) USING BTREE,
KEY `idx_c` (`c`)
) ENGINE=InnoDB CHARSET=utf8mb4;
insert into t17(id,a,b,c) values (1,1,1,1),(2,2,2,2),(4,4,4,4),(6,6,6,4);
3.1 实验1:非索引字段查询(where b=1)
实验步骤
| session1 | session2 | session3 |
|---|---|---|
set session transaction_isolation='REPEATABLE-READ'; |
同左 | 同左 |
begin; |
- | - |
select * from t17 where b=1 for update; |
- | - |
| - | select * from t17 where b=2 for update;(等待) |
insert into t17(a,b,c) values (10,10,10);(等待) |
commit; |
select * from t17 where b=2 for update;(立即返回) |
insert into t17(a,b,c) values (10,10,10);(立即成功) |
实验分析
RR下非索引字段查询的锁范围是"全表记录锁+全表间隙锁":
- 由于无索引,存储引擎全表扫描,对所有记录加排他锁;
- 同时对所有记录之间的间隙加间隙锁(如id=1~2、2~4、4~6、6之后的间隙);
- session2查询
b=2(对应id=2)时,触发记录锁冲突;session3插入新记录时,触发间隙锁冲突,均需等待。
对比RC:RR下非索引查询的锁范围更大(多了间隙锁),并发能力更弱。
3.2 实验2:非唯一索引查询(where c=4)
c是普通索引,测试数据中c=4对应2条记录(id=4、id=6),且id不连续(存在间隙4~6)。
实验步骤
| session1 | session2 |
|---|---|
set session transaction_isolation='REPEATABLE-READ'; |
同左 |
begin; |
begin; |
| - | select * from t17 where c=4 for update;(锁定c=4的记录) |
insert into t17(a,b,c) values (7,7,4);(等待锁释放) |
- |
| - | commit;(提交事务) |
insert into t17(a,b,c) values (7,7,4);(立即成功) |
- |
实验分析
RR与RC的核心差异:间隙锁的存在。
- session2查询
c=4时,除了对普通索引idx_c中c=4的记录(对应id=4、6)加锁,还会对记录之间的间隙加锁(如c=4对应的id=4~6的间隙); - session1插入
c=4的记录时,需要写入该间隙,触发间隙锁冲突,因此等待; - 间隙锁的目的:防止其他事务插入新记录,导致session2再次查询
c=4时出现"幻读"(记录数增加)。
3.3 实验3:唯一索引查询(where a=1)
实验步骤
| session1 | session2 |
|---|---|
set session transaction_isolation='REPEATABLE-READ'; |
同左 |
begin; |
- |
select * from t17 where a=1 for update; |
- |
| - | select * from t17 where a=2 for update;(正常返回) |
| - | select * from t17 where a=1 for update;(等待) |
commit; |
select * from t17 where a=1 for update;(立即返回) |
实验分析
RR下唯一索引查询无间隙锁:
- 唯一索引确保查询结果唯一,无需通过间隙锁防止幻读(不可能插入相同
a值的记录); - 仅锁定"唯一索引匹配的记录+对应的主键记录",锁范围与RC一致。
结论:RR下唯一索引查询的并发能力与RC相当,是兼顾一致性和性能的最优选择。
四、核心结论与实践建议
通过上述实验,可提炼出InnoDB加锁机制的3条核心规律,以及对应的业务实践建议:
4.1 核心规律
-
无索引时,RC和RR均大范围加锁
- RC:全表记录加排他锁;
- RR:全表记录锁+全表间隙锁(锁范围更大);
本质原因:无索引导致存储引擎全表扫描,无法精准定位数据。
-
RR比RC的锁范围可能更大
- 仅当查询走非唯一索引 或无索引时,RR会额外加间隙锁;
- 唯一索引查询时,RR与RC的锁范围一致(无间隙锁)。
-
唯一索引是"性能与一致性"的平衡点
- 无论RC还是RR,唯一索引查询的锁范围最小(仅锁定匹配记录);
- 唯一索引天然避免幻读(无需间隙锁),兼顾并发性能。
4.2 实践建议
-
务必为查询条件添加索引
尤其是更新/删除语句(
update/delete ... where ...),避免无索引导致全表锁,引发大面积锁冲突。 -
优先使用唯一索引或主键索引
如用户ID、订单号等唯一标识,尽量作为查询条件,最小化锁范围。
-
根据业务场景选择隔离级别
- 高并发读场景(如商品列表):选RC,避免间隙锁导致的锁冲突;
- 强一致性场景(如订单支付):选RR,通过间隙锁确保无幻读,且唯一索引查询性能不受影响。
小结
InnoDB的加锁机制并非"黑盒",其核心逻辑是"索引决定锁范围,隔离级别决定是否加间隙锁"。理解不同场景下的加锁范围,是解决MySQL高并发锁冲突的关键------合理设计索引、选择合适的隔离级别,才能在保证数据一致性的同时,最大化系统的并发能力。