MySQL MVCC机制详解:版本链、ReadView与RC/RR差异

发布时间:2026/10/5 19:45:00
MySQL MVCC机制详解:版本链、ReadView与RC/RR差异
先说明一下MySQL的MVCCMulti-Version Concurrency Control多版本并发控制是我这几年后端排障生涯里反复摔跤又反复受益的一个知识点。面试官爱问它线上诡异数据问题绕不开它写复杂事务SQL时也经常要退回到这条原理上来重新推演。最让我头疼的不是概念背不下来而是很多人把MVCC理解成靠多份数据副本实现并发控制结果既解释不清为什么一行数据物理上其实只有一份也说不明白RC和RR到底差在哪儿、长事务为什么会拖垮数据库。这篇文章我按自己的理解把MVCC拆成四层来讲先说是解决什么问题再讲它依赖的物理结构隐藏字段、undo log、版本链然后重点拆ReadView的判定逻辑最后把RC/RR差异、长事务与undo膨胀、以及实操里怎么排查这些问题全部串起来。适合刚接触MySQL事务的初级开发也适合背过不少八股但一到现场就发懵的中级工程师按顺序读完应该能建立一条完整的链路。1. MVCC到底解决了什么问题读写冲突与两条读取路径1.1 读与写的冲突是数据库的老大难并发事务最朴素的需求其实就两个读的时候别读到脏数据写的时候别互相踩脚。如果只用最传统的加锁策略那就得让读写互斥——读加共享锁写加排他锁写没提交的时候读只能干等。这样一致性有了并发度却没了线上稍微热点一高系统就变成一串串排队。InnoDB的取舍思路很巧妙写之间仍然严格加锁但读不再非要等写锁释放。它让每次修改都保留一份旧版本读操作可以选择读最新已提交版本也可以选择读某个历史快照。这就是多版本并发的核心思想把当前数据和历史版本分离开就像一本持续修订的书读者拿到的可能是某一版定稿作者改稿子不需要等读者放下书。这个设计直接解决的问题就是读写阻塞尤其是OLTP场景里最常见的一个事务改了行还没提交另一个事务想查询。如果没有MVCC这类查询会被阻塞到第一个事务结束有了MVCC查询立刻返回快照结果。代价是存储和清理成本也就是后面要说的undo log和purge机制。1.2 快照读与当前读MVCC只覆盖其中一条路理解MVCC的第一步是先分清InnoDB里两种完全不同的读取方式。快照读也叫一致性读就是我们最常见的普通SELECT。它不跟任何行锁冲突按照事务的可见性规则去读一个历史快照。MVCC只对快照读生效。当前读则相反它永远读取最新已提交版本并且要为涉及的行加锁。常见操作包括SELECT ... LOCK IN SHARE MODESELECT ... FOR UPDATEUPDATE、DELETEINSERT内部的唯一索引冲突检查如果一条UPDATE语句的WHERE条件命中了某行它会先找到最新版本再加排他锁然后修改。如果另一个事务还没提交当前读就要等锁。这也是为什么MVCC并不能让所有操作都变成无锁。另外要注意隔离级别对MVCC的开关影响我整理了一个对照表隔离级别普通SELECT的行为是否走MVCCREAD UNCOMMITTED直接读最新版本不判断可见性否READ COMMITTED每条语句生成一个新快照是REPEATABLE READ整个事务首次读时生成快照并复用是SERIALIZABLE普通SELECT被自动转换为加锁读否变为当前读把这两条路想清楚后面很多所谓诡异现象其实都是因为搞混了快照读和当前读。2. 版本链的底层构造隐藏字段和undo log如何协作2.1 每行数据都带着身份信息MVCC不是凭空判断版本的它的根基埋在每个聚簇索引行记录里。除了业务字段InnoDB还给每行维护了几个隐藏字段DB_TRX_ID6字节最后一次插入或修改这行的事务ID。DB_ROLL_PTR7字节回滚指针指向undo log里记录的上一个旧版本。DB_ROW_ID6字节可选当表没有主键时InnoDB用它作为聚簇索引的行标识。此外行记录头里还有一个delete flag标记位表示这行是否被逻辑删除。可以这么理解每个版本都像Git里的一次提交既记录了这次提交是谁做的也记录了上一个提交在哪里。DB_TRX_ID是提交者签名DB_ROLL_PTR是父子提交的链接指针。一个事务能不能看到某个版本本质就是拿自己的快照跟这个签名做比对。2.2 undo log版本链的构建过程现在看一次UPDATE操作在InnoDB内部发生了什么先从聚簇索引定位到目标行。把行当前的内容写入undo log形成一条旧版本记录同时记录旧版本本身携带的DB_TRX_ID和DB_ROLL_PTR。原地修改聚簇索引中该行的业务数据把DB_TRX_ID改成当前事务的ID把DB_ROLL_PTR改成指向刚才写入的那条undo log记录。注意一个关键点物理上最新的行记录是原地覆盖的旧版本不是在缓冲池里另存一份完整拷贝而是进了undo log靠回滚指针串联起来。所谓多版本是逻辑概念物理上你只看到一份当前记录加上一段可回放的undo历史。这也解释了为什么版本很多时查询速度会受影响——因为要沿着链往回找。链的结构大致是聚簇索引当前记录 (balance300, trx_id30) │ roll_ptr ▼ undo记录V2: 旧值 balance200, 当时的trx_id20 │ roll_ptr ▼ undo记录V1: 旧值 balance100, 当时的trx_id10 │ 源头插入操作单独说一下。INSERT也会写undo log但它是insert undo主要用于事务回滚时把新插入的行删掉并不会形成像update那样可供其他事务追溯的版本链因为插入时还没有上一个版本。2.3 删除操作在版本链中的样子DELETE在InnoDB里也不是物理删除而是两步走先在记录头上置删除标记再写一条delete-mark类型的undo log。这条记录同样带DB_TRX_ID指向删除它的事务。对快照读来说删除标记是否生效取决于判断规则如果删除该行的事务对这个读取者可见那这行在读取者眼里就不存在如果删除事务不可见比如还没提交读取者就要沿着版本链往更早的版本去找。真正把带删除标记的物理记录从页面上清掉的是后面的purge线程不是DELETE语句本身。这里有个日常容易踩的坑DELETE大量数据之后表空间可能不会立刻缩小因为旧版本还在undo里、物理记录还在页面上要等purge。以为删完了就是真删完了这其实是把逻辑删除和物理清理混为一谈了。3. ReadView可见性判定一次快照读背后的三步裁决3.1 ReadView的四个核心属性光有版本链还不够还得有一个裁判来决定某个版本对当前事务是否可见这个裁判就是ReadView。它在快照读发生时生成里面记录了生成那一刻的事务全局状态。ReadView有四个关键属性属性含义m_creator_trx_id创建这个ReadView的事务IDm_ids生成时刻所有活跃未提交事务的ID集合m_up_limit_idm_ids中的最小事务ID即低水位m_low_limit_id系统即将分配的下一个事务ID即高水位生活化类比一下你在某一刻进公司拍照登记m_ids是当时仍在工位上没下班的所有人的工牌号m_up_limit_id是其中最早的工牌号m_low_limit_id是下一个要新发出去的工牌号。当你判断另一个人的修改能不能被你看到时公会根据他的工牌号来判断工牌号比最早还在岗的人还小说明早干完走了工牌号比下一个新号还靠前说明拍照之后才进公司肯定没干完。这里有个高频误解要特别纠正m_low_limit_id不是当前最大事务ID而是下一个待分配的事务ID。所以只要一个版本的trx_id大于等于这个值它一定是快照生成之后才出现的事务直接判定不可见。3.2 可见性判定规则拿到ReadView之后按照下面的顺序对每个版本的trx_id做判断如果trx_id等于m_creator_trx_id说明这个版本是当前事务自己写的可见。如果trx_id小于m_up_limit_id说明该版本在快照生成前就已经提交可见。如果trx_id大于等于m_low_limit_id说明该版本属于快照生成后才出现的事务不可见。如果trx_id在m_ids里说明该版本还属于活跃未提交事务不可见。其余情况也就是trx_id在低水位和高水位之间、且不在活跃事务列表里说明它在快照生成前已经提交可见。第2条需要解释一下为什么小于最小活跃ID就一定已提交如果这个事务在快照生成那一刻还活跃它的ID一定会出现在m_ids里而m_up_limit_id是m_ids里的最小值所以小于它的ID不可能活跃必然是已提交状态。反过来第5条的版本是在快照生成前拿到ID、在快照生成前提交的同样可见。3.3 沿着版本链找第一个可见版本判定不是只做一次而是要沿着版本链从新到旧逐级找直到找到第一个满足可见性规则的版本。具体流程是定位到物理当前版本读出它的DB_TRX_ID和删除标记。用ReadView判断这个版本是否可见如果可见且无删除标记直接返回这行数据。如果可见但有删除标记相当于这行在读取者眼里已经删除返回无此行。如果不可见顺着DB_ROLL_PTR跳到上一个undo版本重复上述判断。如果整条链走完都没有找到可见版本说明这行要么是未提交事务刚插入的要么在当前快照里已经被删掉了读取者就不应该看到它。举个例子某次快照生成时活跃事务集合是[20, 25]m_up_limit_id20m_low_limit_id28当前事务creator_trx_id18。某行物理版本trx_id25在活跃列表里不可见顺着回滚指针找到旧版本trx_id22仍然在活跃列表里不可见再往前找到trx_id15因为15小于20判定可见于是读到的就是15号事务留下的数据。这段逻辑是所有MVCC问题的核心建议自己拿一张纸多画几遍。画熟了之后你会发现各个隔离级别下的读取差异几乎都可以用ReadView什么时候生成这一个变量来解释。4. 快照生命周期RC与RR下MVCC的核心差异4.1 READ COMMITTED每条SQL都是独立快照RC隔离级别下事务里每一条普通SELECT执行前都会重新创建ReadView。也就是说同一条SQL在一个事务里执行两次如果这中间另一个事务提交了第二次的结果就可能不同。这正是不可重复读的含义。但脏读仍然被防住了其他事务未提交的版本其trx_id一定还在该语句快照的m_ids里判断不可见所以读取者不会读到没提交的数据。这种每条语句一个快照的实现非常简单粗暴好处是事务边界和快照边界基本一致长事务也不太容易积累旧版本坏处是应用层如果想要事务内读到的数据始终一致RC做不到。4.2 REPEATABLE READ第一次快照读把整个事务钉住RR隔离级别下事务内第一次普通SELECT生成ReadView后这个ReadView会被保存并复用直到事务结束。后面的所有快照读都拿同一个ReadView去判断可见性所以整个事务看到的是一幅固定画面。这就是RR名字的由来也是它能阻止不可重复读的根本机制。这里有两个容易忽略的细节。第一个细节如果事务的第一条语句是UPDATE、DELETE或SELECT ... FOR UPDATE这类当前读它并不会触发快照ReadView的生成。直到事务里出现第一条普通SELECT快照才建立。所以事务一开启就固定快照这个说法不严谨准确说是事务第一条快照读固定快照。第二个细节事务自己修改的数据永远可见因为那些版本的trx_id等于creator_trx_id。所以RR下你可以在事务里先更新一行再普通SELECT读到的一定是新值不会因为快照旧而读回老值。4.3 一个对照实验RC和RR的读取差异光讲规则还是抽象用一个实际场景跑一遍最直观。先建一张表CREATE TABLE account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO account VALUES (1, 100);RR下的表现-- 会话A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 返回100此时建立快照 -- 会话B独立连接 UPDATE account SET balance 200 WHERE id 1; COMMIT; -- 会话A继续 SELECT balance FROM account WHERE id 1; -- 仍然返回100快照没变 COMMIT; SELECT balance FROM account WHERE id 1; -- 事务结束后能看到200RC下的表现-- 会话A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 返回100 -- 会话B独立连接 UPDATE account SET balance 200 WHERE id 1; COMMIT; -- 会话A继续 SELECT balance FROM account WHERE id 1; -- 返回200因为每条语句都新建快照差异的本质就在于快照的生命周期RC是语句级RR是事务级。这个实验我建议你自己在本地跑一遍比背一百遍定义都管用。4.4 快照读之外的坑当前读与幻读RR既然快照固定了普通SELECT就不会出现幻读。但当前读不一样它读的是最新已提交版本如果只加行锁不加以控制一个事务用SELECT ... FOR UPDATE扫了一段范围另一个事务往这段范围里插入新行第一个事务的后续操作就可能看到多出来的行也就是幻读。InnoDB给出的方案是next-key lock它是记录锁和间隙锁的组合。在RR下对索引范围做当前读时被扫描到的记录会加记录锁记录之间的空隙也会加间隙锁把整个区间锁死让其他事务无法往里插入。这也是为什么RR下并发插入经常出现间隙锁等待和死锁代价比RC高。到了RC隔离级别InnoDB对索引扫描和搜索通常会禁用间隙锁只保留记录锁死锁概率明显下降。这也是很多互联网业务宁可牺牲一点隔离性也要用RC的原因但代价是会出现不可重复读业务要自己接受。UPDATE里还有一个容易被忽略的semi-consistent read优化当一行被其他事务锁住时InnoDB可能先用最新已提交版本来判断该行是否真的满足WHERE条件如果不满足就可以不继续等锁降低锁等待。5. 小心MVCC的隐性成本长事务、undo膨胀与purge滞后5.1 purge线程旧版本迟早要被清理undo log不会永远保留。当一个事务提交后它产生的undo记录并不能立刻清理因为可能还有其他活跃事务的ReadView仍在尝试访问这些旧版本。旧版本真正被回收需要满足一个条件对于所有活跃ReadView来说这个旧版本已经铁定可见。可以理解为如果某版本对每个活跃事务的快照都一定可见那就不会再有任何事务需要沿着链走到它前面去它就可以安全退役。这个清扫工作由后台purge线程负责。它除了回收undo记录还会把页面上带删除标记、且已经无人需要访问的记录做物理清理同时处理二级索引的历史入口。InnoDB默认有若干个purge线程由innodb_purge_threads控制还可以通过innodb_max_purge_lag让DML在purge落后时被延迟主动给purge创造追赶时间。这台环卫车要是停下来积压的后果很快就显现。5.2 长事务为什么是undo log膨胀的元凶长事务等于持有一个很老的ReadView。只要这个ReadView还活着所有比它更新的undo版本就都不能被purge因为说不准那个快照什么时候会沿着版本链找过来。结果就是History list length不断增长undo表空间持续扩大热点行的版本链越来越深。我实际遇到过这样一个case一张订单表核心订单行在业务处理过程中被中间状态更新了十几次正好有一条报表事务跑了一个多小时没结束。结果这行的undo链攒了十几条等报表事务结束后purge才开始慢慢追。期间现象就是undo表空间涨了十几个GSHOW ENGINE INNODB STATUS里的History list length一直居高不下。复盘下来真正的元凶不是那十几条更新而是那个只读但超长的报表事务把快照拖得太久。长事务还会连带影响DDL、备份和其他需要purge配合的操作。有时候DROP TABLE卡住不动查下去也是purge被长事务堵住。5.3 快速定位长事务的实用SQL排查长事务最直接的就是查information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_mysql_thread_id, trx_rows_modified, trx_isolation_level FROM information_schema.innodb_trx ORDER BY trx_started ASC;重点看running_seconds最大的几行再把trx_mysql_thread_id对应到会话SELECT * FROM performance_schema.threads WHERE PROCESSLIST_ID trx_mysql_thread_id;确认是无用连接后用KILL trx_mysql_thread_id结束它。不过要注意只读事务在很多版本里可能不占事务IDtrx_id显示为0但它在RR下创建的ReadView同样会拖住purge。我的经验是判断一个会话是否危险别只看它有没有更新数据还要看它的事务起始时间和隔离级别。配合SHOW ENGINE INNODB STATUS\G里TRANSACTIONS段落的History list length也能快速判断积压。数值持续增长且已经到几万甚至几十万基本可以断定有长事务或purge异常。6. 排查MVCC问题的实用清单与高频疑问6.1 常见问题速查表把我在工作中遇到的MVCC相关现象整理成一个速查表遇到类似情况可以按图索骥现象可能原因处理方向同一事务内两次普通SELECT结果不一致RC下快照按语句重建其他事务在中间提交改用RR或接受RC语义并在业务层处理RR事务里读不到别的已提交事务的数据快照在第一次快照读时固定检查事务起始时间和隔离级别必要时缩短事务先UPDATE后SELECT读到旧数据快照在UPDATE之前已建立普通SELECT走快照读确认是否需要当前读考虑用FOR UPDATEundo表空间持续增长长事务、大事务、purge滞后定位innodb_trx中的长事务KILL或拆分必要时调innodb_purge_threads死锁频繁出现当前读与间隙锁组合分析锁等待评估是否可以用RC binlog row格式大量DELETE后表空间不降逻辑删除等待purge物理清理等待purge、确认无长事务再做OPTIMIZE TABLE或合理归档6.2 三个最容易被追问的细节只读事务会不会阻塞purge会。很多开发者以为只有写事务才跟MySQL内部事务状态挂钩实际上只读事务在RR下创建了快照就可能让旧版本无法回收。所以我只是在跑一个很长的查询不影响别人这种想法在MVCC下要打个问号。trx_id显示为0的事务要不要管在部分版本里只读事务的trx_id会显示为0但它的ReadView仍然是真实存在的。判断影响不能只看事务ID要看会话活跃时长和SQL状态。我习惯按trx_started和PROCESSLIST_ID两条线索同时排查。MVCC能否彻底替代锁不能。MVCC解决的只是快照读和写操作之间的冲突写写之间、当前读之间依然要走锁RR下的范围当前读还要用间隙锁。MVCC并没有让并发控制变成无锁而是把读这个维度从锁的竞争里剥离了出去。6.3 我对MVCC的实操体会最后分享一个我自己的复盘早期排查RR事务下数据不一致这类问题我总习惯先去看业务代码后来才发现90%的案例都是因为事务在第一条普通SELECT之前还夹杂了当前读或者应用把多个业务操作塞进了同一个长事务。现在的习惯是出问题先查information_schema.innodb_trx确认事务起始时间和隔离级别再回到代码里分析语句顺序。MVCC的规则本身并不复杂真正复杂的是你的事务边界设计。把每一条SQL归类为快照读还是当前读把每个事务的生命周期画清楚绝大多数并发问题都能在这个框架里找到答案。