MySQL MVCC 原理详解:从版本链到 ReadView 的实现与隔离级别

发布时间:2026/10/18 4:56:31
MySQL MVCC 原理详解:从版本链到 ReadView 的实现与隔离级别
1. 为什么要折腾出MVCC读提交和可重复读背后的博弈聊到MySQL的MVCC很多人第一反应是多版本并发控制用来解决读写冲突但再往下追问为什么需要它、它到底解决了什么级别的矛盾不少人是含糊的。我最早接触这个机制时也犯过迷糊以为MVCC就是靠存多份数据来让读写互不打扰后来把事务隔离级别、锁、undolog、ReadView串在一起才真正把这块拼图补完整。先从最基础的痛处说起。假设一张用户表两个事务同时在操作事务A要更新一行数据事务B要读同一行数据。如果按传统加锁的思路读和写之间只能互相等读要等写释放锁写要等读释放锁这在高并发场景下就是性能灾难。MySQL的设计目标很明确读操作不能被写操作阻塞写操作也不能被读操作阻塞。要做到这一点最朴素的方案是让读操作去读写操作修改之前的数据快照——这就是MVCC的核心思想它不是让读写并行写同一份数据而是给读操作提供一个一致性视图。这个机制真正要解决的问题有两个层面。第一层是读写阻塞上面说的读等写、写等读的互斥问题第二层是一致性读一个事务内多次执行相同的SELECT要保证结果一致不能读到别的事务中途提交的半成品数据也不能在同一事务里一会儿看到老数据、一会儿看到新数据。第二层其实就是事务隔离级别里的可重复读语义而MySQL默认的隔离级别恰恰是RRRepeatable Read所以MVCC在MySQL里不只是优化手段更是实现隔离级别的基础设施。MVCC的全称是Multi-Version Concurrency Control多版本并发控制。拆开来看多版本指的是同一行逻辑数据在底层可能同时存在多个物理版本并发控制指的是通过版本管理来协调并发事务之间的读写顺序而不是靠粗暴的互斥锁。理解这一点的关键在于看清楚它和传统锁方案的分工MVCC负责读不阻塞写、写不阻塞读但写与写之间仍然是互斥的这个互斥的保障来自锁机制比如行锁、间隙锁。换句话说MVCC不是万能的它管的是快照读普通SELECT管不了当前读SELECT FOR UPDATE、UPDATE、DELETE这些需要读最新已提交版本的语句。知道这些背景后再看MVCC的内部构造就不会觉得数据结构是一堆冷冰冰的名词了。2. MVCC的地基隐藏列、Undo Log版本链和ReadView三位一体很多人上来就研究ReadView结果越看越懵因为ReadView的判定逻辑依赖前两个概念跳过它们直接看判定规则等于不看地图直接闯迷宫。所以这一节先把地基打扎实。2.1 一行数据里藏着多少看不见的信息InnoDB存储引擎里每行数据除了显式定义的字段之外还藏着至少三个隐藏字段。第一个是DB_ROW_ID如果表没有显式主键InnoDB会用这个字段作为隐含主键第二个是DB_TRX_ID记录最近一次修改INSERT或UPDATE这行数据的事务ID第三个是DB_ROLL_PTR回滚指针它指向Undo Log中该行修改前的版本记录可以理解为链表里的next指针。为什么需要回滚指针因为Undo Log里存的是修改前的数据。每次UPDATE发生时InnoDB不会原地物理覆盖数据而是先把当前版本的完整数据写进Undo Log形成一个旧版本记录然后让DB_ROLL_PTR指向这个旧记录再在数据页上生成新版本。这样每一个逻辑版本都通过回滚指针串成了一条链。举一个具体例子。假设有一张订单表初始数据是订单编号1001、金额200元插入它的事务ID是10。此时数据行的情况是这样的数据页上订单号1001金额200DB_TRX_ID10DB_ROLL_PTRnull没有更早版本。Undo Log里没有任何对应记录。接着事务ID为20的事务执行UPDATE把金额改成300。此时InnoDB会先把金额200的旧记录写入Undo Log再更新数据页上的金额为300DB_TRX_ID改成20DB_ROLL_PTR指向Undo Log里那条旧记录。于是版本链变成版本链方向数据页当前值(trx_id20) → Undo Log旧值金额200(trx_id10) → null如果事务ID为30的事务再执行一次UPDATE把金额改成400版本链会继续延伸数据页当前值(trx_id30) → 旧版金额300(trx_id20) → 旧版金额200(trx_id10) → null注意一个细节UPDATE在Undo Log里产生的旧版本记录本质是修改前的那一行的完整数据。而DELETE其实也是一种特殊UPDATE它的修改是把行上的删除标记位delete flag置为1Undo Log里存的是删除前的完整数据。所以事务回滚时只需要沿着版本链反向执行补偿操作即可。理解了这种结构后续读操作如何回溯历史版本就清晰了。INSERT也有Undo Log但它的旧版本记录内容相对简单因为插入前本来就没有这一行所以事务回滚时可以直接把插入的记录清除。2.2 ReadView一个事务的时间快照版本链事实上有无数个历史版本那么一个事务执行SELECT时该读哪个版本规则不能是读最新的否则可重复读无法保证——因为别的事务可能在你的事务执行中途提交了新数据也不能是读最早的否则你读到的永远是最古老的数据没有任何实用价值。InnoDB的答案是ReadView一个在事务执行快照读时生成的可见性判定视图。ReadView本质上是一份内存结构在某个时刻由系统生成记录了四个关键信息字段含义m_ids生成ReadView时当前系统中活跃未提交的所有事务ID列表min_trx_idm_ids中的最小值即最早活跃事务的IDmax_trx_id生成ReadView时系统分配给下一个事务的ID注意它不是活跃事务的最大值creator_trx_id生成这个ReadView的事务自己的ID这四个字段看起来抽象但理解它们之后可以发现MAX_TRX_ID的取值是有讲究的它取的是下一个将被分配的事务ID而不是当前活跃事务中的最大值。所以判定一个数据版本是否可见时凡是事务ID大于等于max_trx_id的版本统统不可见因为那些事务是在ReadView生成之后才开始的它们的修改不应该被当前事务看到——这保证了快照的一致性。2.3 可见性规则一条版本如何被判定为可见有了ReadView接下来就是读操作的核心算法沿着版本链从最新版本开始逐个判断版本是否对当前事务可见。判断标准分三档。第一档版本的事务ID等于creator_trx_id。说明这条版本是自己事务修改的自己的修改自己当然应该看到可见。第二档版本的事务ID小于min_trx_id。说明这个版本在ReadView生成之前就已经提交了对当前事务来说属于历史已提交数据可见。第三档版本的事务ID介于min_trx_id和max_trx_id之间。这里需要查m_ids如果这个事务ID在m_ids列表中说明它当前还活跃未提交这个版本对当前事务不可见——因为可重复读要求不能看到别的事务未提交的中间数据如果不在m_ids列表中说明它虽然是在ReadView生成前开始但生成时已经提交了可见。一个版本判定为不可见就沿着回滚指针DB_ROLL_PTR跳到链上的下一个版本继续按同样的规则判定直到找到一个可见版本或者走到版本链尽头。版本链尽头通常对应某个已提交的老版本最终一定能找到一个可见版本。这种逐版本回溯的机制就是MVCC里读老版本的本质。从内存结构看新版本和旧版本都真实存在只是可见性规则决定了哪个版本被暴露给当前事务。3. ReadView的生成时机与两种隔离级别的分水岭ReadView的判定规则本身没有哪个字段是MySQL专属魔法真正的分水岭在于ReadView是什么时候创建的。不同隔离级别下创建时机完全不同这也是读已提交RC和可重复读RR行为差异的根本来源。3.1 读已提交每次SELECT都重新生成ReadView在RC隔离级别下事务内每一次执行快照读SELECT都会重新生成一个新的ReadView。这意味着事务A执行两条SELECT之间如果事务B提交了修改事务A第二条SELECT生成ReadView时事务B的ID已经不在m_ids里了就能看到B提交后的新版本数据。这个行为对应RC的语义在一个事务里你能看到别的事务已提交的最新数据。好处是数据新鲜坏处是同一个事务内多次查询的结果可能不一致出现不可重复读。用生活类比RC相当于你每次打开微信群聊都重新刷新别人刚改的群昵称你立刻能看到但你连发两条全体成员时看到的成员列表可能不一样——因为有人在你两次刷新之间改了昵称。3.2 可重复读只在首次快照读时生成ReadView在RR隔离级别下事务内首次执行快照读时生成ReadView之后整个事务复用同一个ReadView。这个设计上的一个细节很多人会漏掉不是事务一开始就生成而是第一次SELECT才生成。所以一个事务先执行UPDATE/DELETE当前读再执行SELECT快照读生成ReadView的时机点在第一个当前读附近ReadView的内容取决于当前读那一刻的活跃事务列表。复用的效果是事务A第一次SELECT之后哪怕事务B提交了新数据事务A之后的所有SELECT依然用最初那个ReadView判定B的新数据对A始终不可见。这保证了可重复读——同一个事务内多次查询结果一致。回到群聊类比RR相当于你第一次打开群聊时把成员列表截图保存了后面哪怕别人改了昵称、退了群你翻看自己截图时看到的还是最初那份名单。一致性有了但代价是看不到别人后来提交的数据这在某些需要实时感知外部变化的场景可能成为问题。3.3 两种生成时机带来的现象差异两种生成时机的差异直接体现在两个经典现象上。第一个是不可重复读RC下能复现RR下不会。第二个是幻读这个要谨慎表述——严格意义上的幻读同一查询条件返回不同行集合在RR的快照读下不会出现因为所有快照读都基于同一个ReadView行集合固定但在当前读比如SELECT FOR UPDATE、UPDATE下RR仍有可能会出现幻读风险这正是InnoDB引入间隙锁和next-key lock的原因之一。后面第5节我会用一个完整的多事务场景把RC和RR下ReadView的差异用可执行SQL一步步验证比只看概念清楚得多。4. 当前读、快照读和锁定读MVCC管不到的另一半世界MVCC经常被误解为所有读都不加锁实际完全不是。MySQL的读分两种快照读和当前读。MVCC只管快照读的可见性问题当前读走的是另一套基于锁的逻辑。快照读就是普通的SELECT语句不加任何锁直接基于ReadView从版本链中选取可见版本不需要等待别的事务释放锁也不会阻塞别的事务写入。这是MVCC发挥作用的主力场景。当前读则相反它读取的一定是最新已提交版本并且会对读取的记录加锁防止并发修改。典型语句包括SELECT ... FOR UPDATE加排他锁X锁SELECT ... LOCK IN SHARE MODE加共享锁S锁UPDATE加排他锁DELETE加排他锁为什么UPDATE和DELETE算当前读因为修改操作必须基于最新数据你不能拿着一个历史版本去改否则会产生更新丢失。所以UPDATE语句执行时哪怕前面已经有快照读它依然走当前读读取最新版本并在上面加X锁。来看一个实际场景。事务A执行SELECT * FROM t_order WHERE order_id 1001; -- 此时是快照读MVCC介入 UPDATE t_order SET amount amount 50 WHERE order_id 1001; -- 此时是当前读MVCC不介入走锁逻辑事务A的UPDATE会尝试对order_id1001的记录加X锁。如果事务B恰好在此之前也对该记录加了X锁比如也在执行UPDATE事务A的UPDATE会被阻塞直到B提交释放锁。但同样情况下如果事务B只是执行了普通的SELECT快照读事务A的UPDATE完全不会被阻塞——这就是MVCC和锁机制各管一块的分工。理解当前读和快照读的区别还有一个实际意义排查线上死锁或者行锁等待时如果只盯着慢SQL里的SELECT往往会漏掉真正的元凶——可能是一条看起来无害但实际是当前读的UPDATE语句。我在实际运维中遇到过不止一次应用侧发的SELECT偶发阻塞排查半天发现是同一事务里先SELECT后UPDATEUPDATE被另一事务的行锁卡住了。先搞清楚语句属于哪种读再去看锁等待方向才对。有一点值得强调MVCC解决的是读写冲突让读永远不阻塞写、写永远不阻塞读。但写写冲突仍然要靠锁解决也就是两个事务同时更新同一行必然有一个需要等待。很多人把这个边界搞混以为MVCC实施后写操作就完全无锁了——不是这样的写操作仍然需要行锁来保证唯一性和有序性。MVCC只是把锁的竞争范围压缩到写写之间这已经大幅提升了并发度。5. 三个事务的并发实验ReadView到底怎么影响你的查询结果概念说多了容易绕用一个可重复执行的多事务场景把RC和RR行为差异跑一遍边看结果边对着ReadView分析理解会牢固得多。模拟场景用的是RR隔离级别MySQL默认通过调整事务的提交时机来演示。先建一张操作演示表CREATE TABLE t_demo ( id INT PRIMARY KEY, amount INT ); INSERT INTO t_demo VALUES (1, 100);假设事务A、B、C并发操作id1这一行。为了便于描述给它们编号A的trx_id10B的trx_id20C的trx_id30实际事务ID由系统分配这里简化说明逻辑。场景一B未提交A快照读事务A执行SELECT * FROM t_demo WHERE id1生成ReadView。此时事务B尚未执行活跃事务列表里只有A自己如果是空事务列表m_ids里至少包含A的trx_id10。A读到amount100符合预期。事务B执行UPDATE t_demo SET amount200 WHERE id1但先不提交。B拿到X锁新版本DB_TRX_ID20版本链变成当前值200(trx_id20) → 旧值100(trx_id10)。事务A再次执行SELECT * FROM t_demo WHERE id1快照读。因为A复用首次生成的ReadViewm_ids里包含B的trx_idB还活跃着所以版本链最新节点trx_id20被判为不可见沿指针回溯到trx_id10的版本看到amount100。这说明RR下A看不到B未提交的修改也不会看到B修改后的中间状态。场景二B提交后A继续快照读事务B提交commit。此时数据页上当前版本仍然是amount200trx_id20。事务A第三次执行SELECT * FROM t_demo WHERE id1。在RR下A仍然用首次生成的ReadViewm_ids里的B已经不在但A的ReadView里min_trx_id和m_ids都是当初那一刻的状态所以对trx_id20这条记录判定的结果取决于ReadView的max_trx_id和m_ids数据。由于A的ReadView固定了B未提交的可见性判定它依然不认可trx_id20这个版本继续回溯到旧值100。实验结果A始终读到100。这就是可重复读语义的直观体现——哪怕B提交了A的会话内快照始终不变。场景三RC下的对比把隔离级别调成RC重复同样的步骤事务B提交后事务A再次执行SELECT。此时A会重新生成ReadView新ReadView里m_ids不再包含Bmin_trx_id可能变成别的值判定trx_id20时发现B已提交直接读到200。实验结果A第二次读到200第一次读到100——不可重复读现象复现。这个对比解释了面试里常问的为什么RC会有不可重复读RR不会核心就在ReadView的生成时机和复用策略。RC每次查询都重新拍照RR只在第一次查询时拍一张照片反复用。对于当前读可以用另一个小实验验证A先执行SELECT amount FROM t_demo WHERE id1 FOR UPDATEB随后执行UPDATE t_demo SET amount300 WHERE id1B会阻塞等待A的事务结束才能继续。因为FOR UPDATE是当前读它直接锁定该行的最新版本。这个实验建议自己动手跑一遍观察锁等待时间、版本链变化比单纯背书印象深很多。6. 从源码层面看MVCC可重复读的ReadView和版本链细节有人在理解层面觉得已经够用了但MVCC的很多实际行为细节不进入源码层的逻辑是解释不清的。我在排查问题时发现为什么某些查询在RR下还是看到了新数据、为什么长事务会撑大Undo Log这类问题只有回到源码实现才能真正讲透。6.1 ReadView在源码里到底存了什么ReadView的核心实现在存储引擎层的read_view结构体里。关键字段包括m_low_limit_id即前文说的max_trx_id生成ReadView时系统尚未分配的下一个事务ID。所有trx_id大于等于它的版本都不可见。m_up_limit_id即min_trx_idm_ids里的最小值。m_ids活跃事务ID列表通常是一个数组维护有序或无序的活跃事务集合。m_creator_trx_id当前事务ID。可见性判断的核心函数会先判断版本trx_id与m_up_limit_id、m_low_limit_id的关系再查m_ids。如果要深入研究可以看存储引擎源码中关于ReadView可见性判断的实现逻辑跟前面描述的基本一致只是围绕索引查找版本的路径更复杂。提示这里的源码实现是指InnoDB存储引擎层面的C实现普通使用者不需要精细读代码但了解字段含义有助于排查异常行为。6.2 为什么说首次快照读而不是事务开始RR下ReadView不是事务一开始就建立而是执行第一条快照读语句时建立。这个语义非常重要因为它影响一个经典问题的答案如果事务先执行了UPDATE当前读再执行SELECT快照读ReadView里记录的活跃事务列表会包含哪些人从源码执行角度看UPDATE会获取最新版本并加锁此时系统里活跃事务情况会影响后续ReadView的内容。假设A先UPDATE了某行B在A还未提交时也UPDATE了同一行并提交那么A后续快照读能否看到B的结果从语义上因为A的UPDATE是在B之前的当前读之后B提交了A再次快照读时ReadView的生成时机取决于首次快照读是否确实发生在B提交之后。如果首次快照读在B提交之后生成B的trx_id不在m_ids里A就能看到B提交的结果。这个行为常常让刚接触MVCC的人困惑不是说RR下看不到别人提交的数据吗这个结论只限定在首次快照读之后的场景。这里给了实际工作者一个重要提醒线上排查RR下的数据一致性问题时不要把RR看不到已提交数据当成绝对规则要先明确事务里第一条语句到底是普通SELECT还是UPDATE/DELETE当前读。很多RR下出现幻读的误报其实是当前读引发的锁和版本变化造成的。6.3 Undo Log版本链的清理策略版本链不会无限保留所有历史版本。当一个旧版本不再被任何活跃事务需要时InnoDB的purge线程会把它物理删除。判定不再需要的条件包括没有活动的ReadView还引用它、没有事务需要回滚到它之前的版本等。这也是为什么RR下长事务会导致Undo Log膨胀——长事务持有最初的ReadView所有比它新的版本都被判定为不可见为了给这个ReadView提供回溯数据版本链上的旧版本不能清理。一旦ReadView被释放事务结束版本链上不再被引用的部分就可以被purge线程清理。我在真实环境中遇到过因为一个持续数小时的报表事务导致Undo Log文件暴涨、磁盘吃紧的问题。根因就是那个报表事务在RR下长时间持有一个ReadViewInnoDB为了保护快照一致性只能留住大量旧版本。处理方案不外乎两类缩短长事务的执行时间或者调整事务隔离级别为RCRC每次SELECT重新生成ReadView旧版本可以更快被清理。从源码层面理解MVCC最大的收益是排查问题时不再对着现象猜原因。比如数据不一致、长时间未提交、Undo Log异常增长这些都可以回到ReadView生命周期和版本链回收条件找到方向。7. 日常使用MySQL时理解MVCC能帮你解决哪些实际问题7.1 面儿上的概念之外先检查你的隔离级别心理预期很多人在项目里把事务隔离级别设成RR却没意识到默认RR下快照读看的是旧快照这个特性对业务的影响。我见过一个真实案例支付系统里一个事务先查询订单状态然后基于查询结果做后续判断结果因为RR的一致性视图读到的订单状态一直是事务开始时的旧值导致主流程判断失误。排查下来不是数据更新不对而是查询被MVCC保护得不该保护了——业务根本不要求可重复读反而需要读到最新已提交数据。换成RC后问题立刻消失。这里有一个关键判断标准如果你的业务事务中需要多次读取同一行数据且要求结果一致比如报表统计、对账校验用RR合理如果你需要每次读到的都是最新已提交状态比如订单状态流转判断用RC更符合直觉。7.2 锁等待超时和死锁先分清是MVCC能解决的还是不能解决的MVCC让读不阻塞写但写写互斥依然存在。实际排查中某个UPDATE为什么等了半天都没执行下去这类问题99%不是因为MVCC失效而是当前读之间的锁竞争。遇到锁等待超时步骤应该是用SHOW ENGINE INNODB STATUS查看事务和锁信息确认哪条语句在等哪条锁。检查当前读语句的WHERE条件是否命中了同一组记录命中范围是否过大比如无索引条件下全表扫描导致锁范围放大。优化方向缩小事务处理的数据量、适当调整索引让锁范围精确、把长事务拆短。MVCC在这里能帮的忙是如果两个事务一个是快照读、一个是当前读它们可以并行不阻塞如果两个都是当前写只能靠锁队列排队。理解这个边界就不会在网上查到MVCC解决并发之后以为所有并发问题都能靠它解决。7.3 长事务检查和Undo Log监控前文提到过长事务是MVCC最容易被忽视的副作用。实操层面的监控手段包括通过information_schema.innodb_trx查看长时间未提交的事务及其SQL。关注Undo Log表空间大小增长情况异常膨胀往往和活跃长事务强相关。设置合理的innodb_purge_threads并确保purge线程正常工作。按我的经验数据库运维中最常见的诡异慢查询排查最终都会回到某个事务长时间持有了一个ReadView这层。所以每次线上变更或报表任务前养成先检查和预估事务时长的习惯比事后调参省力得多。7.4 基于MVCC的版本号乐观锁实践理解MVCC里多版本的思想后另一个实用技巧是实现业务层的乐观锁。比如更新用户余额时与其用悲观锁锁住整行不如在表中加一个version字段UPDATE user_account SET balance balance - 100, version version 1 WHERE user_id 123 AND version 5;执行后影响行数为0说明version已被其他事务改过需要重新读取最新版本再重试。这个模式在业务性能压力大、并发冲突不频繁的场景下非常实用比直接加SELECT FOR UPDATE更轻量也能避免大量锁等待。它本质上就是借用MVCC多版本记录版本比对的思路在应用层实现的并发控制。8. 容易被误解的几个细节MVCC不是只加不加锁那么简单8.1 MVCC不是纯无锁方案很多人把MVCC简单理解成无锁并发这是最大的误区。InnoDB中MVCC快照读确实不加锁但当前读和写操作依然要走锁机制。写写冲突必然需要锁来协调这是数据库保证一致性的底线。MVCC的实际价值是把读写锁互斥变成了读写各走各的通道仅在写写之间保留锁。所以严格说MVCC是读不加锁、写锁依旧。8.2 快照读看到的不是历史数据库快照读看到的是一致性版本但这不等于读到数据库过去某个时刻的完整快照。它只是基于ReadView在版本链上找到符合条件的可见版本。如果某行数据根本没有被修改过那么快照读看到的和其他读看到的没有区别只有那些在ReadView生成之后被修改过的记录才会用到历史版本。这也是为什么RR下长事务查询速度不一定变慢——版本链回溯不是全表扫描只是在特定行的版本链上走一小段。8.3 当前读不产生新的ReadView执行UPDATE、DELETE等当前读时会加锁读取最新版本但不会因此创建新的ReadView。ReadView由快照读触发创建当前读本身不创建ReadView。但当前读会影响后续快照读时ReadView里的活跃事务信息因为当前读可能在那一刻观察到其他事务的状态这是一个容易混淆的点。8.4 RR下的幻读问题并非完全消失如前所述RR下快照读的幻读被MVCC挡住了但当前读场景下如果两个事务同时对某个范围做当前读仍可能插入新行后导致另一方读到不同结果。InnoDB用间隙锁Gap Lock和next-key lock来限制插入操作的范围从而尽可能避免这种幻读。gap lock和MVCC是两套机制共同服务于RR的隔离目标这点面试时容易串。理解这些细节的价值在于它能帮你准确描述MySQL到底提供了什么样的并发语义在业务设计、问题排查、性能调优时都能做出更合理的选择。9. 从MVCC走向更高层的并发设计你的系统真的需要可重复读吗最后一个话题与其说是技术不如说是工程判断。MVCC帮我们实现了可重复读但可重复读不一定是业务需要的。很多互联网业务场景比如库存扣减、秒杀、订单状态流转天然只关心最新的已提交数据。在这些场景用RR反而带来副作用快照一致性视图导致读到旧数据间隙锁放大锁范围长事务需要更长的Undo Log保留。把隔离级别改成RC配合合理的业务重试机制往往能在保证一致性的前提下提升并发度和运维稳定性。MySQL 8.0之后innodb_default_row_format、新版本优化器、Undo Log自动截断等能力都在改善长事务场景下的资源消耗但RR下ReadView复用的语义没有改变。所以选哪个隔离级别还是要回到业务需求去权衡。如果你在一个报表或对账系统里希望一个事务内多次查询结果稳定RR是正确的如果你的应用每次读都需要刚提交的最新结果RC会更符合直觉。MVCC给了你选择的权利但用哪把钥匙开哪扇门取决于你对场景的判断。工具只是工具理解它为什么存在、管什么东西、不管什么东西比背下一个定义重要得多。希望这篇文章能帮你在实际工作和面试里把MySQL MVCC这块拼图拼到它真正的位置上去。