资讯详情

MySQL InnoDB MVCC原理:版本链、ReadView与隔离级别详解

📅 2026/10/20 22:21:20 | 华诺云谱 👁 阅读
MySQL InnoDB MVCC原理:版本链、ReadView与隔离级别详解
1. 为什么要有MVCC读写冲突才是并发问题的核心聊MVCC之前先想想它到底在解决什么问题。说白了就一句话多个事务同时操作同一行数据时怎么保证互不干扰、还能看出一个相对一致的数据版本很多人背八股能背出多版本并发控制这几个字但问到为什么需要多版本单靠锁行不行就卡住了。这一层不理解后面看ReadView规则等于看天书。数据库并发场景里把操作拆开看就四类读-读、写-写、读-写、写-读。读-读天然没事两个事务同时查同一行谁也不会把谁的数据改坏写-写必须串行化两笔更新同时对同一行动手不加锁就乱套这个交给锁去处理。剩下的读-写和写-读才是MVCC真正发挥价值的地方。想象一个最普通的业务场景订单表里有一条金额100的记录事务A正在更新这条记录把它改成120事务B此时执行SELECT想看看当前金额。如果按“读写互斥”的老思路事务A没提交之前事务B只能阻塞等待直到A提交完才能读。这个等待可能只有几十毫秒但放在高并发下一个热点行被频繁更新所有读请求都在排队数据库的吞吐量直接腰斩。更麻烦的是如果A事务在更新之后还要做一大堆后续操作才提交B的读请求可能被卡在那里几秒钟——这在线上是完全不能接受的。加锁方案确实能解决数据一致性但它牺牲了并发度。MVCC的思路聪明在什么地方呢它把读分成两种一种是读数据的历史版本不需要加锁叫快照读一种是读数据的最新版本并且要加锁叫当前读。普通SELECT走快照读UPDATE、DELETE、SELECT ... FOR UPDATE这类语句走当前读。这样一来读操作不锁数据写操作也不会被普通读阻塞读和写就能并行跑了。举个例子你就明白这个设计有多关键。某交易系统里的账户余额表每天有大量查询余额的请求同时又有转账请求在改余额。如果读和写必须排队那查询一多转账就得干等着用户体验必然崩。MVCC让查询直接读某个时间点之前的快照转账该改改、该提交提交两边各干各的谁也不碍谁。这里的快照不是真的把整张表复制一份——那样内存就炸了——而是通过行数据的多版本机制让每条记录能在逻辑上同时存在多个版本读的人按规则挑一个适合自己的版本。这套机制在MySQL里就是InnoDB存储引擎实现的MVCC。注意一个关键点MVCC不是MySQL的通用能力而是InnoDB的特性。你换用MyISAM引擎它根本不知道多版本是什么东西读写照样互相锁。所以面试要是被问MySQL怎么实现MVCC本质问的是InnoDB怎么实现MVCC。那InnoDB为了实现这套机制在底层到底布置了哪些东西这便是接下来要展开的核心。2. MVCC的三大地基隐藏字段、undo日志版本链、ReadViewInnoDB的MVCC能跑起来靠的是三个基础组件的配合记录上的隐藏字段、修改时产生的undo日志、以及读操作时生成的ReadView。三个缺一不可逐个说清楚。2.1 隐藏字段每行记录自带的身份信息InnoDB的聚簇索引记录里除了你定义的业务字段还悄悄塞了几个隐藏字段。MVCC相关的主要是两个DB_TRX_ID记录最近一次修改这条记录的事务ID。事务ID由MySQL内部维护一般随事务创建而递增所以谁后改、谁先改一眼就能看出来。DB_ROLL_PTR回滚指针它指向这条记录的上一个版本在undo日志里的位置。另外还有一个DB_ROW_ID是行ID只有当你建的表没有主键、没有唯一键时才会出现InnoDB会用它生成隐藏主键。它和MVCC的可见性判断关系不大但知道这个字段存在没什么坏处。理解DB_TRX_ID的时候记住一句话每一个版本都有它的身份证号这个号码就是修改它的事务ID。数据库判断一条记录对你可不可见本质就是看这个事务ID在你的ReadView规则里合不合法。后面讲到ReadView时你会反复遇到它。2.2 undo日志版本链把历史版本串成一条线有了隐藏字段还不够。DB_TRX_ID只能告诉你这行是谁最后改的但如果快照读需要拿到一笔更早的数据内容呢这时候就要靠undo日志了。一条记录被UPDATE时InnoDB并不会原地覆盖掉旧值而是先把旧值写入undo日志再把新值写到当前记录里同时让新记录的DB_ROLL_PTR指向刚才写入的undo日志位置。下一次再更新时又生成一条undo日志并把新记录的DB_ROLL_PTR指向上一条。这样一路指下去就形成了一条版本链记录最新值 - undo日志(版本v2) - undo日志(版本v1) - undo日志(版本v0)链的头部是当前记录的最新值越往尾巴走版本越老。每一次版本切换都会把旧数据记进undo日志而undo日志又是回滚事务和旧版本读取的共同基础。你可以把这条链理解成一条时光隧道从最新状态一路往后退能退回到事务开始前的任意版本。DELETE操作也可以看作一种特殊的UPDATE它不是真的立刻删掉记录而是往版本链上追加一个删除标记版本。等到事务提交后再由后台清理线程把这些旧版本真正回收。这里就引出一个常被忽视的点——版本链不能无限保留否则undo日志会疯狂膨胀磁盘迟早被撑爆。InnoDB会有一个后台的purge线程定期清理掉那些已经没有任何ReadView需要它们的旧版本。也正因为undo日志里有全部历史版本读操作才能拿到自己想要的那个版本而不用管这条记录当前被改成了什么。这正好回答了一个常见疑问MVCC是复制多份数据吗不是它是在逻辑上通过版本链让人看到多个版本物理上只有一份当前数据旧数据都放在undo日志里靠指针串接而已。2.3 ReadView一把专门判断谁对我可见的尺子隐藏字段和版本链只是把数据的历史版本摆好了真正决定哪个版本对当前事务可见的是每个快照读生成的ReadView。这是MVCC里最核心、最绕的部分值得单独拆开讲。ReadView本质上是一个事务快照在快照读第一次执行时生成。它内部主要记着四样东西字段作用m_ids生成ReadView那一刻当前所有活跃但未提交的事务ID列表min_trx_idm_ids中最小的那个事务IDmax_trx_id下一个将要分配的事务ID也就是当前系统里最大的事务ID加1creator_trx_id生成这个ReadView的事务自己的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列表里在列表里说明这个修改事务还没提交不可见不在列表里说明这个事务在ReadView生成前就已经提交了可见。如果DB_TRX_ID正好等于creator_trx_id也就是当前事务自己改的当然可见。规则本身不难背但很多人栽在为什么还要一条一条回溯版本链上。原因是版本链头部的数据可能对你不可见但链上更早的版本未必不可见。所以InnoDB拿到一条记录后会从链头开始用上面这套规则挨个版本检查找到第一个可见的版本就返回如果一路到链尾都不可见就说明这条记录对当前事务来说压根不存在。这里有个细节容易被忽略判断是自己事务改的这条往往被漏掉。如果忘了creator_trx_id这层判断会出现自己事务里刚刚update的数据回头SELECT居然查不到的诡异情况。我见过不少人在这一步栽跟头写SQL的时候明明先UPDATE再SELECT结果读出来还是旧值排查半天最后发现是ReadView规则没算全。3. 可见性判定的完整规则手动推演一遍版本链检索过程规则摆出来了光看文字还是容易晕。我习惯用一个具体例子手动推演一遍这样比死记规则管用得多。比如有张用户表里面一行数据初始版本是事务ID为1的事务插入的当前现金版本为trx_id1, name张三, money100。现在系统里有三个事务在跑事务AID为10正在修改这行数据把money改成200但还没提交事务BID为11执行SELECT生成ReadView事务CID为12也修改了这行数据把name改成李四但还没提交假设此时记录状态是当前版本由事务12修改DB_TRX_ID12money确实是200name是李四它的上一版本由事务10修改过DB_TRX_ID10money是200name是张三再上一版本是初始插入版本DB_TRX_ID1。事务B生成ReadView的时刻事务10和事务12都还没提交所以m_ids [10, 12] min_trx_id 10 max_trx_id 13下一个待分配的事务ID creator_trx_id 11事务B要读这行数据从版本链头开始检查当前版本DB_TRX_ID12在m_ids列表里说明是活跃未提交事务改的不可见继续往前找。上一版本DB_TRX_ID10也在m_ids列表里同样是活跃事务的修改不可见继续往前找。再上一版本DB_TRX_ID1比min_trx_id还小说明是在ReadView生成之前就提交的老版本可见。事务B最终读到的是初始版本name张三money100。这个例子走完一遍你会发现一个很有意思的结论版本链上可能存在多个中间版本但对某个快照读来说它只会落在某一个特定版本上其他版本对它完全是空气。这也是可重复读能达到效果的底层原因——第一次生成ReadView之后把它固定住后面每次SELECT都按同一把尺子量结果必然稳定。有人可能会问如果事务B第一次SELECT之后事务A提交了事务B第二次SELECT还能看到事务A修改后的数据吗看规则就知道不能因为ReadView还是第一次生成的那个m_ids还是[10,12]事务A虽然提交了但那个版本号10依然在列表里被排除。这就是REPEATABLE READ下读到的是事务开始时的快照这句话的真正含义。再补充一个容易考到的问题ReadView是在什么时候生成的快照读第一次执行时才生成而不是事务创建时就生成。如果一个事务一开始先做了快照读那ReadView生成得早如果一个事务开了事务但迟迟没有读操作直到别的同事都改完提交了才执行第一条SELECT那ReadView生成得晚能看到的内容就多。这就是为什么有人会疑惑同样开了事务为什么我看到的和别人看到的不一样——大家的ReadView生成时机不一样。4. 快照读与当前读的分工MVCC管读锁管写前面提到MVCC把读分成快照读和当前读这一节要把两者的边界彻底划清楚因为MVCC是不是能解决所有并发问题这件事很多人的理解是错的。普通SELECT语句就是快照读它不加锁直接按ReadView的规则从版本链里挑版本。正因为不加锁它永远不会阻塞其他事务的写操作其他事务提交不提交跟它都没关系它只认ReadView。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE都属于当前读。当前读必须读最新已提交的版本而且要对记录加锁。加锁的粒度取决于索引和隔离级别可能是记录锁、间隙锁也可能是Next-Key Lock。当前读没有读旧版本这个选项因为写操作如果基于旧版本做运算那算出来的新值本身就是错的所以它必须读到最新值、锁住它防止别的写事务趁机插入或修改。一个很常见的真实场景转账。扣钱和加钱这两个步骤如果都先查余额再改余额就必须用当前读。比如SQL长这样SELECT balance FROM account WHERE id 1 FOR UPDATE; -- 业务代码里判断balance够不够扣 UPDATE account SET balance balance - 100 WHERE id 1;第一条FOR UPDATE锁定id1这行第二个事务想更新同一行就只能阻塞等待。如果这里用快照读普通SELECT两个事务都读到balance100各自扣掉100最后提交时都基于旧值计算钱就多出来了。这就是为什么MVCC不能替代锁一致性读保的是读的隔离写冲突还得靠锁来扛。所以整体分工是这样的快照读之间完全不互斥全靠MVCC隔离快照读与当前读不互斥当前读写归写快照读读归读当前读与当前读互斥必须锁等待理解了这个分工再回看那句流传很广的话MVCC实现了读写不冲突就知道它精确的说法是MVCC让普通读与写不冲突但写与写依然要锁。面试时能主动说出这层区别跟只会背结论的人拉开差距。5. READ COMMITTED和REPEATABLE READ一个ReadView复用的差异MVCC在隔离级别里的具体表现是面试的高频题尤其是为什么REPEATABLE READ能可重复读、READ COMMITTED不能这个问题。答案其实非常干脆两者生成ReadView的策略不同。READ COMMITTED每一次快照读都重新生成一个新的ReadView。REPEATABLE READ只在第一次快照读时生成ReadView之后一直复用同一个。因为RR复用ReadView后续无论执行多少次SELECT判断可见性用的m_ids、min_trx_id、max_trx_id全都固定了先下手为强后面别的同事提交了再多的数据也进不了这份快照。RC每次都生成新的ReadView新ReadView会把最近已经提交的事务排除出m_ids于是就能看到最新提交的数据。同一事务里两次SELECT结果不一样这就是读已提交的语义。用一个简单的时间线说明时刻1事务A开始未做任何读 时刻2事务B update某行并提交 时刻3事务A执行SELECTRC下生成新ReadView看得到B的提交RR下也生成ReadView 时刻4事务C update同一行并提交 时刻5事务A再次执行SELECT - RC重新生成ReadViewC已经提交不在m_ids里能看到C的结果 - RR复用时刻3的ReadViewC虽然在提交后版本链头但版本号被排除看到的还是B提交后、C提交前的状态甚至可能回到更早顺带一提InnoDB的默认隔离级别是REPEATABLE READ。原因有些历史因素但一个很实际的原因是RR下主库生成binlog如果用语句格式记录日志里能保证重放顺序和主库执行的一致性问题更少。既然MVCC快照读已经解决了大部分幻读问题RR的成本没有多数人想的那么高默认用RR也就说得通了。这个话题展开又是一篇长文这里先点到为止。那RR解决了幻读吗这个问题呢网上讨论特别多准确说法是RR下的快照读通过MVCC解决了幻读但当前读场景下的幻读MVCC管不了需要Next-Key Lock兜底。比如事务A先SELECT ... WHERE id 10 FOR UPDATE加的是范围锁另一个事务往这个范围里插入id11的数据就会被锁挡住。如果只靠MVCC的快照读这条插入是不会被挡住的因为快照读读到的是旧版本根本感知不到数据行增多了。所以InnoDB在RR级别下会使用间隙锁和Next-Key Lock来锁住范围防止幻读插入。这也是为什么RR在InnoDB里能提供更强的隔离保证但并发度稍稍受损。这张表整理一下面试前可以拿来速记对比维度READ COMMITTEDREPEATABLE READReadView生成时机每次快照读都生成首次快照读生成并复用能否看到其他事务新提交的数据能不能可重复读效果同一事务两次读结果可能不同同一事务两次读结果稳定快照读幻读可能被MVCC解决当前读幻读靠间隙锁/Next-Key Lock兜底靠间隙锁/Next-Key Lock兜底实际业务里取舍要看需求报表统计、对账之类要求一个事务内多次读取完全一致的场景RR更稳追求实时看到最新数据、对一致性要求没那么严格的场景比如商品浏览计数RC更合适。没有绝对的好坏只有合不合适。6. 被很多文章忽略的细节二级索引怎么配合MVCC做可见性判断前面讲的版本链判断默认建立在聚簇索引记录上因为隐藏字段就在聚簇索引里。但一个常见查询走的是二级索引非聚簇索引二级索引的叶子节点只存索引列的值和主键值压根没有DB_TRX_ID和DB_ROLL_PTR。那MVCC在二级索引上还怎么判断可见性这个问题能讲明白的人不多但它确确实实在线上排查和面试深挖时都会出现。InnoDB的处理方式很巧妙二级索引不直接判断可见性而是靠回表解决。查询走二级索引时会先根据索引定位到叶子节点拿到主键然后回表查聚簇索引在聚簇索引记录上用ReadView规则判断可见性。如果聚簇索引记录可见就把结果返回如果不可见就顺着聚簇索引的版本链继续往前找找到可见版本再把需要的数据字段取出来。但这里有个效率隐患如果不可见的版本很多每次回表发现不可见又要重新沿着undo版本链回溯代价可不小。InnoDB为了避免这种无谓的回表在二级索引页里可能带有一个标记位PAGE_MAX_TRX_ID之类的页级信息来做初步过滤如果这个页最近没有大于ReadView中min_trx_id的事务修改过整个页都是安全的可以放心用二级索引直接拿记录如果页上有比min_trx_id大的修改记录那就要对页内的记录逐个回表判断。具体实现版本间有差异但大思路是一致的先用页级信息做粗筛减少回表次数再靠聚簇索引的版本链做精细判断。这个机制的实际影响是什么呢最直接的是二级索引查询在某些高并发修改场景下可能变慢。比如一张订单表status列有二级索引几万个事务正在同时改status页级标记大概率是脏的那么走status索引查出来的每一行都要回表验证一遍可见性扫描量会明显上升。你在慢日志里看到某些索引查询莫名其妙变慢了有时候不是索引失效而是MVCC的可见性判断成本被这些更新放大了。另一个在真实业务里很常见的现象count(*)或者大范围查询走二级索引时明明符合条件的行不多但执行时间很长。除了正常的回表成本还有一层就是对不可见版本的回溯判断。很多DBA排查慢SQL时容易忽略这层原因我建议你把MVCC可见性判断开销列入慢SQL根因排查的候选清单里尤其是那种更新频繁二级索引查询并存的表。再说一个很多人会绕进去的误解MVCC是不是每个查询都把所有历史版本都扫描一遍?当然不是。ReadView的min/max规则配合索引路径能快速跳过绝大部分不可见记录。真正需要长时间回溯的都是并发修改特别激烈的热行。对于热点行合理设计索引让查询尽量走聚簇索引、减少回表比事后调参更有效。7. 面试官顺着MVCC还会追问什么高频追问与答题思路聊完实现细节最后站在面试角度把可能的追问都过一遍。不是为了背答案而是看看自己是不是真理解了。追问一为什么说MVCC和undo log是配合关系MVCC读旧版本靠undo log保存的历史数据事务回滚也靠undo log恢复旧值。ReadView就是一道过滤器决定版本链里哪个版本被放行undo log则是那些版本的实体存放地。没有undo logReadView再精准也拿不到数据。追问二RR级别下一个事务先快照读之后其他事务提交了新数据这个事务能通过加锁的方式读到新数据吗能。快照读和当前读是两套路径。你执行SELECT ... FOR UPDATE走的是当前读直接读最新已提交版本并加锁不受之前生成的ReadView限制。所以在RR级别下同一事务内先快照读、再当前读可能拿到不同版本的同一行——这不违反可重复读的定义因为可重复读约束的是快照读。追问三如果一条记录被UPDATE了多次undo日志里有很多版本purge线程怎么判断哪些能清理核心判断标准就是还有没有活跃的ReadView需要这个版本。如果某个版本的事务ID小于当前所有活跃ReadView的min_trx_id说明再也不会有人回到那个版本去读了这个版本就可以被purge掉。这也是为什么长事务是MVCC的大敌——一个长时间不提交也不读的事务会让它的ReadView一直存活连带堵住一大批旧版本无法清理undo日志膨胀最终影响整个库的性能。我建议你在线上多留意长时间挂起的只读事务它们中的一大部分对业务毫无贡献却能把数据库拖到磁盘耗尽。追问四MVCC解决了脏读吗解决了。快照读读的是ReadView允许的已提交版本未提交事务的修改根本进不了ReadView的判定范围所以读不到脏数据。RC和RR两种级别在这一点上表现一致。追问五既然MVCC这么好为什么高并发下还是会出现死锁MVCC只处理了读与写的并发冲突处理不了写与写。两事务互相持有对方需要的行锁就死锁了。MVCC不是免死金牌死锁检测和重试机制该用还得用。一道综合题很能检验理解深度多个事务并发修改同一行为什么RR级别下后提交的可能覆盖先提交的这就要分两层答如果两个事务都做当前读后抢到锁的先执行先抢到锁的等待后重新评估条件再更新这属于锁竞争如果一个事务快照读算出了结果再更新另一个事务已经基于最新值更新过了前者提交时会用它的旧计算结果覆盖——这本质上是丢失更新问题需要应用层做乐观锁版本号或改走当前读来解决。能讲到这一层MVCC基本是真正吃透了。从为什么需要MVCC、三块地基怎么搭的到ReadView规则、隔离级别的差异、二级索引的可见性配合再到面试追问这套链路走下来MYSQL的MVCC就不再是八个字的空洞概念了。说句实在话这类原理性的知识点靠背是背不稳的。我给自己立的规矩是理解背后为什么之后在纸上自己画一遍版本链手动模拟一次ReadView判断然后找一张真实表把隔离级别切来切去验证一把。跑通一次胜过刷十遍面经。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑