news 2026/10/19 14:29:39

MySQL MVCC原理详解:从隐藏列、undo log到ReadView的完整机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL MVCC原理详解:从隐藏列、undo log到ReadView的完整机制

面试聊到MySQL,十个面试官有九个会问MVCC。八个候选人里,五个能像背课文一样说出“MVCC就是多版本并发控制,通过隐藏列、undo log、ReadView实现”,但再往下追问一句“RC和RR的ReadView到底哪里不一样”,或者“版本链是怎么一步步长出来的”,基本就卡壳了。八股文背得再熟,不理解底层机制,线上出问题照样抓瞎。

我自己做过很长一段时间后端和数据相关的工作,说句实在话,MVCC并不算多难,难点在于它把好几个机制串在了一起,隐藏列、undo log、ReadView、隔离级别、当前读和快照读,哪个环节没串起来,理解就会断层。这篇文章我就把这些环节一个个拆开,结合一条数据从插入到修改的全过程,把MVCC的真实工作方式讲透。不管你是正在准备面试,还是日常排查数据库问题,看完都应该能有一个比较完整的框架。

1. 先从并发场景说起:为什么数据库非要搞出“多版本”

1.1 读写冲突是并发系统绕不开的核心矛盾

任何一套业务系统,只要用户量上来,并发读写就是躲不掉的。拿一个最简单的电商订单表来说,用户下单要insert,后台改状态要update,用户查订单要select。多个请求同时落在同一张表、甚至同一行数据上,数据库必须保证数据不出乱子。

最朴素的做法就是加锁。读之前加共享锁,写之前加排他锁,读写互相阻塞。这样做数据绝对安全,代价就是并发能力直线下降。一个写操作长时间占用锁,所有读操作都得排队等着,用户那边表现就是页面卡顿、接口超时。一个读操作占用共享锁,写操作也得等着,系统吞吐量上不去。在互联网高并发场景下,这种“一刀切”的加锁方案根本撑不住。

所以InnoDB引入了MVCC,全称Multi-Version Concurrency Control,多版本并发控制。核心思想一句话就能说清:一条数据在数据库里并不是只有一个“最新值”,而是同时存在多个历史版本。读操作去读符合条件的“旧版本”,写操作去改“最新版本”,读和写各自干各自的,互不堵路。

1.2 把“一把大锁”变成“多条车道”

用生活里的场景来类比,假设你和同事在协作编辑一份在线文档,A同事正在修改某个章节,如果此时其他同事都不能看这个章节,那协作效率会非常低。MVCC的思路相当于:A同事修改的同时,其他人看到的是这个章节上一秒保存的版本,等A改完提交,大家再看到最新版本。修改的瞬间不阻塞阅读,阅读的瞬间也不阻塞修改。

放到数据库里,MVCC最大的价值就是实现了“读写不互斥”。读操作不需要等待写操作释放锁,写操作也不需要等待读操作完成,理想情况下甚至可以做到读写完全并行。同时,每条数据都保留了历史版本,事务在回滚时也能找到旧数据,可以说是一举两得。

但注意,MVCC并不是完全抛弃锁。插入、更新、删除这类写操作在真正执行时,依然要对记录加锁,防止两个事务同时改一条数据。MVCC主要优化的是普通SELECT(快照读)和写操作之间的冲突,这也是理解MVCC边界很重要的一点。我们后面专门展开说。

2. 管中窥豹:MVCC需要哪三样“地基”

2.1 隐藏列:每条记录的“身份证”和工作履历

先看一个基本事实:InnoDB里一张表,除了你定义的业务字段,每条记录还会额外携带几个隐藏字段。它们平时不显眼,但MVCC的整个逻辑全压在它们身上。

相关的隐藏列主要有三个:

  • DB_TRX_ID:最近一次修改这条记录的事务ID。事务ID由系统分配,一般来说越晚开启的事务,ID越大。
  • DB_ROLL_PTR:回滚指针,指向undo log里这条记录上一个版本的位置,作用是把各个版本串成一条链。
  • DB_ROW_ID:如果表没有定义主键,InnoDB会隐式生成一个自增的行ID,用于聚簇索引组织。有主键的表不会用到它。

可以把这三个字段理解成一组“工作履历”:DB_TRX_ID写着“这个版本是谁改的”,DB_ROLL_PTR写着“上一个版本存在哪”。版本链在物理上靠DB_ROLL_PTR串起来,在逻辑上靠DB_TRX_ID判断哪个版本对当前事务可见。

2.2 undo log 版本链:回滚的目的不只是回滚

每次执行UPDATE、DELETE、INSERT时,InnoDB都会把修改前的数据记录到undo log。很多人对undo log的理解停留在“事务回滚要用”,其实它还有另一层关键使命,就是支撑MVCC。

一条记录被反复修改多次,每个旧版本都会以undo log的形式存在。新版本记录里的DB_ROLL_PTR指向最近一次undo log的位置,undo log里又继续指向上一次修改前的undo log,这样一层层往回指,就形成了版本链。

举个例子。一条记录初始值是A,事务100改成B,事务200改成C。此时记录的最新值是C,它内部通过回滚指针能指到“值为B”的undo log,再通过B版本的undo log继续指到“值为A”的原始版本。链头是最新值,链尾是最早的初始版本。

所以当一个事务读到某个版本后,发现这个版本对自己不可见,就会顺着DB_ROLL_PTR往下找,直到找到一个可见的版本为止。整个过程不需要去别的地方做复杂关联,回滚指针天然就是一条现成的查询路径。

2.3 ReadView:某个时间点的“全局快照相机”

有了版本链之后,理论上就能读取历史版本了。但数据库需要回答一个更关键的问题:对于一个正在执行的事务而言,版本链上的哪个版本“合法”?这就是ReadView要解决的问题。

ReadView可以理解成在“生成读视图”这一刻,对数据库所有正在活跃的事务拍了一张快照。它主要记录了这么几个信息:

  • m_ids:生成ReadView时,系统中所有活跃(未提交)事务的ID列表。
  • min_trx_id:m_ids里最小的事务ID。
  • max_trx_id:生成ReadView时,系统分配过的最大事务ID加1,也可以理解为下一个将要分配的事务ID。
  • creator_trx_id:生成这个ReadView的事务自己的ID。

判断某个版本的DB_TRX_ID是否可见,本质就是用这个版本的生成者身份,去对比上面几个边界值。我常说,ReadView不是锁,它不阻止任何人提交或回滚,它只是在生成的那一刻,把“哪些事务当时还没提交”这件事固定下来,给后续的可见性判断一个依据。

2.4 可见性判断规则:拿着版本号比对四条边界

很多人背了ReadView的定义,但不会用。真正落到版本可见性判断时,就是一套固定规则,拿版本的DB_TRX_ID和ReadView的四个信息做比较。

  • 如果版本的DB_TRX_ID等于creator_trx_id,表示这个版本是当前事务自己改的,自己当然能看自己改的数据,可见。
  • 如果版本的DB_TRX_ID小于min_trx_id,表示修改这个版本的事务,在当前ReadView生成之前就已经提交了,可见。
  • 如果版本的DB_TRX_ID大于等于max_trx_id,表示修改这个版本的事务,是当前ReadView生成之后才开启的,对当前事务不可见。
  • 如果DB_TRX_ID在min_trx_id和max_trx_id之间,需要再检查它是否存在于m_ids活跃列表里。如果不在,说明事务已经提交,可见;如果还在m_ids里,说明事务尚未提交,不可见。

这四条规则就是MVCC里“过滤器”的全部内容。当前事务拿版本链上最新版本的DB_TRX_ID来比对,如果不可见,就沿着DB_ROLL_PTR找到上一个版本,再套用同样规则继续比对,直到找到可见版本或者走到链尾。

3. 核心机制拆解:一个事务“看见”数据的完整过程

3.1 快照读和当前读:两种完全不同的读取方式

在真正走流程之前,必须先分清两种读取方式。因为MVCC不是对所有SELECT都生效的,只有快照读才会走ReadView逻辑。

快照读(Snapshot Read),就是不加锁的普通SELECT语句。它读取的是一条数据的某个历史版本,不需要等待其他事务释放锁。快照读的执行依赖MVCC的可见性判断,是MVCC的主要受益者。

当前读(Current Read),读取的是记录的最新版本,并且要对读取的记录加锁,防止其他事务同时修改。最常见的当前读包括:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及INSERT、UPDATE、DELETE操作。这些操作必须读到最新值,因为你要基于最新值做修改逻辑,读到一个旧版本去更新,数据就会出大问题。

MVCC解决的是“快照读和写操作不互斥”的问题,而当前读之间、当前读和写操作之间,依然通过锁机制来保证安全。这也是为什么RR隔离级别下,MVCC能避免很多幻读场景,但无法完全杜绝当前读造成的幻读。这个点后面单独展开。

3.2 一次普通查询如何顺着版本链找可见版本

现在把整个流程串起来走一遍。假设有一个账户表,id=1的balance初始值是100。下面有两个事务并发操作:

事务A(trx_id=100)开启,执行普通SELECT查询balance。 事务B(trx_id=200)开启,执行UPDATE把balance改成200,但还没有提交。

表里实际的数据是:balance=200(最新版本,由事务B修改),它的DB_ROLL_PTR指向balance=100的undo log版本。

事务A执行SELECT时,会生成一个ReadView。此时m_ids=[100, 200],min_trx_id=100,max_trx_id=201。事务A先读取到最新记录balance=200,拿到它的DB_TRX_ID=200。200在m_ids里,说明事务B还没提交,这个版本对事务A不可见。于是事务A沿着DB_ROLL_PTR找到balance=100,这个版本的DB_TRX_ID是某个小于min_trx_id的事务,也就是说修改它的老事务早就在快照生成前提交了,可见。最终事务A查到balance=100。

如果事务B在A的SELECT之后提交了,A再执行一次普通SELECT,由于ReadView复用的原因,A看到的依然是balance=100。这就是可重复读的典型表现,同一事务多次读,结果一致。

3.3 用伪代码说明判断逻辑

把上面的规则翻译成伪代码,会更清晰:

function isVisible(version, readView): trxId = version.trx_id if trxId == readView.creator_trx_id: return true if trxId < readView.min_trx_id: return true if trxId >= readView.max_trx_id: return false if trxId not in readView.m_ids: return true return false function getVisibleRecord(record, readView): current = record while current is not null: if isVisible(current, readView): return current current = current.roll_pointer return null

这段逻辑不只是面试用来背的,线上排查慢查询或者诡异数据时,理解了它你才能快速定位是隔离级别配置问题,还是长事务导致undo log堆积问题。

4. 从一条UPDATE看版本链如何“长出来”

4.1 更新操作做了三件隐秘的事

很多人以为UPDATE就是简单地把值改掉,实际InnoDB内部做了更多事。一条UPDATE的执行大致分为三件事:

  • 对目标记录加排他锁,确保没有其他事务同时修改。
  • 把更新前的旧值写入undo log,形成旧版本。
  • 更新记录的新值,并把记录的DB_TRX_ID改成当前事务ID,DB_ROLL_PTR指向刚才写入的undo log位置。

加锁保证并发安全,写undo log保留历史版本,改隐藏列让版本链延伸。这三件事缺一不可。

这里有个容易被忽略的细节:如果UPDATE修改了聚簇索引的键值,可能导致记录移动位置,此时undo log里还会记录主键的旧值,用于回滚时还原。如果只是普通字段更新,undo log主要记录被更新字段的旧值即可。InnoDB会根据实际修改情况,决定undo log里要保存哪些信息。

4.2 模拟两个事务并发修改同一行数据

用一个完整的例子,模拟两个事务同时修改同一条记录,看看版本链的变化。初始情况:id=1记录balance=100,最后修改它的事务ID=50。

时间线如下:

  1. 事务A(trx_id=100)执行UPDATE,把balance从100改成150。此时最新记录balance=150,DB_TRX_ID=100,DB_ROLL_PTR指向balance=100的undo log。版本链:150 → 100。
  2. 事务A未提交,事务B(trx_id=200)也执行UPDATE。注意,事务B要更新这行,必须先等事务A的排他锁释放。事务A提交后,事务B才能继续。
  3. 事务B把balance从150改成200。执行前先把balance=150写入undo log,然后更新最新值为200,DB_TRX_ID=200,DB_ROLL_PTR指向balance=150。版本链:200 → 150 → 100。
  4. 此时有一个事务C(trx_id=300)执行普通SELECT,生成ReadView时,事务B还在活跃列表里,那么事务C会顺着版本链找到balance=150吗?不会,它会继续判断150这个版本的DB_TRX_ID=100,如果事务A已经提交且100小于min_trx_id,那就可见,查到150。

这个例子能说明两个重点:每执行一次提交后的UPDATE,版本链就多一层;普通查询不是只找第一层,而是一层一层去匹配,直到找到合法的版本。

4.3 版本链不可能无限增长:purge线程在背后兜底

版本链如果一直长大,undo log会膨胀,占用大量磁盘空间,查询也会因为需要遍历太长的版本链而变慢。InnoDB专门有一个后台purge线程负责清理过期的undo log。

purge的清理原则是:当某个旧版本已经没有任何活跃事务的ReadView需要引用它时,它就可以被标记删除并回收空间。换句话说,版本链上的旧版本不是立即删除,而是等“没人再需要”之后再清理。这个“没人需要”的判断,依赖系统对活跃事务和ReadView全局信息的维护。

我见过不少线上事故,就是因为某个长事务长期不提交,导致ReadView一直保存着很早的快照信息,purge线程不敢清理undo log,磁盘空间持续增长。所以监控长事务,不只是业务层面的问题,它会直接影响InnoDB内部的存储和性能。

5. 隔离级别下的MVCC差异:RC和RR到底差在哪

5.1 RR级别下ReadView只在第一次快照读时生成

MySQL默认的隔离级别是REPEATABLE READ(可重复读)。在这个级别下,一个事务内部如果执行多次普通SELECT,只在第一次SELECT时生成ReadView,后续所有快照读都复用同一个ReadView。

复用ReadView会带来什么效果?事务一开始就确定了“能看到的版本边界”,之后再怎么并发修改,这个事务看到的数据都是一致的,不会出现两次查询结果不一样的情况。这正是“可重复读”这个名称的含义。

用前面的例子:事务A先查询balance=100,随后事务B修改并提交,事务A再次查询balance,因为它复用了第一次的ReadView,事务B的修改版本依然不可见,结果还是100。

5.2 RC级别下每次快照读都重新生成ReadView

READ COMMITTED(读已提交)级别下,每次执行快照读都会生成一个新的ReadView。这意味着,事务内两次普通的SELECT,可能读到不同的结果。

继续上面的例子:事务A第一次查询balance=100,生成ReadView1,看不到事务B的修改。事务B提交后,事务A第二次查询,会重新生成ReadView2。此时事务B已经不在活跃事务列表里,它对balance的修改版本对当前事务可见,于是第二次查询结果变成了200。两次查询结果不一样,这就是“读已提交”的行为特征:每条语句都能读到最新已提交数据。

所以面试如果问你RC和RR在MVCC上的区别,本质上就是一句话:RC每次快照读都生成新ReadView,RR复用事务内第一个ReadView。理解了这句,再结合实际读写场景展开,就比单纯背定义有说服力得多。

5.3 为什么RR下还会出现幻读

先说幻读是什么:同一个事务内,执行两次同样的范围查询,第二次多出了一些第一次没看到的行。很多人会问,RR隔离级别下MVCC不是已经保证快照读一致了吗,怎么还会幻读?

关键在于,MVCC的快照读只能保证“快照读”之间的一致,不能保证“当前读”之间的一致性。如果一个事务先做了一次快照读,另一个事务插入了新记录并提交,之后该事务再执行带锁的当前读,比如SELECT ... FOR UPDATE,这时当前读会读取最新已提交的数据,就可能把另一事务刚插入的记录也查出来,产生幻读。

另外还有一种情况:如果事务在快照读之后,执行了UPDATE或INSERT这类当前读,之后再进行快照读,由于当前读会读取最新版本,有可能会间接影响之后看到的版本,造成“自己的修改”“自己新插入的数据”和最初快照不一致的复杂情况。严格来说,靠MVCC无法彻底天然解决幻读,InnoDB在RR级别下还引入了间隙锁(gap lock)和临键锁(next-key lock),用来锁定范围,防止其他事务在区间内插入记录。MVCC解决快照读的幻读感知,间隙锁解决当前读的幻读问题,两者配合才把RR级别拉到了接近序列化的强度。

5.4 两类隔离级别下的MVCC行为对比

用一张表把差异整理清楚:

对比维度READ COMMITTEDREPEATABLE READ
ReadView生成时机每次普通SELECT都生成新ReadView事务内第一次普通SELECT生成,后续复用
同事务多次快照读结果可能不同,读到最新已提交数据一致,基于同一个快照
版本链遍历深度相对较浅,能看到最新提交版本可能较深,需要回溯到快照时刻的可见版本
潜在问题不可重复读快照读无幻读,当前读仍需锁机制辅助

如果业务对数据一致性要求高,使用RR更省心,但要注意间隙锁可能带来更大概率锁等待。如果业务可以接受一条语句内看到最新已提交数据,RC对并发更友好,很多互联网大厂在高并发场景下反而更倾向RC。

6. 面试高频追问:这样回答才算真正懂得MVCC

6.1 追问一:RR级别下,普通SELECT能看到另一个事务没提交的修改吗

看不到。普通SELECT走快照读,会生成ReadView,所有未提交事务的修改版本都在m_ids活跃列表里,被判定为不可见。这是MVCC的核心逻辑,也是“读已提交”“可重复读”两种隔离级别都具备的能力:不会读到脏数据。

6.2 追问二:UPDATE为什么不能走快照读,必须当前读

因为UPDATE要修改数据,它必须先拿到最新版本的值再做变更。如果基于旧版本做修改,就会覆盖其他事务刚提交的新值,产生丢失更新。所以UPDATE会对记录加排他锁,读取最新版本,执行修改。MVCC的快照读只用于查询场景,写入场景必须走当前读。

说到丢失更新,还有一类容易被问到的场景:两个事务都先SELECT,基于同一个旧值做计算,再UPDATE,最终只有一个更新生效或者晚提交的覆盖早提交的。MVCC本身不能完全防止这类问题,需要额外的锁机制或版本号方案配合。

6.3 追问三:DELETE操作和MVCC有什么关系

DELETE也是写操作,执行时会对记录加排他锁,同时把当前版本写入undo log形成旧版本,再在记录上打一个删除标记。从MVCC角度看,被删除的记录并没有物理删除,只是标记为删除,历史版本依然在版本链上。普通SELECT判断可见性时,如果当前标记为删除的版本不可见,会继续找上一个历史版本。这就是为什么一个事务已经提交删除记录,另一个较早开启的事务还能读到它。

6.4 追问四:长时间不提交的事务对MVCC有什么影响

影响主要有两个。第一,它持有的ReadView会一直引用某些旧版本,导致purge线程无法清理对应undo log,磁盘占用只增不减。第二,版本链越来越长,后续查询遍历成本增加,整体性能下降。线上遇到undo log膨胀时,第一时间要查是否有长时间未提交的事务。

排查方式通常是查information_schema下的事务相关状态表,找到运行时间较久的活跃事务,提醒相关业务方尽快提交或回滚。如果事务确实要长时间运行,可以考虑拆分事务、降低单个事务的运行时长,从源头减少对MVCC版本链的压力。

6.5 追问五:如果用一张表把MVCC的工作要素列全,你会列什么

这个问题是很多面试官爱问的开放题。我一般按下面分类来答:

  • 存储结构:隐藏列(DB_TRX_ID、DB_ROLL_PTR)和undo log组成了版本链。
  • 判断依据:ReadView记录了活跃事务ID列表和边界信息。
  • 可见性规则:用版本事务ID和ReadView的边界值做比较,决定版本是否可见。
  • 读取方式:快照读(普通SELECT)走MVCC,当前读(加锁SELECT和写操作)走最新版本加锁。
  • 隔离级别差异:RC每次快照读新生成ReadView,RR复用事务内第一个ReadView。

这套框架把存储、判断、读取、场景差异全部覆盖到了,面试官想往里深入每个点,你都有话可接。

结尾

MVCC本身不是一个能独立工作的组件,它更像一套“机制组合”:底层靠隐藏列搭起版本链结构,执行时靠ReadView提供判断依据,配合undo log保留历史版本,最终让读写并发场景下查询不阻塞、写入不阻塞。

我个人在实际排查问题中最深的体会是,很多人对MVCC的理解停留在“历史版本”这四个字上,一旦线上出现undo log膨胀、RR级别下查到“不该看到”的数据、或者一条慢SQL在版本链上遍历太长,就不知道从何查起。其实这些问题几乎都能回到同一个思路:当前事务的ReadView到底覆盖了哪些事务,版本链的哪个版本对这个ReadView可见。把这个核心问题想清楚,MVCC的基本功就算真正过关了。

最后分享一个小技巧,准备面试或者写技术笔记时,不要只背结论,最好自己动手画一条版本链,标上事务ID和回滚指针,再模拟几次可见性判断。画过一次之后,你会发现再遇到相关的问题,脑子里会自动浮现这条链,而不是去死记规则。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 14:29:32

开源代码评审工作流实战:从流程设计到CI工具链落地

1. 项目概述1.1 为什么你需要一套“open-code-review”流程代码评审这件事&#xff0c;我见过两种极端。一种是完全不评审&#xff0c;代码直接合入主干&#xff0c;等到生产环境炸了才追悔莫及&#xff1b;另一种是评审流程重到令人窒息&#xff0c;每次提 PR 都要开两小时会&…

作者头像 李华
网站建设 2026/10/19 14:29:31

PostgreSQL AI生态实战:从向量检索到机器学习扩展

看到这个标题&#xff0c;我第一反应是&#xff1a;终于有人正儿八经地把 PostgreSQL 和 AI 生态放在一起聊了。这些年数据库圈子和 AI 圈子的交集越来越多&#xff0c;但能把两者讲透、讲清楚、讲出实操价值的文章少之又少。正好我自己最近也在折腾这块&#xff0c;借着这个题…

作者头像 李华
网站建设 2026/10/19 14:28:19

深入Linux echo命令:参数、转义、颜色与日志函数实战

做了这么多年Linux运维和脚本开发&#xff0c;我可以直接说&#xff1a;echo命令是我日常敲得最频繁的工具之一&#xff0c;没有它&#xff0c;我写的脚本几乎没法正常跟人“说话”。一个调试信息、一条进度提示、一段日志记录&#xff0c;本质上都是靠echo把字符串送出来。但你…

作者头像 李华
网站建设 2026/10/19 14:25:09

无线AP故障排查全指南:从物理链路到配置优化的十种实战方法

干了十多年网络运维&#xff0c;最怕听到的其实不是哪个系统崩了&#xff0c;而是同事站在过道里喊“无线AP是不是又挂了”。无线接入点&#xff08;AP&#xff09;这东西没有显示器没有控制台&#xff0c;出了故障往往就是用户觉得慢、觉得卡、连不上&#xff0c;可你跑到设备…

作者头像 李华
网站建设 2026/10/19 14:24:23

智能体开发实战:从核心原理到工程落地全解析

"哇塞&#xff01;智能体来了&#xff0c;智创未来欢乐冒险起航"——这个标题看着确实有点中二&#xff0c;但你要是真把它当一句玩笑话&#xff0c;那就亏大了。我最近连着做了几个智能体&#xff08;AI Agent&#xff09;相关的模拟项目&#xff0c;最直观的感受是…

作者头像 李华
网站建设 2026/10/19 14:23:34

Natas 0-5通关全记录:从源码审计到Cookie篡改的Web安全入门

如果你已经玩过一阵子OverTheWire的Bandit&#xff0c;一定会记得那种“在命令行里摸黑前进”的刺激感&#xff1b;而一旦切到Natas&#xff0c;画风立刻变成浏览器、HTML源码、HTTP请求头这些Web层面的东西。Natas是OverTheWire系列里专门练Web服务端安全的关卡&#xff0c;前…

作者头像 李华