先交代一个背景我最近面了一家电商平台企业技术面第一轮面试官坐下之后几乎没有寒暄上来就是一句“你讲讲 MVCC 的原理吧。”我当时心里“咯噔”一下不是因为不会而是这个问题太像八股了。如果只背“MVCC 就是多版本并发控制解决读写冲突”那基本就凉了。面试官要听的绝不是这个。他要听的是版本链怎么组织、ReadView 怎么判断可见性、RR 和 RC 在 MVCC 上到底有什么差别甚至还要你结合一条具体的 SQL 推演一遍。这篇文章就基于那场面试把“MVCC 原理”从底到上完整拆一遍。我不打算给你速背口诀而是把它讲成一套能推导的逻辑。懂了这套逻辑不管面试官怎么换着问你都能顺下来。1. 开场第一道题就把我按在板凳上说实话数据库并发控制里MVCC 算是“既高频又容易讲浅”的知识点。很多人能说出“通过多版本解决读写冲突”但后来被问到“版本存放在哪里”“怎么判断哪个版本对当前事务可见”“为什么 RR 不会出现不可重复读”时当场卡壳。那天的面试官就踩着这个套路走。他先问“MVCC 解决了什么问题”我答“读写不阻塞”。他接着问“读和写分别是读什么、写什么”我答“读是快照读写是当前读”。他点头然后立刻追问“那这个快照是怎么构建的ReadView 什么时候生成”这里其实已经脱离了背题进入原理验证阶段。为了不再写“面试官问完就没了下文”的流水账我把那次对话里涉及的技术点重新整理成一套完整的脉络。先讲底层结构再讲判断逻辑最后讲隔离级别如何驱动不同的可见性行为。这套顺序也是你在面试中“被问下去”时最安全的展开路径。1.1 面试官不是要你背“多版本”MVCC 里的“版本”是有物理载体的。在 MySQL InnoDB 引擎里一行数据上除了业务字段还藏着几个对用户不可见的字段事务 ID、回滚指针以及可能用到的隐藏主键。每次事务修改记录之前先把旧值写到 undo log再通过回滚指针把它串成一条链条。所以你随便一搜“MVCC”搜出来的版本链示意图本质上就是“当前记录 undo log 里的旧版本”。这个链条不是为查询历史数据准备的而是为了在某个事务读取时能沿着指针找到一个“它应该看见的版本”。1.2 理解 MVCC 的三个关键词真正要理解 MVCC需要抓住三块东西隐藏字段、undo log、ReadView。隐藏字段提供了定位版本的能力undo log 承载了历史版本的数据ReadView 决定了“哪些版本可见、哪些版本不可见”。很多讲解把它们割裂开讲导致背完规则却不知道数据从哪来。其实你可以把 MVCC 看作一套“时间旅行”机制每条记录都有修改时间线ReadView 就是当前事务的“时间旅行滤镜”滤镜只允许它看到那个时刻已经尘埃落定的版本。脏读、不可重复读这些异常本质上都是“滤镜”太宽松造成的。2. 先把版本链和隐藏字段拼出来MVCC 的第一步是理解 InnoDB 中一行记录到底长什么样。我经常跟人开玩笑你以为存进表里的只有字段其实引擎在暗地里给记录“贴了标签”。2.1 一行数据里不只有业务字段以这张表举例CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, balance DECIMAL(10,2) ) ENGINEInnoDB;当有一条记录(id1, balance100)时实际在 InnoDB 内部它至少还包含三个隐藏字段隐藏字段含义DB_TRX_ID最近一次修改插入或更新这条记录的事务 IDDB_ROLL_PTR回滚指针指向当前版本在 undo log 中的上一个版本位置DB_ROW_ID如果没有定义主键InnoDB 会生成一个隐藏的自增主键用于聚簇索引定位其中前两个字段是 MVCC 的核心。你可以这样理解每一条记录身上都刻着“最后一次动我的人是谁”事务 ID和“我上一个版本在哪里”回滚指针。插入操作会生成一个初始版本版本链长度为 1更新操作会在链头加一个新版本旧版本继续留在 undo log 里。2.2 一次 UPDATE 在背后做了什么假设事务 A事务 ID 为 100执行UPDATE account SET balance 200 WHERE id 1;InnoDB 的流程并不是直接覆盖给记录加排他锁。将修改前的(id1, balance100)连同事务信息写入 undo log。更新内存中的数据行将balance改为 200同时把这一行的DB_TRX_ID改成 100DB_ROLL_PTR指向刚才写入 undo log 的那个旧版本。于是内存中当前版本是(200, trx_id100, roll_ptr-旧版本)undo log 里躺着旧版本(100, trx_id上一次事务id, roll_ptr-更早版本)。这条记录在逻辑上形成了一条从新到旧的链表最新版本 → undo 版本1 → undo 版本2……这也是为什么说“多版本”并不是把多份数据都放在数据页里而是“数据页只放最新值历史值全部依赖 undo log”。搞清楚这一点面试时再被问“版本链在哪里”你就知道答案不是“在数据表”而是“最新值在聚簇索引历史值在 undo log”。2.3 版本链解决了读写不互锁的问题读写互斥的痛点在哪里如果写操作直接覆盖数据那正在执行读操作的事务就会看到变化中的值读的原子性就无法保证。一个很朴素的办法是读操作加共享锁写操作加排他锁但这样读和写就完全串行化高并发性能无从谈起。MVCC 的思路是“写的时候不覆盖旧版本而是新增版本”。读操作如果不需要最新值就去选择版本链上某个对它可见的旧版本这样读和写操作在物理上都不需要互相等待。写操作持有锁只锁住“最新版本”本身快照读走 undo log完全绕开锁。这句话就是 MVCC 解决“读写阻塞”的本质。3. ReadView 是可见性判断的“裁判”版本链摆在那里链路上一大串版本哪一个是当前事务应该看到的这就轮到 ReadView 登场。很多面试题喜欢只让背 ReadView 的字段和规则其实完全可以当成一个“资格判定问题”来推。3.1 ReadView 里都有什么ReadView 是 InnoDB 在事务执行快照读时生成的一份“快照视图”里面记录了生成时刻所有未提交事务的集合。它的核心字段包括creator_trx_id创建这个 ReadView 的事务 ID也就是“当前事务自己”。m_ids生成 ReadView 时系统中所有仍处于活跃状态未提交的事务 ID 列表。min_trx_idm_ids中最小的那个事务 ID。max_trx_id生成 ReadView 时系统为下一个事务准备的事务 ID一般来说是“最大事务 ID 1”。你可以把 ReadView 想象成一个班里的学生名单。min_trx_id是学号最小编号max_trx_id是下一位新生的预设编号m_ids是当前没有交卷的考生列表creator_trx_id则是你本人。3.2 判断规则不是死记硬背是比大小当某个事务读取一条记录时它会拿出记录上的DB_TRX_ID把这个事务 ID 和 ReadView 做四步比较。条件判定结果DB_TRX_ID creator_trx_id可见。这是自己修改的版本DB_TRX_ID min_trx_id可见。这个事务已提交且早于所有活跃事务DB_TRX_ID max_trx_id不可见。这个事务是在 ReadView 生成之后才开始的还没提交DB_TRX_ID在m_ids列表中不可见。这个事务在 ReadView 生成时仍活跃没有提交DB_TRX_ID不在m_ids中且大于min_trx_id、小于max_trx_id可见。事务在生成快照前就已提交看到没有规则的核心就是“我是否可以确定这个版本对应的修改者已经尘埃落定”。如果修改者已经被标记为提交而且它在我的快照之前就已经完成那么我就看得到如果它还没提交或者是在我快照之后才开始的新事务那对不起我看不到。3.3 一条具体记录的可见性推导光说规则太抽象我现场推一遍。假设事务 A 的事务 ID 为 20事务 B 的事务 ID 为 21。初始数据balance100的版本事务 ID 为 10已提交。执行顺序如下事务 A 开启但还没操作此时它执行第一次快照读生成 ReadView。当前没有其他活跃事务所以m_ids []min_trx_id无数值或空集max_trx_id为 22下一个分配的事务 ID。事务 B 开启执行UPDATE account SET balance200 WHERE id1;。此时版本链为最新版本trx_id21旧版本trx_id10。事务 B 尚未提交。事务 A 再次执行SELECT * FROM account WHERE id1;此时它继续使用之前生成的 ReadView 判断最新版本的DB_TRX_ID21不等于 creator20也不小于 min21 不小于所以不看它沿着回滚指针找到trx_id10的旧版本。10 小于 min_trx_id可见。事务 A 读到100。事务 B 提交后事务 A 再次执行同一查询因为 RR 隔离级别下它还是用同一个 ReadView判断逻辑不变依然读到100。这就是可重复读。但如果换成 RC 隔离级别第 3 步会重新生成一个新的 ReadView此时事务 B 已提交m_ids不再包含 21那么最新版本trx_id21落在 m_ids 之外且小于 max可见。因此事务 A 会读到200从而造成不可重复读。这个例子你如果能自己在纸上画一遍面试时基本就稳了。4. 隔离级别怎么“指挥” ReadViewReadView 的判断规则是固定的真正让隔离级别产生差异的是 ReadView 的生成时机和复用策略。4.1 两个隔离级别下 ReadView 的生命周期在 MySQL InnoDB 中READ COMMITTEDRC每次执行快照读都会生成一个新的 ReadView。也就是说一个事务里第一次 SELECT 用一个快照第二次 SELECT 再生成一个新的快照。所以这个隔离级别能避免脏读但不能避免不可重复读。REPEATABLE READRR只在事务第一次执行快照读时生成 ReadView之后整个事务内都复用这一个 ReadView。后面的所有查询都用同一把“时间尺子”去衡量版本所以不会出现不可重复读。对比项READ COMMITTEDREPEATABLE READReadView 生成时机每条 SQL 执行前都新建事务中第一条快照读语句执行时生成同一个事务两次 SELECT 是否一致可能不一致一定一致能否避免脏读能能能否避免不可重复读不能能这里要注意一个细节快照读本身并不主动加锁它在生成 ReadView 时就“冻结”了当时的事务状态。RC 之所以每次 SQL 都要重新冻结是因为业务上往往需要看到“最近已提交”的数据RR 之所以只冻结一次是为了提供一个稳定的、可重复的读视图。两者的取舍就藏在 ReadView 的“冻结频率”里。4.2 实例模拟脏读和不可重复读是如何被挡住的脏读场景事务 A 修改一行数据但未提交事务 B 去读。假设使用 RC 或 RR事务 B 在生成 ReadView 时发现这行最新版本的DB_TRX_ID仍在该快照活跃事务列表中它读不到最新值于是沿版本链找上一次已提交版本。因此脏读被挡住。不可重复读场景事务 A 读取某值后事务 B 修改并提交事务 A 再读。如果使用 RR因为 ReadView 没变事务 A 依然认为事务 B 的修改“不可见”自然重复读到旧值。如果使用 RC事务 A 第二次 SELECT 会生成新 ReadView事务 B 已经提交因此新修改变得可见从而读到新值。想深入理解的话可以自己建表开两个 MySQL 会话分别执行SELECT和UPDATE观察 RC 与 RR 的结果差异。眼见为实比死记结论牢靠得多。4.3 RR 还有幻读MVCC 当前读的边界好多人背过“RR 解决不可重复读但可能幻读”可面试官一追问“为什么 MVCC 能防幻读又不能防幻读”就懵了。其实 MVCC 主要解决的是快照读场景下的幻读。在 RR 隔离级别下事务第一次 SELECT 生成 ReadView后续相同条件的快照查询结果保持一致因此自动获得了一致的快照也就看不到后来插入的新行。从这个角度说MVCC 消除了快照读的幻读。但是如果业务里执行的是当前读比如SELECT ... FOR UPDATE、UPDATE、DELETE这些语句必须读取最新版本MVCC 的版本链帮不上忙InnoDB 就用了另一种武器next-key lock记录锁 间隙锁的组合。它会锁住查询范围里的已有记录也锁住范围内的“空隙”防止在加锁期间新记录插入。所以 InnoDB 在 RR 下能真正防住当前读幻读。面试的加分点是你要主动提到MVCC 对付的是一致性快照读next-key lock 对付的是当前读。两者合起来才能覆盖 RR 隔离级别下的一致性。5. 面试追问链从原理到场景除了上面这些主脉络面试官还会从各种角度追问试图考察你到底有没有真正理解。我把那天被追问的几类问题整理一下。5.1 删除操作和更新操作本质相同有面试官会问“删除了的记录在版本链里怎么体现”很多人以为删除是直接在物理层面抹掉记录其实不是。在 InnoDB 中删除操作更像是“打上标记的更新”。它会先写一条 undo log把记录标记为已删除同时将该记录的DB_TRX_ID更新为删除事务的 ID。这个删除版本照样在版本链上。之后某个事务如果通过 MVCC 判断该删除事务不可见就会沿回滚指针找到更早的版本在它眼中记录依然存在。只有当删除事务已经提交而且没有其他需要引用该版本的事务时后台清理线程才会真正执行物理删除。这就是为什么在一个 RR 事务里先删除某行再查询数据仍然可能是可见的因为判断依据不是物理状态而是“删除事务是否在当前快照可见”。5.2 当前读和快照读的区分再解释再强调一遍“读”被拆成两类快照读普通的SELECT不加 FOR UPDATE / LOCK IN SHARE MODE。它读的是版本链上符合 ReadView 的版本不加锁。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE。它读的是记录的最新版本并且要对读到的记录加锁。MVCC 只管快照读。当前读不用版本链它直接用锁定机制保证并发安全。面试时如果能把两种读拆分清楚再结合场景说“当前读为什么不能用 ReadView 判断”就显示你已经把 InnoDB 并发控制的整体框架装进脑子里了。5.3 “版本号”为什么用事务 ID还有一个很容易被忽略的问题为什么可见性判断用事务 ID 而不是时间戳其实事务 ID 在 InnoDB 里可以看作一种“逻辑时间戳”。它单调递增创建 ReadView 时记录活跃事务集合其实就是记录该时刻的“事务时间截面”。对比物理时间戳事务 ID 有个天然优势它严格遵循事务提交顺序。事务可能长时间不提交物理时间早的事务未必先提交成功。如果按物理时间判断可见性两个事务交错提交时结果会产生错乱。而事务 ID 配合活跃列表可以从逻辑上保证“提交顺序与 ID 顺序一致的前提下只有已提交事务才可见”这一判断是确定性且可复现的。6. 我自己准备 MVCC 的三个笨办法最后分享一点个人的备考经验。我能把 MVCC 讲得比较透靠的其实不是读更多原理文章而是三个笨办法。第一个办法画版本链。我找了一张只有 few 条记录的表手动模拟 3 个事务并发操作把每次更新后记录上的DB_TRX_ID、DB_ROLL_PTR以及 undo log 中每个版本的 ID 全部写出来。画上几遍后你对“多版本到底多在哪里”就有了不可磨灭的记忆。第二个办法手工推 ReadView。我给自己出题假设事务 ID 1 到 8 按某种顺序开启、提交、修改同一行在某个时刻事务 6 执行 SELECT判断它会看到哪个版本并写出判断过程。这比背规则有效得多因为每推一次就是重新理解一遍规则背后的排序思想。第三个办法结合隔离级别做实验。在本地起一个 MySQL开两个会话把默认隔离级别分别切成 RC 和 RR反复执行“A 读B 改A 再读”的脚本。结论不需要背诵实验看得多了自然就内化了。面试那天被问到“起手 MVCC”我顺着版本链、ReadView、隔离级别这条线一口气讲完面试官后续才把问题转到了 next-key lock 和实际业务里的死锁排查上。我后来复盘发现真正有用的不是我把概念背得多熟而是我能在脑内快速画出一条事务生命周期的时间线再用这条时间线去解释所有并发问题。这个思路才是 MVCC 问不倒的本质。