MySQL RR和RC隔离级别下MVCC的行为差异及幻读问题解析
一、InnoDB MVCC 的底层结构支撑
MVCC 的基本目的,是在并发事务操作同一行记录时,让读操作不加锁即可读取到符合当前事务可见性要求的数据版本。为了实现这一点,InnoDB 在每一行聚簇索引记录上隐式增加了两个核心系统字段,并通过 Undo Log 串联起历史版本:
- DB_TRX_ID:6 字节,记录创建或最后一次修改该行记录的事务 ID。
- DB_ROLL_PTR:7 字节,回滚指针,指向写入 Undo Log 的上一版本历史镜像。
当一行记录被多次并发或连续修改时,旧版本数据不会直接在磁盘中被覆盖,而是通过 DB_ROLL_PTR 形成单向链表,即版本链。读取操作只需根据规则在链上寻找到对自身可见的那个节点即可。
二、ReadView 结构与版本可见性判定规则
当事务执行普通的快照读(如普通的 SELECT)时,会构造一个名为 ReadView 的内存结构。ReadView 包含四个核心关键字段:
m_ids:创建该 ReadView 时,系统内当前正在活跃(未提交)的读写事务 ID 列表。min_trx_id:创建该 ReadView 时,当前活跃事务列表中的最小事务 ID,即m_ids中的最小值。max_trx_id:创建该 ReadView 时,系统应该分配给下一个事务的全局自增 ID(注意:并非m_ids的最大值,而是系统下一个预分配 ID)。creator_trx_id:生成该 ReadView 的当前事务自身的事务 ID。
当一个事务访问某行记录的版本时,会读取该版本的 DB_TRX_ID,并依次比对以下可见性规则:
- 若
DB_TRX_ID == creator_trx_id:说明该版本就是当前事务自己修改的,允许直接访问。 - 若
DB_TRX_ID < min_trx_id:说明生成该版本的事务在 ReadView 创建前就已经提交完毕,该版本对当前事务可见。 - 若
DB_TRX_ID >= max_trx_id:说明生成该版本的事务是在 ReadView 创建后才启动的,该版本对当前事务不可见。 - 若
min_trx_id <= DB_TRX_ID < max_trx_id:此时需要检查DB_TRX_ID是否存在于m_ids列表中:- 如果在列表中:说明生成该版本的事务仍处于活跃未提交状态,因此该版本不可见。
- 如果不在列表中:说明在 ReadView 生成前,该事务已经提交完成,因此该版本可见。
如果某个版本经判定为不可见,系统便沿 DB_ROLL_PTR 追溯上一个 Undo 历史版本,重复上述判断,直到找到第一个可见版本,或者因查到链尾而认定该记录对当前事务不存在。
三、RC 与 RR 隔离级别下 MVCC 的行为差异
RC(Read Committed)与 RR(Repeatable Read)底层的多版本链与判定公式完全一样,唯一的本质区别在于生成 ReadView 的生命周期与频率。
为了具体观察这一行为,假设当前数据库中有一条基础记录:id=1, name='Alice'。我们通过两个会话的交替执行进行对照验证:
-- 准备基础表
CREATE TABLE t_user (
id INT PRIMARY KEY,
name VARCHAR(20)
) ENGINE=InnoDB;
INSERT INTO t_user VALUES (1, 'Alice');
行为推导步骤对比
-
会话 A 开启事务并执行初次读取:
此时无论是 RC 还是 RR,会话 A 都会生成一个 ReadView。BEGIN; SELECT * FROM t_user WHERE id = 1; -- 结果为 Alice -
会话 B 并发介入并提交事务:
此时版本链增加了新版本BEGIN; UPDATE t_user SET name = 'Bob' WHERE id = 1; COMMIT;name = 'Bob',该版本的DB_TRX_ID对应的事务已经处于已提交状态。 -
会话 A 再次执行相同的快照读:
SELECT * FROM t_user WHERE id = 1;- RC 模式下的表现:查询结果返回
Bob。因为会话 A 重新生成了 ReadView,会话 B 的事务 ID 已不在当前的m_ids中,符合可见性规则,因此会话 A 能读到刚刚提交的数据(出现不可重复读)。 - RR 模式下的表现:查询结果仍为
Alice。会话 A 沿用事务开始时的 ReadView,在该视图生成之时会话 B 仍处于活跃状态或未启动,因此新版本的DB_TRX_ID对会话 A 不可见,系统通过 Undo Log 回溯出历史镜像Alice(实现了可重复读)。
- RC 模式下的表现:查询结果返回
四、RR 级别下的幻读问题全面解析
幻读是指在同一个事务内,多次使用相同的查询条件读取数据时,后一次查询发现读到了之前没有出现过的新增行(由其他并发事务插入并提交)。许多开发者容易将幻读与不可重复读混淆:不可重复读针对的是记录的更新与删除,幻读特指新记录的插入。
要准确理解 MySQL RR 是否彻底解决了幻读,必须将查询划分为快照读与当前读。
1. 快照读对幻读的处理:依赖 MVCC 视图隔离
普通的 SELECT ... FROM ... WHERE ... 均为快照读。在 RR 隔离级别下,由于事务始终复用第一次查询生成的 ReadView,即使并发事务插入并提交了新的数据行,该新行记录的 DB_TRX_ID 一定大于该 ReadView 的 max_trx_id。根据判定规则,新增的整行数据对当前事务直接不可见,因此普通的快照读不会出现幻读。
2. 当前读对幻读的处理:依赖 Next-Key Locks
以下查询均属于当前读,它们要求读取记录的最新已提交版本,并通过加锁防止并发修改:
SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE; -- 8.0亦可写作 FOR SHARE
INSERT INTO ...;
UPDATE ...;
DELETE ...;
在当前读场景下,MVCC 的 ReadView 机制直接失效,因为操作必须拿到当前数据的真实物理状态。为了防止其他事务在此期间插入满足条件的数据行导致幻读,InnoDB 引入了 Next-Key Locks(临键锁),即记录锁(Record Lock)与间隙锁(Gap Lock)的组合。
3. RR 并没有 100% 避免幻读的特殊反例
尽管在纯快照读或纯当前读下幻读都有对应防护,但在快照读与当前读混用的特定边界下,RR 仍然会出现幻读现象。
反例一:通过 UPDATE 触发的快照读“穿透”
-- 初始表状态:仅包含 id = 1
-- 事务 A 启动并快照读
[事务 A] BEGIN;
[事务 A] SELECT * FROM t_user WHERE id = 5;
-- 结果为空,ReadView 生成
-- 事务 B 插入记录并提交
[事务 B] BEGIN;
[事务 B] INSERT INTO t_user VALUES (5, 'Charlie');
[事务 B] COMMIT;
-- 事务 A 执行一次范围或等值更新(当前读)
[事务 A] UPDATE t_user SET name = 'David' WHERE id = 5;
-- 影响行数:1!(UPDATE是当前读,直接定位并修改了事务B插入的数据)
-- 该记录的 DB_TRX_ID 被重写为事务 A 自己的事务 ID
-- 事务 A 再次执行快照读
[事务 A] SELECT * FROM t_user WHERE id = 5;
-- 此时成功读出了 id=5, name='David' 的数据!
原因解析:UPDATE 操作采用当前读,直接修改了磁盘上已提交的物理行,并把该行的 DB_TRX_ID 改成了事务 A 自身。根据可见性规则第一条,DB_TRX_ID == creator_trx_id 永远可见。因此随后的快照读打破了原本看不见的限制,凭空“幻化”出一行记录。
反例二:先快照读、后当前读引发的幻行
事务在执行之初使用 SELECT ... 快照读,未对扫描范围加锁。中途并发事务插入并提交了新行,之后事务如果再执行 SELECT ... FOR UPDATE,当前读会穿透快照直接按最新物理数据获取记录,从而立即返回包含了并发事务插入的行,破坏了前后结果的一致性。
五、RC 与 RR 的核心技术特征对比
在数据库选型和参数调优时,选择 RC 还是 RR 通常不是随意的,而是需要权衡并发开销与数据一致性:
| 对比维度 | 读已提交(RC) | 可重复读(RR) |
|---|---|---|
| ReadView 生成机制 | 每次执行普通查询时都新建一次视图 | 事务内首次执行查询时创建,后续一直复用 |
| 快照读能否防幻读 | 不能(由于重新生成视图,看到新提交的行) | 能(复用首次视图,新增行在其版本之后) |
| 间隙锁与 Next-Key Lock | 默认关闭(除外键检查和重复键检查外) | 默认开启,用于当前读防护幻读与范围一致性 |
| 死锁发生概率 | 较低(只锁记录,不锁间隙) | 相对较高(间隙锁易发生范围交叉与死锁) |
| binlog 格式要求 | 必须使用 ROW 格式保证主从一致 |
支持 STATEMENT、MIXED 或 ROW |
六、总结与生产实践建议
归纳而言,在生产系统中:
- 若追求极高写入并发且死锁率低:业界(包括绝大多数互联网企业)更倾向于将默认的
REPEATABLE-READ调整为READ-COMMITTED,并强制配合binlog_format=ROW。这能消除间隙锁带来的大范围阻塞与死锁问题。 - 若业务严重依赖事务内部的完全快照一致性:保持默认的
REPEATABLE-READ。在编写业务代码时,务必避免在事务前半段使用普通的快照读,后半段又转为当前读或带范围的UPDATE。若业务逻辑确实需要确保绝对无幻读并加锁排他,应在事务一开启时便直接使用SELECT ... FOR UPDATE显示锁定目标区间。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









