1. 从面试翻车说起:MVCC为什么值得花时间搞懂
1.1 那一场面试到底败在哪
先还原一下当时的场景。
面试官问:“谈谈你对MySQL的MVCC的理解。”
我当时心里一喜,这题我背过。于是张口就来:“MVCC是多版本并发控制,它通过保存数据的历史版本,让读写操作互不阻塞,从而提升数据库的并发性能。”
说完我就等着面试官点头,结果对面沉默了两秒,接着问了一句:“那这个多版本是怎么存下来的?什么时候会生成一个ReadView?”
我卡住了。接下来他追了第三个问题:“为什么在RC隔离级别下,同一个事务里两次SELECT的结果会不一样,而RR下就不会?”到这里我已经没法接话了。最后面试官客气地说“先回去等消息吧”,我就知道这轮基本结束了。
后来复盘,我发现自己犯了一个很典型的错误:把“概念性的了解”当成了“真正的理解”。我知道MVCC是个什么东西,能说出两三个关键词,但一旦往下追问到“底层怎么组织数据版本”“ReadView的判断规则是什么”“隔离级别的差异怎么体现在机制上”,就露馅了。
这也是我写这篇文章的原因。MVCC是MySQL面试中几乎绕不开的高频考点,也是理解InnoDB事务隔离、锁机制、性能调优的一条关键线索。如果你正在准备后端开发岗位的面试,或者想系统搞懂MySQL的事务原理,这篇文章可以帮你把这条线一次拉通。
1.2 MVCC能解决什么问题,不能解决什么问题
MVCC全称是Multi-Version Concurrency Control,多版本并发控制。它在数据库里解决的核心问题,可以用一句话概括:让读操作和写操作尽量不互相阻塞。
想象一个场景。事务A正在修改某一行数据,事务B同时想读这一行。如果数据库使用朴素的加锁方案,那B只能等A提交之后才能读,这在高并发的业务下是不可接受的。MVCC的思路是:A在修改的时候不覆盖旧值,而是生成一个新版本,B来读的时候如果发现新版本还没提交或者不该看见,就顺着版本链去读旧版本。这样一来,读和写操作各走各的路,谁也不用等谁。
但要注意,MVCC并不是万能的。它解决的是读写并发之间的阻塞问题,对于写写冲突——也就是两个事务同时修改同一行——依然要靠锁来保证。另外在RR隔离级别下,MVCC只能解决快照读的幻读问题,对于当前读场景下的幻读,还得靠Next-Key Lock来兜底。这一点在后面的章节会详细展开,这也是面试官最喜欢用来挖坑的地方。
把这两个边界先想清楚,你对MVCC的定位就不会再停留在“一个名词”的层面了。
2. MVCC的地基:InnoDB的隐藏字段与Undo Log版本链
2.1 每行记录背后的三个隐藏字段
要理解MVCC,第一个需要改变的认知是:InnoDB表里每一行记录,并不只有你建表时定义的字段。
InnoDB在存储每一行数据时,会自动附加几个隐藏字段。其中三个和MVCC直接相关:
- DB_TRX_ID:记录最近一次修改(插入或更新)这行数据的事务ID。
- DB_ROLL_PTR:回滚指针,指向这行数据在Undo Log中上一个版本的位置。
- DB_ROW_ID:如果没有显式定义主键且没有唯一键,InnoDB会自动生成一个隐藏主键,就是这个字段。它本身和MVCC关系不大,但因为有主键的情况下它不出现,很多人会把它和MVCC混淆,这里提一句。
面试时说到MVCC,最好能从这三个字段开场,这比直接背定义要显得专业得多。
这里有第一个容易踩的坑:DB_TRX_ID记录的是“最后一次修改这行数据的事务ID”,但不同隔离级别下,这个ID会不会被其他事务看见,是由ReadView来决定的,而不是说事务ID大就一定看不见。很多人在这里会想当然,后面我们会仔细讲。
2.2 Undo Log怎么串起版本链
隐藏字段只是标记了位置,真正的数据版本存在Undo Log里。
当事务A执行一条UPDATE语句时,InnoDB不会直接覆盖掉磁盘上的旧数据,而是先把旧行的内容写入Undo Log,再把新值写到当前行,同时完成三件事:更新DB_TRX_ID为事务A的ID,把DB_ROLL_PTR指向刚写入Undo Log的那个旧版本。这个过程可以用一张图来理解(文字版):
当前行 (id=1, name='张三', age=25, DB_TRX_ID=100, DB_ROLL_PTR ---> 版本2) ^ | 版本2 (name='张三', age=20, 由事务80写入,DB_ROLL_PTR ---> 版本1) ^ | 版本1 (name='张三', age=18, 由事务50写入,DB_ROLL_PTR=NULL)版本链的链头是当前行,越往链表深处走,数据越老。每次对同一行执行UPDATE,都会往链表头部新增一个版本,而链尾通常是这条记录最初被INSERT进去时的原型。
这里有一个非常容易忽略的细节:INSERT的时候不会往前版本链上追加旧版本,因为插入之前这一行根本不存在,没有“旧值”可以保存。所以插入操作只生成一个新版本,DB_ROLL_PTR为空。只有UPDATE和DELETE才会产生旧版本——DELETE本质也是一次UPDATE,只是把删除标记位置位。
面试时如果能把“每次更新都会往版本链加一个节点”这个动作讲清楚,就已经比一半候选人强了。
2.3 版本链上的数据怎么读出来
明白了版本链的结构,接下来的关键问题是:当一条SELECT语句到达这行记录时,InnoDB到底该读哪个版本?
它不能无脑读当前版本,否则事务B就读到了事务A未提交的修改,破坏了隔离性。它也不能无脑读最早的版本,否则数据就和最新状态完全脱节了。
InnoDB的机制是:每个事务在特定时机生成一个快照,这个快照里记录了一些事务ID的边界信息,称为ReadView。InnoDB读取一行记录时,先看当前版本的DB_TRX_ID,然后拿着这个ID去ReadView里做可见性判断。如果这个版本可见,就直接读它;如果不可见,就通过DB_ROLL_PTR找到上一个版本,再重复这个过程,直到找到可见版本或者到达版本链尽头。
这个过程可以类比成一个场景:你面前排列着这件商品的多个历史标价,收银员不直接采用最新标价,而是根据一张“当前可以相信哪些交易批次”的清单,从新到旧逐个比对,找到第一个在清单里合法的标价。这个清单就是ReadView。
所以MVCC的完整链条其实由三部分组成:隐藏字段负责记录版本归属和指向关系,Undo Log负责存储历史数据,ReadView负责判定版本可见性。三者缺一不可。
3. ReadView决策规则:MVCC的核心判断逻辑
3.1 ReadView里都装了什么
ReadView是MVCC最核心的组件,也是一个最容易讲不透的点。
一个ReadView里主要包含以下信息:
- m_ids:生成ReadView的时刻,当前系统中所有“活跃”事务的ID列表。所谓活跃事务,指已经启动但还没提交的事务。
- min_trx_id:m_ids中最小的那个事务ID。
- max_trx_id:生成ReadView时,系统分配给下一个新事务的ID。注意,这个值不是当前活跃事务中的最大值,而是“下一个将要分配的事务ID”,可以理解为活跃事务ID的上界。
- creator_trx_id:生成这个ReadView的事务自己的ID。
这里面最容易混淆的是max_trx_id。它不等于当前活跃事务的最大ID,而是“下一个未分配的事务ID”,所以它天然比当前所有已存在的事务ID都大。在可见性判断时,事务ID大于等于max_trx_id的版本,一定是ReadView生成之后才产生的事务,当前事务绝对不能看见。
3.2 可见性判断的四个问题
当一个事务拿着ReadView去判断某个数据版本的可见性时,InnoDB实际执行的是这样一套规则,拿被访问版本的DB_TRX_ID记为trx_id,依次判断:
- 如果trx_id等于creator_trx_id,说明这个版本是当前事务自己修改的,可以看见。
- 如果trx_id小于min_trx_id,说明这个版本在ReadView生成之前就已经提交了,可以看见。
- 如果trx_id大于等于max_trx_id,说明这个版本是ReadView生成之后才启动的事务修改的,不能看见。
- 如果trx_id在min_trx_id和max_trx_id之间,那么去m_ids列表里查:如果trx_id在m_ids里,说明这个事务还没提交,不可见;如果不在m_ids里,说明这个事务已经提交了,可见。
把第4步展开来说:一个事务ID落在活跃列表里,意味着ReadView生成时它还在执行中,那么即便它在之后提交了,这个ReadView也不承认它的修改。这就是快照读的“时间冻结”效果的来源。
3.3 为什么RC和RR行为不一样
讲清楚了ReadView的规则,就很好解释RC和RR隔离级别的核心差异了。
在**RC(读已提交)**隔离级别下,事务执行过程中,每条普通SELECT语句都会生成一个新的ReadView。这意味着每次SELECT看到的“快照”都是最新的,能看到其他事务刚刚提交的数据,因此同一个事务里两次SELECT的结果可能不同。这就是不可重复读。
在**RR(可重复读)**隔离级别下,事务第一次执行快照读时生成ReadView,之后整个事务内部的所有快照读都复用这同一个ReadView。无论其他事务在这期间提交了多少数据,快照读看到的版本边界始终不变,所以两次SELECT结果一致。这就是可重复读的底层来源。
这里有一段我自己的理解,分享给读者:RC和RR的差别,本质上不是“能不能看见已提交数据”的机制不同,而是ReadView生成时机和复用策略不同。很多面试者把RC和RR的区别背得滚瓜烂熟,却说不清背后的驱动机制,面试官一旦追问就露馅。反之,如果你能把“RC每次新建ReadView、RR复用第一个ReadView”这句话说出来,面试官基本能确认你是真的理解了。
4. 快照读与当前读:MVCC为什么不能单打独斗
4.1 快照读:不加锁的读
普通SELECT语句在InnoDB里执行时,走的是快照读。它直接依赖ReadView从版本链中选取可见版本,不需要加任何锁,因此也不会阻塞其他事务的写操作。这是MVCC发挥作用的主战场。
快照读有几个特点值得单独强调:
- 它读取的可能是历史版本,而不是最新已提交版本。
- 在RR下,整个事务共享同一个ReadView,所以读到的数据是事务开始时的快照。
- 在RC下,每次SELECT都刷新ReadView,因此读到的数据相对更新。
很多人在使用ORM时会遇到一个经典问题:事务A更新完数据后,在同一个事务里用SELECT去查,发现查到的还是旧值。排除代码问题后,很可能就是快照读在起作用——UPDATE本身是当前读,但随后的SELECT是快照读,在RR下它复用事务开始时生成的ReadView,自然看不到更新后的数据。如果你不了解这个机制,排查这类问题会非常痛苦。
4.2 当前读:必须加锁的读
和快照读相对的是当前读,它读取的是最新已提交版本的记录,并且在读取时会对记录加锁。
触发当前读的语句包括:
SELECT ... FOR UPDATE(加排他锁)SELECT ... LOCK IN SHARE MODE(加共享锁)UPDATEDELETEINSERT
为什么这些操作要走当前读?因为写操作需要基于最新的数据状态执行,否则就可能出现覆盖更新的问题。如果UPDATE也走快照读,事务A基于旧版本去做修改,事务B又基于另一个版本去做修改,最后到底以谁为准就完全乱套了。
MVCC在这里就体现出边界了:它只能让快照读不阻塞写、写不阻塞快照读,但当前读和写操作之间仍然需要锁来保证串行化处理。这也是面试中很关键的一句话:MVCC不是银弹,写写冲突始终由锁机制控制。
4.3 RR隔离级别下幻读是怎么被兜住的
幻读指的是同一个事务里,两次相同条件查询返回了不同的行集合,多出了新插入的行。
在RR隔离级别下,这个问题被两个机制联合解决:
- 快照读场景下的幻读,由MVCC解决。由于第二次快照读复用第一个ReadView,事务看不到其他事务新插入并已提交的行,自然也就没有幻读。
- 当前读场景下的幻读,由Next-Key Lock解决。Next-Key Lock是记录锁和间隙锁的组合,它在锁定命中记录的同时,也锁住了记录前面的间隙,阻止其他事务在间隙中插入新记录。
面试官特别喜欢在这里埋一个陷阱:他会问你“RR能完全避免幻读吗?”。
正确的回答是:不能完全避免。如果事务先做了一次快照读,然后另一个事务插入了一条新记录并提交,之后该事务再用当前读(比如SELECT ... FOR UPDATE)去查,就可能看到这条新插入的记录,因为新增记录的间隙锁并不会阻塞当前读本身,只有插入操作之间的间隙锁会互相冲突。换句话说,当前读可能依然会产生幻读,只不过InnoDB用锁把这个场景限制得很窄了。
能答出这个边界,面试官基本就会相信你是真的在源码层面或者机制层面做过功课,而不是只会背概念。
5. 面试官到底想听什么:一套能过关的回答结构
5.1 从表层到源码的递进式回答
经过前面几轮的复盘,我总结出了一套自己觉得比较稳妥的回答结构。面到MVCC时,不要上来就背定义,而是按下面的顺序递进:
第一步,先用一句话定位问题:“MVCC是多版本并发控制,主要目的是在不加锁的情况下解决读写互相阻塞的问题,它依赖隐藏字段、Undo Log版本链和ReadView三者配合实现。”
第二步,展开版本链:“每一行数据都有DB_TRX_ID记录最近修改它的事务ID,DB_ROLL_PTR指向Undo Log中的旧版本,UPDATE会产生新版本并把旧版本推入链中,DELETE本质上是更新删除标记。”
第三步,讲清楚ReadView规则:“ReadView里有m_ids活跃事务列表、min_trx_id、max_trx_id和creator_trx_id,通过比较被访问版本的trx_id与这些边界的大小关系以及是否在活跃列表中,决定该版本是否可见。不可见就沿回滚指针找上一版本。”
第四步,落到隔离级别:“RC下每条SELECT都生成新ReadView,所以能读到已提交的新数据;RR下整个事务复用第一个ReadView,所以可重复读。但RR下当前读产生幻读的问题还需要Next-Key Lock来解决。”
这个结构的好处是层层递进,每一层都是面试官可能追问的下一层。你主动把框架搭好,对方大概率会顺着你的节奏往下问,而不是随机抽一个点把你问懵。
5.2 追问环节的常见分支和应对
如果面试官在你讲完之后继续追问,通常绕不开这几个方向:
第一,长事务会不会导致Undo Log膨胀?
会。版本链上的旧版本不能立刻清理,因为长事务的ReadView一直存在,那些对旧版本可见的事务可能还需要读取它们。Undo Log会持续累积,直到这个最长事务结束。所以生产环境要避免开长事务,这也是MySQL性能调优里经常提到的一点。
第二,为什么RR下有时还能看到其他事务刚插入的数据?
这就回到了当前读与快照读的区别。如果是快照读,绝对看不到;如果是当前读,在有锁配合的情况下,插入操作后的当前读有可能读到新版本。遇到这个场景,先问对方说的“看到”是基于哪种读操作。
第三,MVCC、锁和隔离级别三者怎么关联?
MVCC服务于快照读,锁服务于当前读,隔离级别决定ReadView的生成策略和锁的加锁范围。RC只取消间隙锁,RR保留间隙锁,可串行化则几乎给所有读都加锁。你能把这三者之间的关系捋顺,这一题就基本拿下了。
5.3 我给后来者的建议
经历过这次面试,我开始用“面试官向下追问三层”的方法来检验自己是不是真的懂了某个技术点。对于MVCC来说,这三次追问分别是:版本怎么存、可见性怎么判、隔离级别差异怎么来。如果你能不看资料,完整地把这三层讲给一个不懂的人听,并且让他听明白,那才叫真的理解。
另外一个很实用的方法是画时序图。把事务A、B、C对同一行数据的操作顺序画出来,手动模拟每个时刻ReadView的内容和可见性判断结果。我当时反复画了不下五遍,每一遍都会发现新的理解偏差。这一招比背十遍面试题都管用。
最后分享一个写代码时的小技巧:如果你发现不明不白的“脏读”“重复读”问题,先确认当前事务隔离级别是什么,再确认执行的是快照读还是当前读,基本能定位九成的问题。MVCC不只是一个面试题,在日常排查并发问题时,它就是你手里的第一把工具。把这条链路吃透,以后无论面试还是工作,都能少走很多弯路。