MySQL 事务隔离级别与 MVCC 实现原理
提出问题
MySQL 的事务隔离级别是面试中几乎必问的题目,但很多人的理解停留在"背下来四种隔离级别分别解决了什么问题"的层面。面试官通常不会满足于此——他们会追问:RR 到底是怎么解决不可重复读的?RC 和 RR 的 MVCC 实现有什么区别?为什么说 InnoDB 的 RR 实际上解决了幻读?
这些问题背后其实只有一个核心:MVCC(多版本并发控制)。不理解 MVCC 的具体实现,面试中一追问就露馅。生产上,事务隔离级别选择不当导致的死锁、主从延迟、数据不一致问题比比皆是,选对隔离级别是 DBA 和高级开发的基本功。
分析问题
四种隔离级别及其解决的问题
SQL 标准定义了四种隔离级别,按从低到高排列:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE READ | 避免 | 避免 | 可能 |
| SERIALIZABLE | 避免 | 避免 | 避免 |
- 脏读 (Dirty Read):读到另一个事务未提交的数据。如果该事务回滚,读到的就是"脏数据"。
- 不可重复读 (Non-Repeatable Read):同一事务内两次读取同一行数据,结果不一致(因为其他事务提交了 UPDATE)。
- 幻读 (Phantom Read):同一事务内两次范围查询,结果集的行数不一致(因为其他事务提交了 INSERT 或 DELETE)。
需要注意的是,InnoDB 在 RR 级别下通过 Next-Key Lock 解决了幻读,所以实际上 InnoDB 的 RR 做到了 SQL 标准中 SERIALIZABLE 才有的幻读防护能力。
MVCC 核心原理:undo log 版本链与 ReadView
MVCC 的核心思想是:不直接加锁,而是通过数据行的多个版本来实现读写不互斥。每一行数据在 InnoDB 中实际上有两个隐藏列:
DB_TRX_ID:最近修改该行的事务 IDDB_ROLL_PTR:指向 undo log 中上一个版本的指针
当一行数据被修改时,InnoDB 不会直接覆盖旧数据,而是将旧版本写入 undo log,然后通过 DB_ROLL_PTR 形成一条版本链。查询时,根据当前事务的 ReadView 判断哪个版本可见。
-- 查看当前事务隔离级别
SELECT @@transaction_isolation;
-- 在 RR 下验证不可重复读被解决
-- 会话 A:
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- 会话 B:
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- 会话 A 再次查询,RR 下仍然返回 100(MVCC 保护)
SELECT balance FROM account WHERE id = 1; -- 100
COMMIT;RR 和 RC 的 ReadView 生成时机差异
ReadView 是一个数据结构,包含三个关键字段:
low_limit_id(低水位):当前尚未分配的最小事务 ID(即max_trx_id + 1)up_limit_id(高水位):当前活跃事务列表中最小的 trx_idtrx_ids(活跃事务数组):生成 ReadView 时所有未提交事务的 ID 集合
可见性判断的逻辑:
DB_TRX_ID < up_limit_id → 该事务已提交,可见
DB_TRX_ID >= low_limit_id → 在 ReadView 之后才启动,不可见
up_limit_id <= DB_TRX_ID < low_limit_id:
→ 在 trx_ids 中 → 不可见(事务未提交)
→ 不在 trx_ids 中 → 可见(事务已提交)RR 和 RC 的关键区别就在这里:
- RR:ReadView 在事务第一次执行 SELECT(快照读)时生成,整个事务复用。因此同一事务内多次查询结果一致,不可重复读被解决。
- RC:ReadView 在每次查询时重新生成。因此每次查询都能看到最新已提交的数据,不可重复读可能发生。
# 示意:ReadView 可见性判断伪代码
def is_visible(trx_id, read_view):
if trx_id == current_trx_id:
return True # 自己的修改当然可见
if trx_id < read_view.up_limit_id:
return True # 已提交的旧事务
if trx_id >= read_view.low_limit_id:
return False # 未来事务,不可见
if trx_id in read_view.trx_ids:
return False # 活跃事务,不可见
return True # 介于之间但已提交快照读 vs 当前读
MVCC 只对快照读(普通的 SELECT)生效。以下操作属于当前读,会读取最新已提交的数据,并加锁:
SELECT ... FOR UPDATE -- 加行锁 + 间隙锁(RR 下)
SELECT ... LOCK IN SHARE MODE -- 加共享锁
UPDATE ... -- 先当前读,再修改
DELETE ... -- 先当前读,再删除
INSERT ... -- 插入这就是为什么即使 RR 下,一次 SELECT ... FOR UPDATE 可能看到其他事务刚提交的新行——因为当前读不走 MVCC 的版本链,直接读最新数据。
总结
关键要点
- MVCC 让读写不互斥:读不加锁,通过 undo log 版本链 + ReadView 判断可见性,这是 InnoDB 高并发的核心基石
- RR 和 RC 的 ReadView 策略不同:RR 一次生成、整个事务复用;RC 每次查询重新生成。这是两者解决不可重复读能力差异的根本原因
- RR 下幻读的保护:快照读靠 MVCC 防止幻读,当前读(
FOR UPDATE)靠 Next-Key Lock 防止幻读 - 生产建议:RC 适合高并发 OLTP 场景(锁竞争少),RR 适合报表类场景(需要一致性读)。但注意 RR 下间隙锁可能引发更多死锁
面试话术示例
"MVCC 本质上是通过 undo log 版本链实现的。每行数据有
DB_TRX_ID和DB_ROLL_PTR两个隐藏列,查询时通过 ReadView 判断哪个版本可见。RR 和 RC 的核心区别在于 ReadView 的生成时机——RR 在第一次 SELECT 时生成并复用,RC 每次查询都重新生成。这就是为什么 RR 能解决不可重复读,而 RC 不能。"
参考:MySQL 官方文档 - InnoDB Multi-Versioning;《MySQL 技术内幕:InnoDB 存储引擎》第 3 章