如果你在一个团队里负责数据库开发和线上问题排查大概率遇到过这种场面一个事务在 UPDATE 某一行数据另一个事务同时 SELECT 同一行结果 SELECT 这头既不阻塞也没读到那个改了一半的状态而是拿到一个旧值或者最终值看起来像什么魔法。很多人的第一反应是数据库加了锁呗其实真正扛大头的是另一套机制——MVCC即多版本并发控制Multi-Version Concurrency Control。这篇文章我想把 MySQL InnoDB 引擎里的 MVCC 实现完整地拆一遍它解决的问题是什么底层的隐藏字段和 undo log 版本链长什么样ReadView 是怎么决定能不能看到某个版本的以及在 RC 和 RR 两种隔离级别下行为差在哪。顺便把几个我踩过的坑和实践验证方法也写出来。适合刚接触 MVCC 想建立完整认知的读者也适合已经会用但没想透原理、排查并发问题时总觉得哪里没对上的同学。1. 先从并发事务的三类乱象说起MVCC 到底在解决什么1.1 脏读、不可重复读、幻读是怎么来的数据库并发控制最核心的目标就是处理多个事务同时操作同一批数据时产生的读写冲突。教科书上把这些问题收敛成三类。第一类是脏读。事务 A 改了某行数据但还没提交事务 B 把这行读走了读到的就是脏数据。如果 A 后面回滚了B 之前读到的内容就成为无源之水整个业务决策建立在幻影上。第二类是不可重复读。事务 B 先读了一次某行数据得到一个值事务 A 在这期间提交了 UPDATEB 再读一次值变了。B 一次事务内两次读到同一个对象结果不允许。第三类是幻读。区别在于不可重复读针对同一行的内容变化而幻读针对同一个范围查询的结果集变化。事务 B 执行了 SELECT WHERE age 20第一次查出 3 行事务 A 提交了一个新插入的满足条件的行B 再执行同样的查询变成了 4 行。多出来的那行像幻觉一样。这三类问题的共性都是一个读操作和一个写操作同时在抢同一批数据而无论是谁先谁后都会有一方收到不干净的视角。1.2 为什么不能全靠锁机制最直觉的解决方案是加锁读的时候锁住数据不让写写的时候锁住数据不让读串行执行总不会错。但纯锁方案有一个致命代价——并发度太低。一个长时间运行的 SELECT 会堵住所有写操作而一个长时间运行的事务里的 UPDATE 也会堵住所有读操作。现实中微服务和在线业务的请求动不动就是几百上千 QPS全都串行化系统基本就废了。所以数据库真正的设计目标是读与写尽量不互相阻塞同时业务又能在需要时获得隔离保证。锁不是被淘汰了而是被收敛到了它真正管用的场景——写与写之间的冲突。至于读与写之间换了一条更聪明的路。1.3 MVCC 的基本思路读写分离、互不阻塞MVCC 的思路换个角度理解其实很朴素既然大家抢的是当前这一版本的数据那我干脆把数据的历史版本都保留下来。写操作不直接覆盖老数据而是生成一个新版本读操作也不非得读最新版本它可以基于某个时间点选择一个合适的旧版本来读。这样一来写事务在最新版本上干活读事务在老版本上干活两者互不打扰。读事务永远不需要等写事务释放锁写事务也永远不需要等读事务结束。等真正需要判断哪个版本对某个事务可见时就靠一套叫 ReadView 的机制来裁决。这就是 MVCC 的全貌多版本是手段并发控制是目的ReadView 是裁判。2. 两个隐藏字段加一条undo链MVCC的物理基础2.1 每行记录里藏着的三个隐藏值很多人以为 InnoDB 聚簇索引记录里就存放表字段那些数据实际上每一行记录还额外带着几个隐藏字段。MVCC 能成立第一个基础就是这些隐藏字段。其中最关键的是 DB_TRX_ID 和 DB_ROLL_PTR。DB_TRX_ID 表示最近一次修改这条记录的事务 ID它占了 6 个字节。只要这条数据被 INSERT、UPDATE 或 DELETE 过这个字段就会被写入对应事务的 ID。第二个是 DB_ROLL_PTR回滚指针占 7 个字节它指向这条记录的 undo log也就是指向上一个版本的位置。正是这个指针把所有历史版本串成了一条链。第三个字段 DB_ROW_ID 相对次要是一个 6 字节的隐藏行 ID。只有当表里既没有主键也没有合适的非空唯一索引时InnoDB 才会真正使用它充当内部主键。平时只要你有主键它基本只是个备胎。这三个字段共同构成了版本管理的基础设施TRX_ID 告诉你是谁改的ROLL_PTR 告诉我要找历史版本该去哪ROW_ID 负责兜底行的唯一性。2.2 undo日志与版本链的形成过程光有隐藏字段还不行历史版本的数据本身得有一个存放地这就是 undo log。每当事务要修改一行数据时InnoDB 会先把修改前的旧值写入 undo log再把数据页里的当前值改掉同时更新隐藏字段。举个例子。有一行记录 id1age20。事务 A 执行 UPDATE 把它改成 age30这个操作发生前InnoDB 会生成一条 undo log里面记录着修改前该行的内容然后数据页里的 age 变成 30DB_TRX_ID 改成 A 的事务 IDDB_ROLL_PTR 指向刚才生成的那条 undo 记录。如果事务 B 紧接着又把这行改成 age40同样的过程再来一遍旧值 age30 进入 undo log数据页值变成 age40DB_TRX_ID 改成 B 的 IDDB_ROLL_PTR 指向 B 的那条 undo 记录。此时从最新版本出发顺着 B 的 undo 能找到 A 的版本顺着 A 的 undo 能找到原始版本。这条由回滚指针串联起来的链条就是常说的 undo log 版本链。每条 undo 记录都对应一个事务生成的版本它们像收据一样一张压一张地挂着直到确认不会再被任何事务需要才由后台的 purge 线程清理掉。2.3 版本链的回溯逻辑从最新版本往回找这里需要特别提醒一个容易忽略的点读数据的路径并不是直接定位到某个指定版本而是从最新版本开始逐个判断这个版本我能看到吗看不到就顺着回滚指针往前翻一个直到找到第一个可见版本。这有点像 git 的提交历史。你当前 HEAD 指向的最新提交不一定是你能接受的状态你需要在提交链上往回找一个符合你视角的节点。版本链也是这个道理最新版本哪怕是别人没提交的修改你也能通过回滚指针绕过去读到一个更早的、对你可见的状态。同时DELETE 在这里也不是物理删除。InnoDB 会把记录标记为已删除这条记录同样有一个事务版本并通过版本链保留。判断一条已删除记录对某个事务是否可见用的也是同一套版本可见性逻辑。如果删除版本对当前事务不可见那么这条记录在快照读里依然活着。3. ReadView那张快照究竟是怎么拍出来的3.1 ReadView的四个关键成员版本链只是把不同版本摆在了那里真正决定哪个版本对当前事务可见的裁判是 ReadView。你可以把它理解成在某个时间点给整个数据库活跃事务状态拍的一张照片。这张照片上有四个关键信息。第一个 m_ids是拍摄那一刻所有正在活跃的事务 ID 集合所谓活跃事务就是已经开始了但还没有提交或回滚的读写事务。第二个 m_low_limit_id是当前系统已经分配过的事务 ID 最大值加 1可以理解为未来才可能出现的事务 ID 起点。第三个 m_up_limit_id是 m_ids 里最小的那个事务 ID代表活跃事务中辈分最大的一个。最后一个 m_creator_trx_id是创建这个 ReadView 的事务自己的 ID。这四个字段共同划出了一块判定区域哪些事务在快照生成前已经结束哪些在快照生成时还活着哪些甚至要等以后才可能出现。任何一个版本的事务 ID都逃不出这几种归属。3.2 可见性判断规则一行数据如何决定能不能看到当某次读操作沿着版本链检查一个版本的 DB_TRX_ID 时ReadView 会按下面几条规则依次判断。第一条如果这个版本的 trx_id 等于 ReadView 创建者自己的事务 ID也就是 m_creator_trx_id那说明这行记录是当前事务自己修改的。自己改的东西自己当然能看见直接判定可见。第二条如果 trx_id 小于 m_up_limit_id说明该版本是由快照生成之前就已经提交的事务产生的因为活跃事务里最小的事务 ID 都比它大它显然不在活跃队列里。这种情况直接可见。第三条如果 trx_id 大于等于 m_low_limit_id说明该版本是由快照生成之后才开始的事务产生的要么还没发生要么发生在快照之后。对当前快照来说这种版本不可见。第四条如果 trx_id 落在 m_up_limit_id 和 m_low_limit_id 之间就需要查一下 m_ids 里到底有没有它。在 m_ids 里说明这个版本来自尚未提交的活跃事务不可见不在 m_ids 里说明它是个在快照生成前已经开始但到快照生成时已经提交完毕的事务可见。这四条规则合起来本质上是在回答一句话生成这个版本的事务相对于我这张快照到底是已经结束还是还在活跃。已经结束且不在我之后的事务放行仍在活跃的拦截。3.3 一个完整的事务读取过程拆解光说规则太抽象我举个完整例子。假设表里有一行 id1age20是事务 ID 为 99 的事务插入的并且它已经提交。现在事务 A 的 trx_id 是 100它执行 UPDATE 把这行改成 age30但还没提交。此时事务 B 执行一条普通 SELECT。B 执行这条 SELECT 时系统里活跃事务集合就是 [100]所以 B 生成的 ReadView 大致是这样的m_ids 里有 100m_up_limit_id 是 100m_low_limit_id 是 101m_creator_trx_id 是 B 自己的事务 ID。然后 B 开始查 id1 这行数据发现最新版本的 DB_TRX_ID 是 100。按规则走一遍100 不等于 B 自己的 ID100 不小于 m_up_limit_id 的 100100 小于 m_low_limit_id 的 101继续查 m_ids100 在里面也就是事务 A 还活跃着。结论不可见。于是 B 顺着 DB_ROLL_PTR 找到上一个版本那个版本的 trx_id 是 99。99 小于 m_up_limit_id 的 100说明事务 99 在快照生成前就提交完了判定可见。B 最终读到 age20。你会发现整个过程中 B 和 A 完全没有互相等待。A 该改改B 该读读最终互不干扰。这就是 MVCC 读不阻塞写、写不阻塞读的直接体现。4. RC与RR的分水岭ReadView的创建时机4.1 读已提交RC每次快照读都是新照片同样的 ReadView 判断规则在不同隔离级别下会有截然不同的行为差别就在于 ReadView 的生成时机。在 RC读已提交级别下事务里每执行一次普通的快照读都会重新生成一个新的 ReadView。也就是说同一事务内的两条 SELECT 语句哪怕中间隔了几毫秒它们拿到的活跃事务照片都可能不一样。回到上面那个例子。事务 B 第一次 SELECT 时事务 A 还没提交B 读到 age20。如果 A 随后提交了B 再次执行 SELECT这次会生成新的 ReadViewm_ids 里已经没有了 A 的事务 ID重新判断版本链时最新版本的 trx_id100 会被判定为可见。因此 B 第二次读到 age30。这正是读已提交这个名称的含义只能读到其他事务已提交的数据。代价是同一事务内两次读同一行结果可能不同也就是存在不可重复读问题。4.2 可重复读RR一次事务只拍一张照片在 RR可重复读级别下ReadView 的生成时机完全不同。它只在事务中第一次执行快照读时创建之后的任何快照读都复用同一张照片。同样场景事务 B 在 RR 级别下第一次 SELECT 时生成 ReadViewm_ids 里有 100这时无论 A 提交与否B 之后的所有普通 SELECT 都继续使用这张旧照片。即使 A 已经提交B 第二次执行同样的 SELECT检查的依然是旧的 m_ids活跃列表里仍然有 100于是最新版本仍然不可见B 继续读到 age20。这就是可重复读的来源整个事务期间你看到的是同一个时间点的快照天然保证了可重复读顺带也解决了快照读场景下的幻读问题。这是个让我印象很深的点。很多资料说 RR 用间隙锁解决幻读其实只说对了一半。RR 下快照读的幻读问题早在这张只生成一次的 ReadView层面就被解决了间隙锁补的是当前读场景下的幻读这一点后面展开说。4.3 换隔离级别后同样的SQL行为为什么不同我经常在排查问题时遇到一种现象同一段代码在测试环境没问题到了某个连接串配置了不同隔离级别的环境表现就完全对不上。八成就是 RC 和 RR 的 ReadView 生成时机差异在作怪。用一句话总结两者的差别RC 是读一次拍一次照RR 是一个事务只拍一次照。这个差别几乎可以解释两者在可见性上的所有行为差异也是理解线上为什么这条 SELECT 总是读到旧值这类问题的总钥匙。顺带一提MySQL 8.0 里默认隔离级别是 RR而很多其他数据库默认是 RC。如果你在迁移或者对比不同数据库行为第一件事就是确认隔离级别不然后面的排查方向很容易跑偏。5. 当前读与快照读MVCC的真正边界5.1 为什么UPDATE和DELETE不走快照读聊到这里有个很关键的边界必须说清楚MVCC 只服务于快照读也就是那些不加锁的普通 SELECT。而 UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 这几种操作走的是当前读。当前读的意思是直接读最新版本的数据然后对读取的记录加锁。为什么写操作不能走快照读因为写操作最终要产生新版本如果它基于一个旧快照去计算那么新版本就会覆盖掉别人已提交的修改造成丢失更新。举个例子事务 B 要执行 UPDATE user SET age age 1 WHERE id 1。如果 B 走快照读拿到 age20 的旧版本然后把 age 改成 21而事务 A 此前已经把最新版本改成了 age30B 的这次更新就把 A 的成果覆盖了。这是不可接受的。所以 UPDATE 必须读最新版本并且要给这行加锁防止别人同时修改。这也解释了为什么并发写同一行时仍然会出现阻塞写与写之间是锁对锁的正面冲突MVCC 插不了手。你观察到的SELECT 不阻塞 UPDATEUPDATE 阻塞 UPDATE本质就是快照读和当前读的分工不同。5.2 幻读在RR下的残留与间隙锁的补位这里要对幻读做一个更细致的区分。在 RR 级别下普通快照读因为复用 ReadView根本不会看到其他事务插入的新行所以快照读场景的幻读已经被 MVCC 解决了。但是当前读场景下比如执行 SELECT * FROM user WHERE age 20 FOR UPDATE这条语句读的是最新版本会把其他事务刚刚插入的满足条件的新行也读出来。如果不采取措施两次当前读之间如果其他事务插入了新行结果集就会变化幻读成立。InnoDB 这里用的是 next-key lock也就是记录锁加间隙锁的组合。它会把查询条件命中的索引区间都锁住不仅锁住已有的记录还锁住记录之间的间隙让其他事务无法在这个区间内插入新行。这样一来在当前读的语义下幻读也被堵住了。所以完整的说法是RR 级别下快照读靠 MVCC 解决幻读当前读靠间隙锁解决幻读。两者各管一段合起来才保证了 RR 级别的隔离承诺。5.3 快照读错过新数据时的实用处理思路理解了当前读和快照读的边界排查时思路就会清晰很多。最常见的现象是应用连接池里某个连接开启了事务事务里先做了一次 SELECT之后另一个服务提交了关键数据这个事务后续再 SELECT 时始终看不到新数据。在 RR 下这是正常行为不是 bug。如果业务确实需要读到最新已提交的数据有几条实用路子。一是把隔离级别改成 RC代价是同一事务内可能出现不可重复读需要评估业务能否接受。二是把那条需要最新数据的 SELECT 改成当前读比如 SELECT ... FOR UPDATE但要小心这会引入锁竞争。三是干脆拆成独立事务让需要最新快照的操作在单独事务里短平快地完成避免长事务里快照过期。这些取舍没有银弹。我给的建议是先在业务层面想清楚到底是要一致性还是要实时性再用隔离级别和读类型去匹配比盲目调参数靠谱得多。6. 几个MVCC误区与实操验证方法6.1 误区一MVCC能解决所有读写冲突这个误区最容易让人在并发设计时翻车。MVCC 解决的是读操作不阻塞写操作、写操作不阻塞读操作这个目标但它没有消除写与写之间的冲突也没有消除当前读与写之间的冲突。一旦事务从快照读切换到当前读比如在事务里先 SELECT 再 UPDATE锁就会介入。两个事务同时想更新同一行后到的那个必须等待。所以做并发方案时不能因为用了 MVCC就觉得万事大吉行锁、间隙锁、死锁检测这些机制依然要纳入考虑。另一个关联误区是认为 MVCC 可以完全替代 SQL 层的锁。实际上 InnoDB 的 MVCC 和锁机制是协同工作、各管一段的快照读交给版本链和 ReadView当前读和写交给锁两者缺一不可。6.2 误区二把ReadView理解成全局唯一的我见过不少刚接触 MVCC 的人以为数据库里有一个全局 ReadView所有事务都照它来判断。实际上每个快照读的发起者都有自己的 ReadView而且不同隔离级别下同一个事务内的 ReadView 数量都可能不同。这也是为什么两个事务同时执行 SELECT 会看到不同结果它们各自的 ReadView 里记录的活跃事务集合不一样对同一版本链的裁决结果自然也可能不一样。每次学习 MVCC 时时刻记住ReadView 是拍给某个事务、某个时点的照片比硬背四条规则更容易想明白。还有一个细节undo log 不是永远保留的。当一个版本不再可能被任何活跃 ReadView 访问到purge 线程就会把它清理掉。所以版本链不是无限长的长事务或大事务频繁更新时 undo 膨胀清理跟不上就会带来空间和性能问题。排查时如果发现 undo 表空间涨得异常优先查是不是有长事务在持续产生旧版本。6.3 两个SQL实验亲手验证RC与RR的差异原理讲了这么多不如亲自动手验证一下。我建议你在本机开两个 MySQL 会话按下面步骤做两组实验验证 RC 和 RR 的 ReadView 差异。先准备一张表CREATE TABLE test_user ( id INT PRIMARY KEY, age INT NOT NULL ) ENGINEInnoDB; INSERT INTO test_user VALUES (1, 20);实验一验证 RC。把会话 A 的隔离级别设为 READ COMMITTED会话 B 也设为 RC。A 开启事务并执行 UPDATE 但不提交B 开启事务第一次 SELECT 应该读到 age20A 提交B 再执行第二次 SELECT这次会读到 age30。因为在 RC 下每次 SELECT 都生成了新 ReadView。-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; UPDATE test_user SET age 30 WHERE id 1; -- 不提交 -- 会话 B SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; SELECT age FROM test_user WHERE id 1; -- 读到 20 -- 回到会话 A 执行 COMMIT; -- 会话 B SELECT age FROM test_user WHERE id 1; -- 读到 30实验二验证 RR。把两个会话都改成 REPEATABLE READ重复同样的步骤。B 第一次 SELECT 读到 20A 提交后B 第二次 SELECT 依然读到 20。因为 B 的事务只生成过一次 ReadView后续都复用它。-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; UPDATE test_user SET age 30 WHERE id 1; -- 不提交 -- 会话 B SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; SELECT age FROM test_user WHERE id 1; -- 读到 20 -- 回到会话 A 执行 COMMIT; -- 会话 B SELECT age FROM test_user WHERE id 1; -- 依然读到 20做完这两个实验你对 RC 和 RR 的差别就有了肌肉记忆。如果再配合 EXPLAIN 或者 performance_schema 里的事务信息观察排查线上问题时会顺手很多。我个人实操下来最大的体会是MVCC 并不难难的是把它和锁机制想成一张完整的地图。每次遇到读到了奇怪数据的问题先问三个问题当前是什么隔离级别这次读是快照读还是当前读被读数据的版本链上有哪些活跃事务三个问题一过大部分并发疑难杂症的位置基本就定位了。