引言:为什么需要「可重复读」?
在数据库的事务世界里,「一致性」是永恒的主题。想象一个电商场景:
你正用事务A查询某商品的库存(SELECT stock FROM product WHERE id=1
),得到库存为100;
与此同时,事务B偷偷修改了库存(UPDATE product SET stock=90 WHERE id=1
)并提交;
如果事务A再次查询,发现库存变成了90——这就是「不可重复读」,同一事务内两次读结果不一致。
更极端的情况:事务B可能直接插入一条新记录(比如id=2
的库存记录),事务A的范围查询(SELECT * FROM product WHERE id>0
)会突然多出一条数据,这就是「幻读」。
MySQL的InnoDB存储引擎中,可重复读(Repeatable Read, RR) 隔离级别正是为解决这类问题而生。它能让事务A「屏蔽」其他事务的干扰,两次读结果始终一致,仿佛「冻结」了某个时间点的数据状态。
今天,我们就来拆开RR的「黑箱」,看InnoDB如何用 MVCC(多版本并发控制) 和 临键锁(Next-Key Lock) 这对「黄金组合」,实现高并发下的一致性。
一、MVCC:让读操作「看见」过去——解决脏读&不可重复读
1.1 脏读和不可重复读的本质
- 脏读:读到了其他事务未提交的「临时数据」(比如事务B改了库存但没提交,事务A读到了90)。
- 不可重复读:读到了其他事务已提交的「新数据」(比如事务B提交了库存修改,事务A第二次读到了90)。
传统数据库的「读已提交(RC)」隔离级别能解决脏读(每次读都读最新提交的数据),但无法解决不可重复读——因为每次读都会获取最新快照。
而InnoDB的RR要更强:同一事务内所有读操作,都基于同一个「历史快照」,不管其他事务是否提交新数据,这个快照都不会变。
1.2 MVCC的核心:Undo Log + 一致性视图
1.2.1 Undo Log:数据的「时光机」
InnoDB的MVCC依赖 Undo Log
(回滚日志)来保存数据的历史版本。当你更新一行数据时,InnoDB不会直接覆盖旧数据,而是:
- 将旧数据复制到Undo Log;
- 用新数据覆盖当前页;
- 在旧数据中添加「回滚指针」,指向更早的版本(形成版本链)。
举个栗子:
初始数据 stock=100
→ 事务B更新为 stock=90
→ Undo Log保存旧版本 stock=100
,并通过指针指向它。
此时,当前数据页是 stock=90
,但Undo Log里藏着 stock=100
的「历史版本」。
1.2.2 一致性视图:事务的「时间滤镜」
每个事务启动时,InnoDB会为它生成一个 一致性视图(Consistent Read View),本质是一个「事务ID的集合」。它的作用是:判断哪些数据版本对当前事务可见。
具体规则:
- 如果数据版本的提交事务ID < 当前事务的一致性视图的最小ID → 该版本在事务启动前已提交,可见;
- 如果数据版本的提交事务ID > 当前事务的一致性视图的最大ID → 该版本在事务启动后提交,不可见(需回溯到Undo Log找更早版本);
- 如果数据版本的提交事务ID在一致性视图的ID范围内 → 说明是当前事务自己或未提交的事务修改的,不可见(未提交的要回滚,自己的能看到)。
1.3 效果验证:RR如何避免脏读&不可重复读
假设事务A(ID=100)和事务B(ID=200)同时操作:
时间点 | 事务A操作 | 事务B操作 | 说明 |
---|---|---|---|
T1 | BEGIN(生成一致性视图,假设当前最大事务ID=150) | BEGIN(生成一致性视图,最大事务ID=150) | 事务A的视图认为「提交ID≤150的数据可见」 |
T2 | SELECT stock FROM product WHERE id=1 | 读当前数据页的stock=100(假设初始提交ID=50≤150,可见) | |
T3 | UPDATE product SET stock=90 WHERE id=1(未提交) | 修改当前数据页为90,Undo Log保存旧版本stock=100(提交ID=200) | |
T4 | SELECT stock FROM product WHERE id=1 | 事务A的视图最大ID=150,新版本提交ID=200>150 → 不可见,仍读Undo Log的100 | |
T5 | COMMIT(提交事务B) | 事务B的提交ID=200被记录到全局事务列表 | |
T6 | SELECT stock FROM product WHERE id=1 | 事务A的视图最大ID还是150(已启动),200>150 → 仍读Undo Log的100 |
可以看到,事务A两次读的结果都是100,完美避免了脏读和不可重复读!
二、临键锁:幻读的终结者——解决「插入/删除导致的行数变化」
2.1 幻读的场景:范围查询的「意外收获」
RR虽然解决了脏读和不可重复读,但最初版本(MySQL 5.0前)对幻读无能为力。举个经典例子:
- 事务A执行
SELECT * FROM product WHERE id>100 FOR UPDATE
(锁定id>100的记录),得到结果[101, 102]
; - 事务B插入一条
id=103
的记录并提交; - 事务A再次执行同样的查询,得到
[101, 102, 103]
(幻读)。
这是因为,普通的行锁只能锁定已存在的记录,无法阻止其他事务在「间隙」插入新数据。
2.2 临键锁:行锁+间隙锁的「组合拳」
InnoDB在RR隔离级别下,默认使用 临键锁(Next-Key Lock) 来解决幻读。它是 行锁(锁定具体记录) 和 间隙锁(锁定记录前的间隙) 的组合,锁定范围是 (左边界, 右边界]
。
2.2.1 间隙(Gap):索引的「空白区域」
假设索引值为 [100, 200, 300]
,那么间隙是:
(-∞, 100]
(100, 200]
(200, 300]
(300, +∞)
2.2.2 临键锁的锁定范围
当事务A查询 id>100
并加锁时,InnoDB会找到第一个符合条件的记录(比如id=101),然后锁定 (100, 101]
这个区间。后续事务B想插入 id=102
(属于这个区间),会被阻塞,直到事务A提交。
2.2.3 效果验证:幻读被「锁死」
回到之前的例子:
- 事务A执行
SELECT * FROM product WHERE id>100 FOR UPDATE
,找到id=101和102; - InnoDB会锁定
(100, 101]
、(101, 102]
、(102, +∞)
这三个区间(具体取决于索引分布); - 事务B尝试插入
id=103
(属于(102, +∞)
区间),会被临键锁阻塞,无法提交; - 事务A再次查询,结果仍是
[101, 102]
,幻读消失!
三、实战警告:无索引时,临键锁会「失控」
临键锁的威力依赖于 索引。如果查询条件没有使用索引(比如 WHERE name='张三'
但name字段无索引),InnoDB会怎么做?
此时,MySQL无法通过索引快速定位记录,会退化为 表级锁(或更粗粒度的锁),导致:
- 所有对该表的操作(包括查询、插入、更新)都被阻塞;
- 性能急剧下降(尤其在高并发场景);
- 临键锁的间隙锁范围扩大到整个表,可能引发大量锁等待。
四、总结:RR的「鱼与熊掌」
InnoDB的RR隔离级别通过 MVCC(解决脏读、不可重复读) 和 临键锁(解决幻读) 的组合,实现了高性能与强一致性的平衡:
机制 | 作用 | 解决的问题 |
---|---|---|
MVCC(Undo Log+一致性视图) | 基于历史快照读数据,避免读最新提交 | 脏读、不可重复读 |
临键锁(行锁+间隙锁) | 锁定索引范围,阻止其他事务插入新数据 | 幻读 |
注意:实际开发中,一定要确保查询条件使用索引(尤其是范围查询),否则临键锁会退化为表锁,严重影响性能!
最后:RR是MySQL最常用的隔离级别(默认级别),理解其原理能帮我们更好地优化事务逻辑,避免「锁冲突」和「数据不一致」的坑~ 下次遇到RR相关的问题,不妨想想:这是MVCC在起作用,还是临键锁在守护一致性?