MVCC 的核心机制是“读快照”,但快照什么时候生成、如何判断可见性,完全取决于当前的事务隔离级别。四种隔离级别中,读未提交(READ UNCOMMITTED)和串行化(SERIALIZABLE)几乎不走 MVCC,真正依赖 MVCC 的是读已提交(READ COMMITTED)和可重复读(REPEATABLE READ),而它们的行为差异正是通过 Read View 的生成时机来体现的。
为了让你直观理解,我们假设有一张简单的账号表 accounts,其中一行记录 id=1, balance=100,并且先后启动两个事务:
- 事务 A:操作账户余额。
- 事务 B:只进行查询。
事务执行的时间线如下(事务 B 在事务 A 中途开始查询):
时刻1:事务 A 开始
时刻2:事务 A 将 balance 更新为 200(未提交)
时刻3:事务 B 开始,并执行第一次 SELECT
时刻4:事务 A 提交
时刻5:事务 B 执行第二次 SELECT
时刻6:事务 B 提交
我们来看在不同的隔离级别下,事务 B 的两次 SELECT 分别读到了什么。
READ UNCOMMITTED(读未提交):基本不用 MVCC
在这个级别下,MySQL 几乎不依赖 MVCC 创建 Read View,而是直接读取记录的最新版本,即使这个版本来自尚未提交的事务。因为“读未提交”就是故意允许脏读的。
事务 B 在时刻3 执行第一次 SELECT,读取到事务 A 未提交的 balance = 200。如果后来事务 A 回滚,事务 B 就读到了永远不会正式存在的数据。这种数据错误在金融业务中是致命的,因此除非有特殊理由(例如仅用于粗略统计,且对准确度要求极低),千万不要在生产环境使用此级别。
你很少会看到 MVCC 在这个级别下起作用,因为它的设计就意味着放弃一致性快照。
READ COMMITTED(读已提交):每次快照读都生成新 Read View
这是很多商业数据库的默认隔离级别(如 Oracle、PostgreSQL)。在 MySQL 中你可以通过 SET TRANSACTION ISOLATION LEVEL READ COMMITTED 开启。
这个级别的核心行为是:一个事务内的每条快照读语句,在执行时都会重新生成一个 Read View,从而读取到最新已提交的数据。
按照上面的例子:
- 事务 B 第一次 SELECT(时刻3):事务 A 尚未提交,因此 Read View 中,活跃事务列表包含事务 A 的 ID,事务 A 的修改对于事务 B 不可见。事务 B 会沿着 Undo Log 版本链找到上一个已提交的版本,读到
balance = 100。 - 事务 B 第二次 SELECT(时刻5):此时事务 A 已经提交,属于“已提交”的事务。当这条新语句执行时,事务 B 会生成一个全新的 Read View,这个新的 Read View 中活跃事务列表不再包含事务 A。于是,事务 A 的修改变为可见,事务 B 读到
balance = 200。
也就是说,在同一个事务 B 内部,两次相同的查询读到了不同的数据,这就是不可重复读。如果你在事务 B 第一次查询后,基于 balance = 100 做出了某个业务决策(比如判断余额足够扣款),但提交前数据已经被事务 A 改成了 200,很可能造成业务逻辑错误。
此外,这个级别下也很容易出现幻读:当另一个事务插入了新的行,并且这些行满足事务 B 的查询条件,事务 B 第二次执行同一条范围查询时,会因为生成了新的 Read View 而“幻影”般地多出一些行。READ COMMITTED 只保证读到的数据都是已提交的,但不保证读到的行集合在事务内不变。
实际应用中,如果你确实希望让 MySQL 的读写性能更高,同时又能够接受不可重复读(例如只是为了做统计快照),READ COMMITTED 是一个可选项,并且此时基于行模式的 Binlog 必须使用 binlog_format = ROW,否则会在复制时出现数据不一致的严重问题。
REPEATABLE READ(可重复读):事务开始时的首个快照读决定一切
这是 MySQL InnoDB 的默认隔离级别,也是大多数开发者在实际业务中最常使用的级别。
它的关键规则是:一个事务内的第一个快照读语句(普通的 SELECT)会生成一个 Read View,此后事务内的所有快照读都复用这个 Read View,不再更新。 这就保证了同一个事务内多次读取的结果完全一致。
回到上面的例子:
- 事务 B 第一次 SELECT(时刻3):此时生成 Read View,活跃事务列表包括事务 A。事务 B 沿着版本链读到
balance = 100。 - 事务 B 第二次 SELECT(时刻5):虽然事务 A 已经提交,但因为事务 B 使用的还是第一次查询时生成的 Read View,该 Read View 中的活跃事务列表仍然包含事务 A,所以事务 A 的修改被视为不可见。事务 B 再次沿着版本链找历史版本,还是读到
balance = 100。
你会发现,在这个级别下,事务 B 的两次查询结果完全一样,这就是“可重复读”。这能很好地消除不可重复读的问题,让你的业务逻辑在一个稳定的数据快照上运行。
关于幻读:由于 MVCC 快照读始终使用同一个 Read View,因此单纯的 SELECT 不会看到其他事务新插入的行,幻读在这种场景下是天然免疫的。但如果你在事务中执行了“当前读”(如 SELECT ... FOR UPDATE 或 UPDATE/DELETE),情况就不同了。当前读总是读取记录的最新已提交版本,并且会加锁。为了避免当前读发生幻读,InnoDB 在 REPEATABLE READ 级别下还引入了间隙锁(Gap Lock)和临键锁(Next-Key Lock),将索引记录之间的间隙也锁定起来,阻止其他事务在这些间隙中插入新行。这意味着,虽然 MVCC 快照读用“不变快照”杜绝了幻象,但当前读则通过“锁住间隙”来彻底堵死幻象的入口。
在日常开发中你会感觉到,REPEATABLE READ 能解决绝大多数的并发一致性问题,并且对开发者的心智负担较小。当你不确定时,保持默认的 REPEATABLE READ 就是最佳实践。
SERIALIZABLE(串行化):所有读都变成当前读且加锁
这个级别最简单,也最粗暴。所有的普通 SELECT 都会被隐式转换为 SELECT ... FOR SHARE(即加共享锁),这相当于所有读操作都变成了“当前读”,并且会对涉及的行和间隙加锁。MVCC 在这时几乎不再发挥作用,因为不再有“快照”的概念——读必须等待写事务完成,写事务也在等待读释放锁。事务之间完全串行执行。
显然,SERIALIZABLE 能彻底解决脏读、不可重复读、幻读,但并发性能会断崖式下降。除非是极其特殊的金融场景(比如要求绝对不可能出现并发异常),否则不会使用。
小结
在不同隔离级别下,MVCC 的行为差异可以浓缩为一句很实用的话:
- READ UNCOMMITTED:不生成 Read View,直接读最新,快但不安全。
- READ COMMITTED:每条语句都生成新的 Read View,能到最新已提交数据,但会不可重复读和幻读。
- REPEATABLE READ:事务内第一个快照读生成 Read View 并一直使用,保证可重复读,再辅以间隙锁防止当前读的幻读。
- SERIALIZABLE:不用 MVCC,所有读都变成加锁的当前读,强行串行。
在现实中,你基本只需要关注后两者,并且 99% 的场景应该使用 REPEATABLE READ,因为它平衡了数据一致性和并发性能。理解这些行为差异,能帮助你在调试异常数据、或者调优事务时,快速判断到底是隔离级别设置不合适,还是自己的锁策略没有跟上。