MySQL RR和RC隔离级别下MVCC的行为差异及幻读问题解析

落日邮差
落日邮差
发布时间:2026-09-20| 7人浏览过
原创
在 MySQL 的 InnoDB 存储引擎中,多版本并发控制(MVCC)是兼顾读写并发性能的核心机制。RC(读已提交)和 RR(可重复读)隔离级别的根本差异,在于生成一致性视图(ReadView)的时机与复用策略。本文将通过底层结构解析、执行流程拆解、对照表格与典型复现案例,彻底厘清 RC 与 RR 下 MVCC 的运作分歧,并明确说明 RR 是如何在快照读与当前读两种模式下分别处理幻读问题的。

一、InnoDB MVCC 的底层结构支撑

MVCC 的基本目的,是在并发事务操作同一行记录时,让读操作不加锁即可读取到符合当前事务可见性要求的数据版本。为了实现这一点,InnoDB 在每一行聚簇索引记录上隐式增加了两个核心系统字段,并通过 Undo Log 串联起历史版本:

  • DB_TRX_ID:6 字节,记录创建或最后一次修改该行记录的事务 ID。
  • DB_ROLL_PTR:7 字节,回滚指针,指向写入 Undo Log 的上一版本历史镜像。

当一行记录被多次并发或连续修改时,旧版本数据不会直接在磁盘中被覆盖,而是通过 DB_ROLL_PTR 形成单向链表,即版本链。读取操作只需根据规则在链上寻找到对自身可见的那个节点即可。

SVG 配图 1
图1:行记录系统字段与 Undo Log 形成的多版本历史链条

二、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,并依次比对以下可见性规则:

  1. 若 DB_TRX_ID == creator_trx_id:说明该版本就是当前事务自己修改的,允许直接访问。
  2. 若 DB_TRX_ID < min_trx_id:说明生成该版本的事务在 ReadView 创建前就已经提交完毕,该版本对当前事务可见。
  3. 若 DB_TRX_ID >= max_trx_id:说明生成该版本的事务是在 ReadView 创建后才启动的,该版本对当前事务不可见。
  4. 若 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 的生命周期与频率。

SVG 配图 2
图2:RC 每次执行 SELECT 重新生成视图,RR 在第一次执行 SELECT 后持续复用

为了具体观察这一行为,假设当前数据库中有一条基础记录:id=1, name='Alice'。我们通过两个会话的交替执行进行对照验证:

-- 准备基础表
CREATE TABLE t_user (
  id INT PRIMARY KEY,
  name VARCHAR(20)
) ENGINE=InnoDB;
INSERT INTO t_user VALUES (1, 'Alice');

行为推导步骤对比

  1. 会话 A 开启事务并执行初次读取:
    BEGIN;
    SELECT * FROM t_user WHERE id = 1; -- 结果为 Alice
    此时无论是 RC 还是 RR,会话 A 都会生成一个 ReadView。
  2. 会话 B 并发介入并提交事务:
    BEGIN;
    UPDATE t_user SET name = 'Bob' WHERE id = 1;
    COMMIT;
    此时版本链增加了新版本 name = 'Bob',该版本的 DB_TRX_ID 对应的事务已经处于已提交状态。
  3. 会话 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(实现了可重复读)。

四、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)的组合。

SVG 配图 3
图3:Next-Key Lock 锁住记录本身及其前置开区间,阻断并发插入
注意锁定边界:在唯一索引的等值查询中,如果查询记录存在,Next-Key Lock 会退化为记录锁;如果记录不存在,则退化为间隙锁。而在非唯一索引或范围查询中,锁将严格维持 Next-Key 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

六、总结与生产实践建议

SVG 配图 4
图4:MVCC 差异与幻读解决方案总结

归纳而言,在生产系统中:

  • 若追求极高写入并发且死锁率低:业界(包括绝大多数互联网企业)更倾向于将默认的 REPEATABLE-READ 调整为 READ-COMMITTED,并强制配合 binlog_format=ROW。这能消除间隙锁带来的大范围阻塞与死锁问题。
  • 若业务严重依赖事务内部的完全快照一致性:保持默认的 REPEATABLE-READ。在编写业务代码时,务必避免在事务前半段使用普通的快照读,后半段又转为当前读或带范围的 UPDATE。若业务逻辑确实需要确保绝对无幻读并加锁排他,应在事务一开启时便直接使用 SELECT ... FOR UPDATE 显示锁定目标区间。
相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。