MySQL MVCC面试精讲:ReadView、undo log、可见性算法的完整推导
MySQL MVCC面试精讲:ReadView、undo log、可见性算法的完整推导
适读人群:Java后端开发、DBA | 难度:★★★★★ | 出现频率:极高
开篇故事
MVCC这个概念,我第一次听到是在入行第三年,当时有个同事问我:"为什么MySQL可重复读隔离级别下,A事务开始后B事务提交的数据,A读不到?"
我当时的回答是:因为是可重复读嘛,读不到是对的。
他追问:那MySQL是怎么实现这一点的?是加锁吗?
我答:应该是加锁……吧?
然后他给我讲了MVCC,讲了ReadView,讲了undo log版本链。我足足听了一个小时,才真正理解:原来读和写不互相阻塞,靠的根本不是锁,而是一套精妙的版本管理机制。
从那以后,MVCC成了我面试必问的一道题。今天把它彻底讲清楚。
一、高频考点拆解
MVCC这道题,面试官想考察的核心:
第一层:知道MVCC是什么(多版本并发控制),解决了什么问题(读写不互相阻塞)
第二层:知道MVCC的实现要素:隐藏字段、undo log版本链、ReadView
第三层:能完整推导出一个具体查询的可见性判断过程,说清楚RC和RR隔离级别下ReadView的差异
二、深度原理分析
2.1 为什么需要MVCC
没有MVCC之前,读操作需要加共享锁,写操作需要加排他锁,读写互斥,并发性能差。
MVCC的核心思想:不同时间点的读操作,看到不同版本的数据。每次写操作产生新版本,读操作根据自己的"快照时间点"选择合适的版本,从而实现读写不互斥。
2.2 三个关键组件
组件1:隐藏字段
InnoDB在每行数据上添加了三个隐藏字段:
| 实际数据列 | DB_TRX_ID(6B) | DB_ROLL_PTR(7B) | DB_ROW_ID(6B) |DB_TRX_ID:最近修改这行数据的事务IDDB_ROLL_PTR:回滚指针,指向undo log中的上一个版本DB_ROW_ID:隐含主键(如果表没有定义主键)
组件2:undo log版本链
每次修改数据,都会把旧版本写入undo log,并通过DB_ROLL_PTR形成版本链:
这条链从最新版本一直追溯到最初版本,每个版本都记录了"是哪个事务修改成这样的"(tx_id)。
组件3:ReadView
ReadView是事务开始读取时生成的"快照视图",包含四个关键信息:
ReadView {
m_ids: [活跃事务ID列表], // 生成快照时,还没提交的事务ID集合
min_trx_id: m_ids中最小值, // 快照时最早的活跃事务ID
max_trx_id: 当前最大事务ID+1, // 快照时下一个将要分配的事务ID
creator_trx_id: 创建这个ReadView的事务ID
}2.3 可见性判断算法
拿到一行数据的某个版本(tx_id),判断这个版本对当前ReadView是否可见:
可见性判断(version_tx_id, readview):
1. 如果 version_tx_id == readview.creator_trx_id
→ 当前事务自己修改的,可见
2. 如果 version_tx_id < readview.min_trx_id
→ 这个版本的事务在快照前就已提交,可见
3. 如果 version_tx_id >= readview.max_trx_id
→ 这个版本的事务在快照后才开始,不可见
→ 沿版本链找上一个版本
4. 如果 min_trx_id <= version_tx_id < max_trx_id
→ 检查 version_tx_id 是否在 m_ids(活跃事务集合)中
4a. 在m_ids中:该事务在快照时还未提交,不可见 → 找上一个版本
4b. 不在m_ids中:该事务在快照时已提交,可见2.4 RC和RR的关键差异
读已提交(RC):每次执行查询语句时,都重新生成ReadView。这意味着如果期间有新的事务提交,下次查询会看到最新提交的数据。
可重复读(RR):只在事务第一次执行查询时生成ReadView,之后整个事务期间复用同一个ReadView。即使期间有其他事务提交,这个事务看到的数据快照始终不变。
这就是"可重复读"名字的来源——同一个事务内多次读同一行,结果是可重复的(不会因为其他事务的提交而改变)。
三、标准答案 + 代码验证
3.1 完整推导示例
假设有如下场景,详细推导可见性:
-- 初始状态
-- users表:id=1, name='张三', balance=1000
-- 事务A开始(tx_id=100)
BEGIN; -- A的ReadView还没生成
-- 事务B开始(tx_id=200)并修改数据
BEGIN;
UPDATE users SET balance = 2000 WHERE id = 1; -- DB_TRX_ID=200
COMMIT; -- 事务B提交
-- 事务A执行第一次读
SELECT balance FROM users WHERE id = 1;在RR隔离级别下的推导:
事务A第一次读时,生成ReadView:
- 此时活跃事务:假设还有事务C(tx_id=150)未提交
- m_ids = [100, 150](注意:事务B已提交,不在活跃列表里)
- min_trx_id = 100
- max_trx_id = 201(下一个将分配的事务ID)
- creator_trx_id = 100
当前行数据:balance=2000,DB_TRX_ID=200(事务B修改的)
可见性判断:
- tx_id(200) != creator_trx_id(100),继续
- tx_id(200) >= min_trx_id(100),继续
- tx_id(200) < max_trx_id(201),继续
- tx_id(200) 在 m_ids([100, 150]) 中吗?不在
- 结论:可见!,事务A读到 balance=2000
等等,这里有个容易误解的地方。事务B在生成ReadView之前已经提交了,所以它不在m_ids中,事务A是能读到事务B修改的值的。
再看一个不可见的场景:
-- 重新模拟
-- 初始:balance=1000
-- 事务A开始并立即读(tx_id=100,此时事务B还没开始)
BEGIN;
SELECT balance FROM users WHERE id = 1; -- 生成ReadView,m_ids=[100],读到1000
-- 事务B开始并修改(tx_id=200)
BEGIN;
UPDATE users SET balance = 2000 WHERE id = 1;
COMMIT;
-- 事务A再次读(RR级别,复用之前的ReadView)
SELECT balance FROM users WHERE id = 1;事务A第二次读,ReadView复用第一次的:
- m_ids = [100](第一次读时,只有事务A自己是活跃的)
- min_trx_id = 100
- max_trx_id = 101(第一次读时,下一个事务ID)
当前行:balance=2000,DB_TRX_ID=200
可见性判断:
- tx_id(200) >= max_trx_id(101):不可见!
- 沿版本链找上一个版本:balance=1000,DB_TRX_ID=99(假设之前某个事务写入的,已提交)
- tx_id(99) < min_trx_id(100):可见!
- 事务A读到 balance=1000(还是旧值)
这就是可重复读:事务B的提交对事务A的后续读不可见,读到的还是快照时的值。
3.2 SQL验证
-- 建表
CREATE TABLE mvcc_test (
id INT PRIMARY KEY,
value INT
) ENGINE=InnoDB;
INSERT INTO mvcc_test VALUES (1, 100);
-- 开两个MySQL连接,分别执行以下SQL:
-- 连接1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT value FROM mvcc_test WHERE id = 1; -- 结果:100
-- 连接2(事务B)
BEGIN;
UPDATE mvcc_test SET value = 200 WHERE id = 1;
COMMIT;
-- 连接1(事务A)继续
SELECT value FROM mvcc_test WHERE id = 1; -- RR级别:结果仍是100!
COMMIT;
-- 如果改成RC级别
-- 连接1(事务A)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT value FROM mvcc_test WHERE id = 1; -- 结果:100
-- 连接2(事务B)
BEGIN;
UPDATE mvcc_test SET value = 200 WHERE id = 1;
COMMIT;
-- 连接1(事务A)继续
SELECT value FROM mvcc_test WHERE id = 1; -- RC级别:结果是200!(读到了B提交的数据)
COMMIT;四、面试官追问
追问1:MVCC能解决幻读吗?
我的回答:MVCC能解决快照读(普通SELECT)的幻读,因为ReadView固定了,新插入的行的tx_id大于max_trx_id,不可见。但MVCC不能解决当前读(SELECT...FOR UPDATE、UPDATE、DELETE等)的幻读,当前读总是读最新版本的数据,如果其他事务插入了新行并提交,当前读会看到这些新行,形成幻读。MySQL的RR级别通过Next-Key Lock(间隙锁+行锁)来防止当前读的幻读。
追问2:undo log和redo log有什么区别?
我的回答:两者都是InnoDB的日志,但用途完全不同。undo log(回滚日志)记录数据修改前的旧值,用于事务回滚和MVCC版本链。每次修改数据前,先把旧值写入undo log,如果事务回滚,就用undo log恢复原始数据。redo log(重做日志)记录数据修改后的新值,用于崩溃恢复(crash recovery)。MySQL采用WAL(Write-Ahead Logging)机制,数据不直接写磁盘(先写Buffer Pool),但先写redo log。如果MySQL宕机,重启时根据redo log把未写入磁盘的修改重新应用,保证已提交事务的持久性。
追问3:MVCC的版本链会一直增长吗?什么时候清理?
我的回答:不会一直增长,InnoDB有一个purge线程负责清理不再需要的undo log。清理原则:如果某个版本的数据已经不可能被任何活跃事务读到(比这个版本更新的ReadView都不再需要它),这个版本就可以被清理。具体来说,purge线程会跟踪当前系统中最旧的活跃ReadView,比这个ReadView更老的undo log版本就可以安全清理。如果有长事务(长时间不提交的事务),它的ReadView会一直存在,导致大量undo log无法清理,undo表空间不断增长,这也是长事务危害之一。
五、同类题目举一反三
MySQL的四种隔离级别分别解决了什么问题?
- 读未提交(READ UNCOMMITTED):啥都没解决,脏读、不可重复读、幻读都存在
- 读已提交(READ COMMITTED):解决了脏读,RC是大多数数据库的默认隔离级别(如Oracle、PostgreSQL)
- 可重复读(REPEATABLE READ):解决了脏读和不可重复读,MySQL默认,MVCC+Next-Key Lock解决了大部分幻读
- 串行化(SERIALIZABLE):解决了所有问题,但并发度最低,每次读都加共享锁
六、踩坑实录
坑一:长事务导致undo log膨胀,磁盘撑满
有次一个批处理任务,开了一个超大事务,处理了几百万行数据,事务持续了4个小时。期间系统其他查询都要绕过这些旧版本数据,undo log不断累积,4个小时内undo表空间增长了几十GB,最终磁盘告警。
教训:批处理任务必须分批提交,不能用超长事务,每处理1000条就提交一次。
坑二:RC和RR的差异导致业务逻辑错误
有个同事在开发中,发现自己写的查询在测试环境有时结果不对——读到了"不存在"的数据(是另一个并发事务中间状态的数据)。后来发现测试数据库的隔离级别被设成了RC,而生产库是RR。两个环境的隔离级别不一致,导致在RC下测试通过的代码,在RR下行为不一样。
教训:确保开发、测试、生产环境的数据库隔离级别一致。
坑三:在RR下以为能看到最新数据,结果读到了旧值
有个接口做了这样的逻辑:先插入一条记录,再异步触发另一个服务来查询这条记录做后续处理。但异步服务用了长连接的数据库连接,这个连接上还有一个开着的事务(忘记提交了),导致ReadView是很早之前的快照,看不到新插入的记录。
教训:检查长连接上的事务状态,每次请求结束后要确保事务提交或回滚,不要让事务长时间挂起。
七、总结
MVCC是InnoDB实现高并发的核心机制,三个要素缺一不可:
- 隐藏字段:DB_TRX_ID记录修改者,DB_ROLL_PTR构成版本链
- undo log版本链:保存历史版本数据,供MVCC读取
- ReadView:快照视图,包含活跃事务集合,配合可见性算法判断哪个版本可见
RC和RR的差异只在ReadView的生成时机:RC每次查询都生成新的ReadView,RR只在第一次查询时生成。
能把完整的可见性判断算法说清楚,再配合一个具体的事务场景推导一遍,这道题就能拿满分。
