拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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

先说明一下,MySQL的MVCC(Multi-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 MODE
  • SELECT ... FOR UPDATE
  • UPDATE、DELETE
  • INSERT内部的唯一索引冲突检查

如果一条UPDATE语句的WHERE条件命中了某行,它会先找到最新版本再加排他锁,然后修改。如果另一个事务还没提交,当前读就要等锁。这也是为什么MVCC并不能让所有操作都变成无锁。

另外要注意隔离级别对MVCC的开关影响,我整理了一个对照表:

隔离级别普通SELECT的行为是否走MVCC
READ UNCOMMITTED直接读最新版本,不判断可见性否
READ COMMITTED每条语句生成一个新快照是
REPEATABLE READ整个事务首次读时生成快照并复用是
SERIALIZABLE普通SELECT被自动转换为加锁读否(变为当前读)

把这两条路想清楚,后面很多所谓"诡异现象"其实都是因为搞混了快照读和当前读。

2. 版本链的底层构造:隐藏字段和undo log如何协作

2.1 每行数据都带着"身份信息"

MVCC不是凭空判断版本的,它的根基埋在每个聚簇索引行记录里。除了业务字段,InnoDB还给每行维护了几个隐藏字段:

  • DB_TRX_ID(6字节):最后一次插入或修改这行的事务ID。
  • DB_ROLL_PTR(7字节):回滚指针,指向undo log里记录的上一个旧版本。
  • DB_ROW_ID(6字节,可选):当表没有主键时,InnoDB用它作为聚簇索引的行标识。

此外,行记录头里还有一个delete flag标记位,表示这行是否被"逻辑删除"。

可以这么理解:每个版本都像Git里的一次提交,既记录了"这次提交是谁做的",也记录了"上一个提交在哪里"。DB_TRX_ID是提交者签名,DB_ROLL_PTR是父子提交的链接指针。一个事务能不能看到某个版本,本质就是拿自己的快照跟这个签名做比对。

2.2 undo log版本链的构建过程

现在看一次UPDATE操作在InnoDB内部发生了什么:

  1. 先从聚簇索引定位到目标行。
  2. 把行当前的内容写入undo log,形成一条"旧版本"记录,同时记录旧版本本身携带的DB_TRX_ID和DB_ROLL_PTR。
  3. 原地修改聚簇索引中该行的业务数据,把DB_TRX_ID改成当前事务的ID,把DB_ROLL_PTR改成指向刚才写入的那条undo log记录。

注意一个关键点:物理上,最新的行记录是原地覆盖的;旧版本不是在缓冲池里另存一份完整拷贝,而是进了undo log,靠回滚指针串联起来。所谓"多版本"是逻辑概念,物理上你只看到一份当前记录加上一段可回放的undo历史。这也解释了为什么版本很多时,查询速度会受影响——因为要沿着链往回找。

链的结构大致是:

聚簇索引当前记录 (balance=300, trx_id=30) │ roll_ptr ▼ undo记录V2: 旧值 balance=200, 当时的trx_id=20 │ roll_ptr ▼ undo记录V1: 旧值 balance=100, 当时的trx_id=10 │ (源头)

插入操作单独说一下。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的事务ID
m_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做判断:

  1. 如果trx_id等于m_creator_trx_id,说明这个版本是当前事务自己写的,可见。
  2. 如果trx_id小于m_up_limit_id,说明该版本在快照生成前就已经提交,可见。
  3. 如果trx_id大于等于m_low_limit_id,说明该版本属于快照生成后才出现的事务,不可见。
  4. 如果trx_id在m_ids里,说明该版本还属于活跃未提交事务,不可见。
  5. 其余情况,也就是trx_id在低水位和高水位之间、且不在活跃事务列表里,说明它在快照生成前已经提交,可见。

第2条需要解释一下为什么"小于最小活跃ID就一定已提交":如果这个事务在快照生成那一刻还活跃,它的ID一定会出现在m_ids里,而m_up_limit_id是m_ids里的最小值,所以小于它的ID不可能活跃,必然是已提交状态。反过来,第5条的版本是在快照生成前拿到ID、在快照生成前提交的,同样可见。

3.3 沿着版本链找第一个可见版本

判定不是只做一次,而是要沿着版本链从新到旧逐级找,直到找到第一个满足可见性规则的版本。具体流程是:

  1. 定位到物理当前版本,读出它的DB_TRX_ID和删除标记。
  2. 用ReadView判断这个版本是否可见;如果可见且无删除标记,直接返回这行数据。
  3. 如果可见但有删除标记,相当于这行在读取者眼里已经删除,返回"无此行"。
  4. 如果不可见,顺着DB_ROLL_PTR跳到上一个undo版本,重复上述判断。
  5. 如果整条链走完都没有找到可见版本,说明这行要么是未提交事务刚插入的,要么在当前快照里已经被删掉了,读取者就不应该看到它。

举个例子:某次快照生成时,活跃事务集合是[20, 25],m_up_limit_id=20,m_low_limit_id=28,当前事务creator_trx_id=18。某行物理版本trx_id=25,在活跃列表里,不可见;顺着回滚指针找到旧版本trx_id=22,仍然在活跃列表里,不可见;再往前找到trx_id=15,因为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 ) ENGINE=InnoDB; 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; -- 事务结束后,能看到200

RC下的表现:

-- 会话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表空间涨了十几个G,SHOW ENGINE INNODB STATUS里的History list length一直居高不下。复盘下来,真正的元凶不是那十几条更新,而是那个"只读但超长"的报表事务把快照拖得太久。

长事务还会连带影响DDL、备份和其他需要purge配合的操作。有时候DROP TABLE卡住不动,查下去也是purge被长事务堵住。

5.3 快速定位长事务的实用SQL

排查长事务,最直接的就是查information_schema.innodb_trx:

SELECT 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>结束它。不过要注意,只读事务在很多版本里可能不占事务ID,trx_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 UPDATE
undo表空间持续增长长事务、大事务、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归类为快照读还是当前读,把每个事务的生命周期画清楚,绝大多数并发问题都能在这个框架里找到答案。

返回列表