事务与并发(ACID / 隔离级别 / 锁 / MVCC) · MVCC 多版本并发控制
RC 和 RR 隔离级别在 Read View 创建时机上有何不同?
What is the difference in Read View creation timing between the RC and RR isolation levels?
答案语言
考察要点
这道题面试官想看你是否深入理解 InnoDB 的 MVCC (多版本并发控制) 实现机制。核心考察点在于:
- Read View 的概念:你是否知道 Read View 是什么,它的内部结构和工作原理。
- 隔离级别的实现:你是否明白
Read Committed(RC) 和Repeatable Read(RR) 这两种隔离级别并非通过简单的加锁,而是通过控制 Read View 的生成时机来实现的。 - 并发场景推演:你能否基于不同的 Read View 创建策略,准确推演出在 RC 和 RR 级别下,一个事务内多次执行
SELECT会看到什么不同的结果,并解释其背后的原因(如不可重复读和幻读)。
核心概念
MVCC 是 InnoDB 为实现高并发事务处理而设计的核心机制,它允许读写操作同时进行而互不阻塞(在“快照读”场景下)。其实现依赖于三个关键元素:隐藏列、Undo Log 和 Read View。
- 隐藏列:每行数据除了我们定义的列外,还有两个重要的隐藏列:
DB_TRX_ID:创建或最后修改该行版本的事务 ID。DB_ROLL_PTR:回滚指针,指向该行上一个版本的Undo Log记录。
Undo Log:当数据被修改时,旧版本的数据会被记录到Undo Log中,形成一个版本链。mermaid1graph LR2 subgraph Undo Log Version Chain3 direction LR4 Row_v3(Row Version 3<br>trx_id=100) -->|roll_ptr| Row_v2(Row Version 2<br>trx_id=95);5 Row_v2 -->|roll_ptr| Row_v1(Row Version 1<br>trx_id=80);6 end- Read View (一致性视图):是事务在执行快照读时,对整个数据库在某一时刻的“快照”。它决定了事务能看到哪个版本的数据。其主要构成如下:
m_ids:创建 Read View 时,所有活跃(未提交)事务的 ID 列表。min_trx_id:m_ids列表中的最小事务 ID。max_trx_id:创建 Read View 时,系统下一个将要分配的事务 ID。creator_trx_id:创建该 Read View 的事务自身的 ID。
可见性判断规则:当一个事务访问某行数据时,会获取其最新的版本,并用该版本的 DB_TRX_ID 与当前事务的 Read View 进行比较:
- 如果
DB_TRX_ID==creator_trx_id,说明是本事务自己修改的,可见。 - 如果
DB_TRX_ID<min_trx_id,说明在创建 Read View 时,修改该行的事务已经提交,可见。 - 如果
DB_TRX_ID>=max_trx_id,说明在创建 Read View 后,才有新事务修改了该行,不可见。需沿着DB_ROLL_PTR找上一个版本。 - 如果
min_trx_id<=DB_TRX_ID<max_trx_id,则检查DB_TRX_ID是否在m_ids列表中:- 若在,说明修改该行的事务在创建 Read View 时仍活跃,不可见。需找上一个版本。
- 若不在,说明修改该行的事务在创建 Read View 时已提交,可见。
RC 和 RR 的核心区别:Read View 创建时机
1. Read Committed (RC) 读已提交
在 RC 级别下,事务中每一条 SELECT 语句执行时,都会重新创建一个新的 Read View。
示例场景:
假设 accounts 表有一行数据 (id=1, balance=1000)。
| 时间 | 事务 A (Session A, RC 级别) | 事务 B (Session B) |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT balance FROM accounts WHERE id = 1; <br> 结果: 1000 <br> (创建 Read View 1) | |
| T3 | START TRANSACTION; <br> UPDATE accounts SET balance = 1200 WHERE id = 1; <br> COMMIT; | |
| T4 | SELECT balance FROM accounts WHERE id = 1; <br> 结果: 1200 <br> (创建新的 Read View 2) | |
| T5 | COMMIT; |
分析:
- 在 T2,事务 A 的
SELECT创建了 Read View 1。此时事务 B 未开始,所以能看到balance=1000。 - 在 T4,事务 A 再次执行
SELECT。因为是 RC 级别,它会重新创建一个 Read View 2。在创建 Read View 2 时,事务 B 已经提交,因此balance=1200这个版本对 Read View 2 是可见的。 - 结果就是事务 A 在同一个事务内两次读取同一行数据,得到了不同的结果,这就是不可重复读。
2. Repeatable Read (RR) 可重复读
在 RR 级别下,只有在事务中的第一条 SELECT 语句执行时,才会创建一个 Read View,并且整个事务期间都会复用这个 Read View。
示例场景(同上):
| 时间 | 事务 A (Session A, RR 级别) | 事务 B (Session B) |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT balance FROM accounts WHERE id = 1; <br> 结果: 1000 <br> (创建 Read View 1,并复用) | |
| T3 | START TRANSACTION; <br> UPDATE accounts SET balance = 1200 WHERE id = 1; <br> COMMIT; | |
| T4 | SELECT balance FROM accounts WHERE id = 1; <br> 结果: 1000 <br> (复用 T2 创建的 Read View 1) | |
| T5 | COMMIT; |
分析:
- 在 T2,事务 A 的
SELECT创建了 Read View 1。 - 在 T4,事务 A 再次执行
SELECT。因为是 RR 级别,它会复用 T2 创建的 Read View 1。根据 Read View 1 的可见性规则,事务 B 的修改(trx_id在 Read View 1 创建时还不存在或处于活跃状态)是不可见的。事务 A 只能看到balance=1000这个版本。 - 结果就是事务 A 在同一个事务内多次读取同一行数据,得到的结果总是一致的,实现了可重复读。
常见低分回答
- 混淆隔离级别与锁的类型:“RC 用行锁,RR 用表锁”。这是一个非常常见的错误。RC 和 RR 在
UPDATE/DELETE时都使用行级锁。它们在快照读(普通SELECT)上的区别主要源于MVCC和 Read View 策略,而非锁的粒度。RR 只是在特定条件下(为了解决幻读)会额外使用间隙锁(Gap Lock)。 - 对“读最新”的模糊描述:“RC 总是能读到最新的数据”。这种说法不精确。RC 读到的是“执行
SELECT语句时,已经提交的最新版本”,而不是数据库物理上绝对最新的版本。如果一个长事务正在修改数据但未提交,RC 也是读不到的。 - 认为 RR 完全解决了幻读:“RR 级别下绝对不会有幻读”。这个说法不完全正确。RR 通过
MVCC机制避免了在“快照读”(SELECT)场景下的幻读。但在“当前读”(SELECT ... FOR UPDATE,SELECT ... LOCK IN SHARE MODE,INSERT,UPDATE,DELETE)场景下,为了避免幻读,InnoDB需要借助Next-Key Lock(Record Lock+Gap Lock)来实现。如果隔离级别设置不当或 SQL 写法特殊,仍可能遇到幻读问题。 - 无法解释 Read View 的构成:只知道“RC 每次都创建快照,RR 只创建一次”,但当被追问“快照是什么,如何判断数据可见性”时,无法回答 Read View 的
m_ids,min_trx_id,max_trx_id等内部细节和判断逻辑。这表明理解停留在表面。
工程实践与优化
- 隔离级别的选择:MySQL 默认隔离级别是 RR,主要是为了保证主从复制(基于 statement 的 binlog)的数据一致性。但在现代基于 row 格式的 binlog 复制下,这个限制已不存在。许多互联网公司(如阿里)的核心业务数据库会主动将默认隔离级别设置为 RC。
- 为什么用 RC:在 RC 级别下,事务每次
SELECT都能读到已提交的最新数据,符合大多数业务直觉。更重要的是,RC 下不存在间隙锁(Gap Lock),只对扫描到的行加记录锁(Record Lock),这能极大减少锁冲突,提高并发性能。 - 什么时候用 RR:当业务逻辑强依赖“事务期间数据快照必须保持绝对一致”时,例如需要进行一系列复杂计算和数据核对的后台批量任务,RR 提供了更强的一致性保证。
- 为什么用 RC:在 RC 级别下,事务每次
- RR 级别下的长事务风险:在 RR 级别下,一个长事务(尤其是只读长事务)会导致其创建的 Read View 非常老。这会阻止
purge线程回收该 Read View 创建之后产生的大量undo log,可能导致undo表空间急剧膨胀,耗尽磁盘空间,并严重影响数据库性能。- 监控:应通过
information_schema.innodb_trx监控长事务,并及时处理。 - 优化:将长查询、数据分析、批量导出等任务放到只读从库执行,或在业务低峰期执行,避免在主库上产生长事务。
- 监控:应通过
- 执行计划分析:
EXPLAIN无法直接展示 Read View 的行为。但理解 Read View 机制有助于你推断在并发场景下,为何一条看起来走了索引的简单SELECT语句返回的是“旧”数据,从而避免误判为是索引或 SQL 的问题。
常见追问与延伸题目
-
追问:RR 是如何解决幻读的?为什么又说它没有“完全”解决?
- 回答要点 1 (快照读):对于普通
SELECT语句(快照读),RR 通过复用事务开始时的 Read View 来解决幻读。因为 Read View 是固定的,在事务期间其他事务所插入并提交的新行,其DB_TRX_ID会大于等于 Read View 的max_trx_id,因此对当前事务是不可见的。 - 回答要点 2 (当前读):对于
SELECT ... FOR UPDATE或UPDATE等当前读操作,MVCC不起作用,必须通过加锁来保证。RR 使用Next-Key Lock(行锁 + 间隙锁)来锁定一个范围,防止其他事务在这个范围内插入新数据,从而避免了当前读场景下的幻读。 - 回答要点 3 (未完全解决的场景):在某些极端情况下,例如一个事务先执行了快照读,然后基于读取的结果去执行当前读(如
UPDATE),可能会出现数据不一致。但最经典的“未完全解决”是指,如果没有正确使用锁,或者在特定并发序列下,应用层面仍可能感知到“幻象”。但从数据库层面,InnoDB的 RR 已经通过MVCC和Next-Key Lock提供了非常强的幻读防护。
- 回答要点 1 (快照读):对于普通
-
追问:一个在 RR 级别下的长事务会引发什么具体问题?如何排查和避免?
- 回答要点 1 (问题):主要问题是
Undo Log积压。由于长事务持有一个旧的 Read View,系统无法清理在该 Read View 之后产生的Undo Log版本链。这会导致:1) Undo 表空间持续增大,可能耗尽磁盘。2) 其他查询在需要访问旧版本数据时,需要遍历很长的版本链,导致查询性能下降。 - 回答要点 2 (排查):可以通过查询
information_schema.innodb_trx表来定位运行时间过长的事务。关注trx_started和trx_state字段,找出长时间处于RUNNING状态的事务及其执行的 SQL (trx_query)。 - 回答要点 3 (避免):1) 业务拆分:避免在
OLTP系统中执行长时间的数据分析或批量处理任务。2) 读写分离:将这类长查询任务迁移到只读从库。3) 设置超时:为事务设置合理的执行超时时间,例如使用max_execution_time参数(MySQL 8.0+)或应用层面的超时控制。4) 优化 SQL:确保事务内的 SQL 都是高效的,避免全表扫描等慢查询。
- 回答要点 1 (问题):主要问题是
-
追问:如果我把隔离级别设为 RC,但某个业务场景又需要可重复读,该怎么办?
- 回答要点 1 (悲观锁):在事务开始时,对需要读取和操作的数据行显式使用
SELECT ... FOR UPDATE。这会给这些行加上排他锁,阻止其他事务修改它们,直到当前事务提交。这样就以牺牲并发为代价,在 RC 级别手动实现了对特定数据的“可重复读”和“防更新”。 - 回答要点 2 (乐观锁):在表中增加一个
version字段。读取数据时一并读出version值。更新时,UPDATE语句的WHERE条件中要包含AND version = old_version。如果更新影响的行数为 0,说明数据已被其他事务修改,此时应用层面可以进行重试或抛出异常。 - 回答要点 3 (临时设置):可以在事务级别动态修改隔离级别:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;,执行需要 RR 保证的业务逻辑,然后COMMIT。这样只在需要时才使用 RR,对系统的整体影响较小。
- 回答要点 1 (悲观锁):在事务开始时,对需要读取和操作的数据行显式使用