☰
一文讲透MySQL MVCC:原理、实践与排查
2026/10/19 6:07:46 网站建设 项目流程

后端同学只要聊到MySQL隔离级别、undo log、行锁、幻读,几乎都绕不开MVCC这个名字。我自己在面试候选人和日常代码评审里见过太多对MVCC的理解,多数人能很快说出“多版本并发控制,读写不互斥”,但再往下问ReadView是什么时候生成的、版本链怎么走、RC和RR为什么表现不一样,往往就开始含糊了。其实MVCC不是多高深的东西,它是InnoDB存储引擎里一个相当精巧的设计:用一份历史版本和一个判断规则,把“读”拆成普通快照读和加锁的当前读两条路,让读写各走一边,互不等待。这篇文章我想从设计动机、行记录结构、ReadView可见性规则、隔离级别差异到线上排查,把整套机制从头到尾串一遍。既适合把数据库当黑盒的业务开发同学补内功,也适合准备后端面试、想答得比别人深一点的朋友。

先讲一个我印象很深的场景。某次在做类似交易状态的模块时,支付回调更新订单状态用的是“先查后改”的写法:普通select读一次状态,判断没问题,再update。压测一上来就偶发覆盖,数据被旧状态回刷。根因并不是代码粗心,而是当时团队没吃透快照读和当前读的边界,普通select拿到的是另一个事务提交前的“历史版本”,拿这个历史版本去覆盖当前版本,自然就丢更新。后来所有这类逻辑都改成当前读,或者用带条件的更新SQL,问题才消失。所以我觉得理解MVCC,不只是为了应付面试。实际业务里的很多诡异数据问题,追到最后都是对“SQL读的是哪个版本”判断错了。

1. 先弄懂MVCC想解决什么问题,再看机制才不晕

1.1 数据库并发控制里的死结:读写互相等待

在没有MVCC的朴素锁模型里,同一行数据同时只有一个事务能修改,而且读也需要锁。事务A写入一行未提交之前,事务B要读这一行,只能等待A提交;反过来如果B先持有读锁,A的写入也要等待。这在业务上有一个名字——读写互斥。读写互斥逻辑简单、实现容易,但代价很大:数据库里绝大多数请求是读请求,一次普通的商品详情查询、订单列表查询,如果都要被正在写入的其他事务阻塞,那么稍微有点并发量的系统就会性能崩溃。

更麻烦的是,长的写事务会让读等待时间不可控。比如一个事务在批量更新,执行了十几秒,期间所有涉及这些行的读请求全部排队。如果恰好有大量读请求集中过来,后端线程池会被数据库连接占满,应用层的超时和重试随之而来。所以MVCC的诞生动机非常现实:与其用锁锁住读,不如让写事务别去破坏旧数据,让每个读到等待的事务直接读历史版本。这种情况下,读不等待写,写也不等待读,大家并行。这个思路听起来简单,写起来难,难点就在于“读旧版本时,哪份旧版本才是这个事务应该看到的”。

1.2 快照读与当前读:MVCC把读分成了两条路

MVCC机制里有一个很基础也很关键的划分:快照读和当前读。快照读就是我们最常用的普通select,它不加锁,InnoDB会借助ReadView加undo版本链找出版本,读到的可能是某次快照时的旧数据。当前读是指select ... for update、select ... lock in share mode,以及update、delete、insert这些必须读数据库最新状态的语句,它们会加锁,读的一定是当前已提交的最新版本。

我用一个表格把这两类读放一起看,记忆会更清晰:

读类型是否加锁看到什么版本典型语句
快照读不加锁基于ReadView判断出的历史版本SELECT ...
当前读加锁(Record/Gap/Next-key等)最新已提交版本SELECT ... FOR UPDATE / UPDATE / DELETE / INSERT

MVCC只负责快照读这一侧。当前读是靠锁和事务隔离机制来保证的,很多人把MVCC说成“并发控制神器”,实际它管不了写写冲突,写写冲突最终还是锁来裁决。理清这个边界,后面分析RC、RR、幻读才不会被概念绕晕。

1.3 MVCC的四件套:隐藏列、undo log、ReadView、可见性判断

MVCC在InnoDB里的落地,主要有四件事:第一,每行记录增加隐藏列,记录“这行是谁改的”和“旧版本在哪里”;第二,更新和删除时,旧数据写入undo log,形成版本链;第三,事务做快照读时,生成一个ReadView,记录生成瞬间数据库里有哪些事务还没结束;第四,拿着ReadView的规则,从当前版本链上挑一个对当前事务可见的版本。四者合起来,才能让一个普通select在没有任何锁的情况下,始终看到一份逻辑一致的数据。

你可以把ReadView理解成一张“当时未提交名单”,把版本链理解成一行数据的“历史底稿”。平时读数据,数据库不会一上来就答应给你最新值,而是先看你手里的名单,再顺着历史底稿往回找你该看的那一版。如果没有MVCC,普通读也要加锁排队,这就是它提升并发能力的本质。接下来两节,我们把“历史底稿”和“未提交名单”分别拆开看,你会发现原理并不复杂。

2. 一行记录背后的隐藏信息,是MVCC的物理基础

2.1 InnoDB行记录里,除了业务字段还藏着什么

很多业务开发同学平时看的就是表的字段,但InnoDB在存储行数据时,除了建表字段,还会额外维护一些隐藏字段。在聚簇索引的记录中,常见的有三个:DB_TRX_ID,记录最近一次修改这条记录的事务ID;DB_ROLL_PTR,回滚指针,指向上一条历史版本在undo log中的位置;DB_ROW_ID,就是行ID,如果表没有显式主键,InnoDB会用它来充当隐式主键,有主键时这个字段可以不关注。

这三个隐藏字段和业务无关,但对MVCC至关重要。每次事务执行update,MySQL并不会把旧数据直接擦掉重写,而是把修改前的旧值移到undo log,再把新值放到当前记录里,并更新DB_TRX_ID为新事务ID,DB_ROLL_PTR指向旧值。你可以把这个过程类比成协同文档的版本历史:正文永远显示最新内容,但每一次修改都在历史记录里留了草稿,需要时随时回溯。

我记得有一次排查一个表结构的时候,曾经看到过一张没有主键的表,数据量不大但回滚段增长异常。后来发现就是因为表没有主键,每行都多了一个DB_ROW_ID的隐式主键,加上隐式二级索引和重复回滚,把存储拖累了。所以建表尽量有业务主键,不光是规范问题,还直接影响存储引擎行为。

2.2 update操作如何打出一条版本链

版本链的建立过程,其实是一次更新一次链接。假设有这么一张用户余额表:

CREATE TABLE user_balance ( id INT PRIMARY KEY, user_no VARCHAR(32) NOT NULL, balance INT NOT NULL ); INSERT INTO user_balance VALUES (1, 'A001', 100);

初始化的insert会写入一条版本为T100的记录。事务T200更新了balance为50,T300又更新为80。那么此时磁盘上的主记录是T300生成的版本,它的DB_ROLL_PTR指向undo log里T200提交的旧版本,T200版本的指针又指向T100的insert版本。这样一条版本链就形成了:T300 → T200 → T100。

执行update时有一个关键动作:先做当前读,找到最新版本的记录并加锁,然后才对记录做修改。修改完成后,旧版本内容进入undo log,新版本回到聚簇索引。所以每条版本链的头部永远是最新版本,越往尾巴走,事务ID越小。MVCC遍历时会从最新版本往旧版本找,而不是从旧往新找。

删除操作也和更新类似。InnoDB删除通常不会立刻把记录物理擦除,而是先给记录打一个删除标记(delete mark),并把删除事务ID写入DB_TRX_ID。之后如果其他事务的快照还需要这条记录,读取时依然能看到它;只有purge线程在确定这条记录对一切事务都不可见后,才会真正释放空间。

2.3 插入、更新、删除三类undo的寿命不同

undo log不是只有一种。insert产生的undo log在事务提交之后就可以被清掉,因为在插入之前这条记录根本不存在,其他事务不可能需要看到“这条记录还没插入”的那个历史版本,它能服务的只有插入事务自己,事务都提交了,自然就没人再需要回滚。而update和delete产生的undo log,因为其他事务可能还拿着旧快照在读取,必须等到“所有有可能看到旧版本的事务都结束”之后,才能交给purge线程清理。

这也是很多“undo无限膨胀”问题的根源。一个业务事务不结束,数据库就不敢清理它之后产生的历史版本。如果这个事务同时又做了一次大范围更新,那一堆旧版本就会一直挂在版本链上。后面的排查章节我会专门讲怎么发现这种问题。

3. ReadView:MVCC如何判定哪个版本可见

3.1 一次快照读,先生成一张“未提交事务名单”

ReadView是MVCC里最核心的判断工具。简单说,它是在一次快照读生成瞬间,InnoDB对当前系统里所有活跃事务的记录。所谓活跃,就是已经启动但还没提交的事务。ReadView里主要有四个部分:

ReadView字段含义
m_ids生成ReadView时,当前系统里所有活跃事务的ID列表
min_trx_idm_ids中最小的事务ID
max_trx_id当前系统下一个待分配的事务ID,可以理解成已分配最大事务ID加一
creator_trx_id创建这个ReadView的事务自己的ID

m_ids这个列表特别重要,它只关心“当时还没结束”的事务。已经提交的事务不在里面,未来才启动的事务更不在里面。判断一个版本是否可见,本质上就是问一个问题:生成这个版本的事务,在ReadView生成的那个瞬间,处于什么状态?如果它已经提交,这个版本就可能可见;如果它还在活跃,那可见性就直接否决;如果它是ReadView生成之后才出现的新事务,那它改出来的新版本当然也不该让我看到。

3.2 三条判断规则,把版本链从头扫到尾

当一条记录存在多版本时,InnoDB会拿当前版本链上的事务ID,从上往下逐版本套用判断规则。我按最常见的理解,把它总结成三步:

  1. 如果版本的事务ID等于creator_trx_id,说明这条数据是当前事务自己修改的,直接可见。
  2. 如果版本的事务ID小于min_trx_id,说明这个版本由ReadView生成前就已经提交的事务创建,直接可见。
  3. 如果版本的事务ID大于等于max_trx_id,说明这个版本由ReadView生成之后才启动的事务创建,不可见,必须继续往前找旧版本。

如果事务ID落在min_trx_id和max_trx_id之间,还要再看它是否在m_ids活跃列表里:如果在,说明生成这个版本的事务当时还没提交,不可见;如果不在,说明事务已经在ReadView生成前提交了,版本可见。

我举个实际例子。假设当前事务T300执行了一次select,生成ReadView时,活跃事务列表是[T200, T250],min_trx_id是200,max_trx_id是301。某条记录版本链是T300 → T200 → T100。遍历开始:最新版本T300的trx_id等于creator_trx_id,第一眼就会命中,直接看到自己的修改,停止查找。如果这条记录都是别人改的,比如版本链是T500 → T250 → T100,其中T500的ID超过max_trx_id,不可见;T250落在活跃列表里,不可见;再看T100,小于min_trx_id,可见。于是一条普通select不做任何加锁,却仍然看到了“一致性视图”下的正确版本。

3.3 必须从新往旧找,不能跳版本

看版本链有个容易忽略的细节:判断顺序永远是从最新版本往最旧版本遍历,找到第一个可见版本就返回,不会跳着找,也不会把整条链都扫完才决策。之所以这样设计,是因为最新版本最接近当前事务应该看到的状态,通常命中率高;而且每个版本的可见性独立判断,前一个不可见,不代表后一个不可见,边界情况都必须往前回溯一层才能确定。

实际开发里大家可能遇到过一种现象:普通select查出来的数据很“旧”,明明另一个事务已经提交了新值,这边还没看到。这就是因为当前事务的ReadView里,新提交事务ID不在“已提交可信任区间”内,版本链遍历到最新版本时判定不可见,退回旧版本返回。理解了这个,就理解了为什么“过一会儿再查数据就变了”——那通常是事务结束,新的事务重建了ReadView,视图刷新了。

4. ReadView的出生时机,决定了RC和RR的命运

4.1 可重复读和读已提交,差别在一个时机

隔离级别RC和RR在InnoDB下对快照读的主要差别,就在于ReadView的生成时机。RC隔离级别下,事务内每次普通select都会生成一个新的ReadView;RR隔离级别下,事务内第一次普通select生成ReadView之后,后续快照读一直复用同一个,直到事务结束。

这个差异看似很小,但带来的行为变化是决定性的。RC因为每次读都看一遍最新活跃事务,所以能看到其他事务已经提交的新数据;同时同一条记录两次读取,可能返回不同内容,也就是不可重复读。RR因为ReadView固定住了,后续select看到的版本集合固定在那一个时间点,事务内即使别人提交了新数据,快照读也当没看见。

4.2 一个经典实验:一个事务里两查一改

我用最简单的两会话实验说明差异。假设表里数据是id=1的balance=100,现在两个会话同时开事务。

操作顺序会话A会话B
1START TRANSACTION;START TRANSACTION;
2SELECT balance FROM user_balance WHERE id=1; -- 100
3UPDATE user_balance SET balance=200 WHERE id=1; COMMIT;
4SELECT balance FROM user_balance WHERE id=1;

在RC下面,会话A第二次select会看到200,两次结果不一致。在RR下面,会话A第二次select依旧看到100,因为第一次select生成的ReadView里,B事务还是活跃事务,它的提交不影响这个ReadView的判断。RR的可重复读,在快照读层面就被ReadView的复用稳稳兜住了。

需要提醒一下:这里说的是“第一次快照读”,不是begin那一刻。RR事务如果先执行update、select for update这类当前读,再执行普通select,可能会看到当前读之后的状态,因为当前读走的是另一套加锁逻辑。不少网文把RR说得过于绝对,以为“RR事务开始以后再也不会看到新数据”,这句话只说对了一半,准确表述是:RR的普通快照读不会看到新数据,当前读还是会看到最新已提交数据。

4.3 幻读的“一半是快照读的功劳,一半是锁的功劳”

幻读指同一个查询条件执行两次,第二次冒出了第一次没有的新记录。RR下普通快照读因为ReadView复用的原因,看不到其他事务新插入并提交的记录,所以问题不大。但如果是当前读,比如select count(*) from t where id > 10 for update,第一次锁定记录范围,此时其他事务往id=20的位置插入新记录,如果没有间隙锁,第二次再统计就会多出一条。因此RR下的当前读要防幻读,真正起作用的是Next-key Lock,锁住记录本身之外还锁住了索引间隙,让新记录根本插不进来。

很多人分不清“RR能不能完全防幻读”这个问题。我的观点是:快照读能防,当前读靠锁也能防,但要满足锁覆盖的范围足够精确。如果表上没有合适的索引,锁可能放大到很大范围,并发度下降,甚至出现锁等待和死锁;如果在业务里混用了快照读和当前读,又会出现看似“幻读”的怪现象。MVCC并不包办所有隔离保障,它和锁是一个组合拳。

5. 当前读和Next-key Lock:MVCC不能包打天下的另一半

5.1 为什么update和delete必须先“当前读”再改

前面提到update执行时会先加锁读取最新记录。这个“先读锁再写”的设计,是为了防止两个事务同时对同一条记录做基于旧值的修改。比如两个事务都读到balance=100,都执行update balance=balance-20,如果没有当前读的加锁保护,后提交者可能会基于过期值计算,覆盖掉前一个事务的修改。当前读保证读取最新已提交版本并加锁,写写冲突在这里就被锁挡掉了。

一个常见业务坑是,用普通select判断条件后执行update。普通select走的是快照读,读的是历史版本,历史版本可能已经过期。用过期状态去执行update,轻则更新行数为0,重则直接把别人已经更新的状态回刷。正确做法包括:直接使用带条件更新的SQL,例如update order set status='PAID' where id=? and status='UNPAID';或者先用select ... for update锁定该行,再在事务内做业务判断和更新。

5.2 Next-key Lock是怎么把间隙堵上的

Next-key Lock可以理解成Record Lock和Gap Lock的合体。Record Lock锁住索引记录本身,Gap Lock锁住记录之间的间隙,防止其他事务在间隙中插入新数据。比如主键索引上有id=10和id=20两条记录,事务A执行select * from t where id between 10 and 20 for update,如果这段范围命中了两条记录和中间间隙,B想插入id=15,就会被间隙锁阻塞。

在RR隔离级别下,InnoDB对索引扫描的当前读默认会加Next-key Lock,这是防幻读的关键;而对唯一索引等值查询,如果记录确定存在,退化为Record Lock,因为不需要防幻读。在RC隔离级别下,间隙锁基本被关闭,只保留Record Lock,所以RC允许幻读。MVCC解决的是快照读的历史版本问题,Next-key Lock解决的是当前读的并发写入控制问题,两者是在两个层面干活。

5.3 快照读和当前读混用时,要注意一致性被打破

网上有个经典讨论:RR下先select普通查询,再update,update之后普通select看到的数据可能和第一次select不一样。原因就在当前读读最新版本并写入了新版本,之后普通select虽然复用旧ReadView,但当前记录头部的DB_TRX_ID已经变成当前事务自己,按照可见性规则第一条“自己改的可见”,于是看到了新值。严格来说这不是MVCC失效,而是当前读把该事务自己的版本写进了版本链,自己的修改当然对自己可见。

我在处理订单状态机的时候习惯一个原则:事务内要么全走当前读,要么全走快照读,不要交叉。对账、报表这类逻辑不需要最新数据,用快照读保证一致性;库存扣减、状态流转这类必须基于最新状态的逻辑,统一用当前读或条件更新。把这条原则想清楚,比死记幻读定义有用得多。

6. 线上排查实录:当版本链和undo日志拖垮性能

6.1 三个误区,很多人一开始都踩过

关于MVCC的误解,我总结过三个高频的。第一个是“MVCC能解决写写冲突”,实际上写写冲突还是靠锁,MVCC只负责读写并发。第二个是“RR隔离级别下事务永远看不到别的新数据”,实际只是快照读看不到,当前读能看到。第三个是“undo log反正会自动清理,不用管”,实际有长事务存在时,清理会停滞,版本链越拉越长,严重时会拖慢所有查询和更新。

这些误区在业务事故里会放大。比如有人为了防并发扣减把事务隔离级别调到RR,但代码里还是普通select+update,结果照样覆盖更新;又比如一个后台统计事务开了半天不提交,前端订单表更新全部被历史版本拖住,undo段暴涨。弄懂MVCC不是只为了面试题,这些坑在真实环境里非常常见。

6.2 怎么判断undo堆积和长事务

最直接的办法是看information_schema。查询正在运行的事务:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE trx_started < NOW() - INTERVAL 30 MINUTE;

如果查到很久没提交的事务,需要进一步看它持有锁和undo的情况。另一个常用手段是执行SHOW ENGINE INNODB STATUS,关注其中的History list length,这个值表示未purge掉的undo日志数量。如果它只涨不降,大概率是有长事务或过期快照一直没释放,purge线程无法把对应的历史版本清掉。

我曾经遇到一个案例,订单查询接口偶尔从几百毫秒突然变成几秒,排查发现是某个报表连接开启事务后长时间不提交,期间产生了几十万条update的undo,每条订单记录被拖成很长的版本链。每次查询为了找可见版本,从最新版本一路回溯好几层,CPU和IO自然就上去了。处理方法是先让应用侧提交或回滚事务,观察History list length下降,再对存量undo做一次确认,性能很快就恢复。直接kill连接是最后手段,因为会导致未提交事务回滚,如果事务很大,回滚本身也是一场灾难。

6.3 实操层面的版本链控制建议

先说参数,和purge相关的参数不要在没确认根因前乱调。innodb_purge_threads控制purge线程数量,高并发写入场景可以适当调大,但不是越大越好;innodb_max_purge_lag控制purge延迟阈值,这个值设置不当会让写入被反向阻塞。我的建议是先修业务,再调参数。把长事务控制住,把每个事务的执行时间控制住,版本链长度自然降下来。

在代码层面,有几个习惯值得养成。事务里不要做远程调用,不要写日志之外的大批量耗时代码,更不要为了省连接把多个业务操作塞进一个长事务。大事务分批提交,尽量缩短SQL执行时间。低频统计如果不需要强一致性,可以放到从库或调整隔离级别,让主库的大量历史版本能够及时purge。

6.4 本地复现这套机制,一次就通

如果这些内容光看还是容易忘,我强烈建议在本地起一个MySQL实例,用两个命令行会话手动复现一遍。步骤不复杂:先建一张只有id和balance的表,插入一行数据;开事务A执行select,不提交;开事务B执行update并提交;事务A再执行select看结果。分别把隔离级别切到RC和RR,对比差异。

进一步还可以用performance_schema或information_schema观察事务ID变化。比如在事务A更新之后,通过innodb_trx查到事务ID在增长;更新提交后undo状态变化。这样把ReadView、版本链、purge几个概念通过实际操作串起来,比记十篇文档都有用。我在团队内部培训时已经让不少人试过,基本一次实验就能把RC和RR的区别讲明白。

7. 面试追问不再慌:MVCC的边界与几个细节

7.1 ReadView存在磁盘里吗

有人会问ReadView是不是像索引一样存到某个文件,不是。ReadView是InnoDB在内存中根据当前活跃事务状态临时构造的结构体,事务系统会维护全局的事务ID分配和活跃事务链表,快照读发生时直接读取这些内存信息生成。它不需要持久化,因为它是某个时刻的瞬时视图,事务结束就失去意义。MVCC留存在磁盘上的主要是undo log和隐藏列,ReadView本身不落盘。

7.2 共享锁在MVCC出来后是不是没用了

MVCC让普通select不用加共享锁,但共享锁依然有用。select ... lock in share mode就是显式加S锁,通常用于某些业务要求读取期间禁止其他事务修改、但允许其他事务继续读的场景;另外在复制、备份等模块也需要读取一致性状态。把它们和MVCC关系理解成:快照读走版本链不加锁,当前读走锁机制,需要与事务隔离级别配合。

7.3 为什么insert undo提交后就能清,update undo不行

这回到了第2节的问题,insert undo服务于“插入前没有这条记录”的回滚场景,只有插入事务自己可能回滚;事务提交后,任何其他事务都不需要看到这条记录的旧状态,因为插入前本来就不存在。update undo则不同,一个事务更新了某行,另一个更早读的事务可能还认为旧版本是当前版本,所以必须留着,直到没有任何事务还需要那个旧快照。

这样的细节在面试中很能体现理解深度。面试官问MVCC,表面上听你在讲ReadView判断,实际上是在考察你是否理解“旧版本为什么存在”“谁在看旧版本”“什么时候能清理旧版本”这一整条链路。真正做过线上排查的人,聊到undo堆积和长事务时明显更有底。

我做过的项目中,替别人解决过几次“明明是正常select,却查到旧数据”的疑惑,最终都指向同一点:普通select读到的版本,取决于发起该事务时ReadView里那些还没提交的事务。想通这条链路之后,很多事务并发问题就变成了一道简单的判断题:某版本事务ID是否在活跃列表里。遇到数据库读多写多、数据状态回刷、或者明明提交了却看不到新数据,都可以按这个思路反向推导一遍。这套机制不是用来背的,是用来在实际SQL行为里反复验证的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询