☰
InnoDB MVCC底层原理:一条update语句看懂版本链与ReadView
2026/10/13 7:43:02 网站建设 项目流程

MVCC这三个字母,只要是碰过MySQL的人应该都不陌生,但“多版本并发控制”这个名字到底对应到引擎里哪几块东西,能讲清楚的人其实不多。我见过不少同学把MVCC挂在嘴边,真到排查线上不可重复读、undo空间暴涨、RR下还出现幻读这类问题的时候,脑子里还是一团浆糊。今天不聊概论,直接从一条update语句在InnoDB里留下的一串历史版本讲起,把隐藏列、undo log、ReadView这几块零件挨个拆开,再用三个终端做一组能看得见的现象验证。这篇文章适合已经掌握事务基本概念、想往“进阶理解”走的人,目标很明确:看完你能说清楚一条记录在并发事务下为什么被读到那个版本,也知道线上出问题时该去哪儿看、怎么处理。

1. MVCC到底在解决什么问题

1.1 并发事务的冲突只有三种

两个事务同时操作同一行数据时,组合情况严格说只有三种:读读并发、写写并发、读写并发。读读没有任何冲突,InnoDB根本不用管;写写必须严格串行,否则后写的覆盖前写的,数据就烂了,所以要靠行锁、间隙锁这些机制去堵;真正麻烦的是读写并发。

如果没有MVCC,读和写之间必然要有一方让步。最粗暴的做法是读写都加锁:读锁和写锁互斥,你要读,我就不能改;我要改,你就得等着。这样隔离级别倒是高,但数据库的并发能力会惨不忍睹,一个只读请求都能被一个更新事务卡住半天,这在互联网场景下是没法接受的。

MVCC解决的核心问题,就是让“普通查询”和“正在修改的行”之间尽量不互相阻塞。写事务仍然需要加锁,但读事务可以顺着版本链找到一个旧版本直接读,不需要等写锁释放。换句话说,数据库用空间换时间,把每一个被修改过的行都保留一份历史版本,让读操作睁一只眼闭一只眼,假装写还没发生。

1.2 锁方案与版本链方案的区别

锁方案是“互斥”,你占着我就不能用,物理上是单车道,所有车排队;MVCC方案是“共存”,你在前面改新版本,我读旧版本,逻辑上是多车道,各走各的。

我举个生活中的例子。你写文档的时候,同事要看你刚改完的部分,如果只有一个文件,你只能把文件锁起来,等他看完再改。MVCC的思路是:你每改一次,系统自动帮你存一份旧稿,同事想看的时候,就把旧稿递给他,你继续在最新版上改。代价是要多占存储空间,收益是同事不用等你。数据库里这个“旧稿”就是undo log里保存的历史版本,而工具人“ReadView”负责判断:当前这个读者,到底有资格看哪一份旧稿。

1.3 MVCC是“三件套”的合称

MVCC不是某一个独立功能,而是三个组件合作的结果:隐藏列、undo log、ReadView。隐藏列给每条记录打上了事务和版本的标记;undo log保存了每次更新之前的旧数据;ReadView在事务执行快照读的时候,生成一扇“时间窗”,用来判断版本链上哪一个版本对当前事务可见。

这三个缺一不可。隐藏列告诉你“这个版本是谁改的、上一个版本在哪里”,undo log是版本链的物理载体,ReadView是判定规则。很多人只知道“MVCC读旧版本”,却没理解这句话背后的机械结构,后面排查问题就容易抓瞎。下面一章先把版本链这个地基讲清楚。

2. 版本链是怎么搭起来的:隐藏列和 undo log

2.1 每一行记录都藏着三列看不见的数据

InnoDB的表都是索引组织表,表中的每一行记录,除了我们建的字段外,系统还会维护几个隐藏列。

第一个是DB_TRX_ID,表示最近一次修改该行记录的事务ID。这个事务ID不是随机的,是InnoDB内部按顺序分配的,同一时刻全局大致递增。第二个是DB_ROLL_PTR,回滚指针,保存了上一个版本的undo log位置。第三个是DB_ROW_ID,如果没有手工定义主键,InnoDB会用它作为隐藏主键;有了主键后这个列基本用不上,可以不用太在意。

我在教别人理解版本链的时候,喜欢把一行记录想象成一个链表节点。每个节点上有两个关键信息:这条记录是谁改的(DB_TRX_ID),上一个版本去哪儿找(DB_ROLL_PTR)。顺着回滚指针一路往前,就能把所有历史版本串成一条链,这就是常说的版本链。

2.2 一条update语句背后的版本链构造过程

假设有一张用户表,结构如下:

CREATE TABLE t_user ( id INT PRIMARY KEY, name VARCHAR(50), age INT ) ENGINE=InnoDB;

插入一条初始数据,事务ID假设是T100:

INSERT INTO t_user(id, name, age) VALUES(1, '张三', 20);

此时这行记录的版本情况是:

  • DB_TRX_ID = 100
  • DB_ROLL_PTR 为空
  • name = 张三,age = 20

接下来,事务T101执行:

UPDATE t_user SET age = 21 WHERE id = 1;

InnoDB不会直接把老数据删掉,而是先把“当前这行”拷贝到undo log里,记为版本V1;然后在原位置更新age为21,DB_TRX_ID改成101,DB_ROLL_PTR指向undo log里的V1。此时版本链是:

V2(name='张三', age=21, trx_id=101) -> V1(name='张三', age=20, trx_id=100)

如果再执行一次更新,事务T102把name也改了,情况变成:

V3(name='李四', age=21, trx_id=102) -> V2(name='张三', age=21, trx_id=101) -> V1(name='张三', age=20, trx_id=100)

整个过程就是典型的“链头指向最新版本,链尾是最早的insert版本”。更新越频繁,链越长,读事务需要沿着指针找符合自己可见性的版本。

2.3 undo log为什么分两种

undo log不是只有一种。Insert操作产生的undo log和update/delete产生的undo log,生命周期完全不同。

insert undo log负责事务回滚。它保存的是插入行的主键值等信息,目的就是万一事务要回滚,可以按图索骥把插入的行删掉。insert undo log在事务提交后立刻删除,因为插入是“凭空创造”一个版本,提交后没人需要访问这个插入前的“空状态”,留着纯属浪费空间。

update undo log才是MVCC的主角。它保存的是被更新行的完整旧数据。事务提交后,这些undo log不能马上删除,因为可能有更早启动的快照读还在使用它们。只有当确定没有任何事务能看到这个旧版本时,purge线程才会把它真正清理掉。

2.4 删除操作为什么也要进版本链

很多人以为delete就是物理删行,在InnoDB里没那么简单。delete操作的底层实现是给这行记录打一个删除标记,并不真正立即移除,同时也会生成一条update undo log记录,相当于把该行标记为“已删除”版本,继续挂在版本链末尾。

这样做的原因很直接:如果有并发事务在删除前开启了快照读,它仍然需要能看到这行数据。物理删除会破坏版本链,所以只能先打标记。等到purge线程确认没有活跃事务引用这个版本时,才会做物理清理。这也是为什么长事务期间,即使删了很多数据,表空间也不见得变小,历史版本都被保留着。

3. ReadView:判断“哪个版本对你可见”的核心

3.1 ReadView 里到底装了什么

版本链放在那里,每个版本都有自己的trx_id,但快照读怎么知道该选哪个?答案是ReadView。ReadView是事务执行快照读时生成的一份“快照视图”,本质上是一个数据结构,里面记录了四类信息。

字段含义
creator_trx_id创建该ReadView的事务ID
m_ids创建ReadView时,所有未提交的活跃事务ID列表
min_trx_idm_ids中最小的那个事务ID
max_trx_id创建ReadView时,InnoDB将要分配给下一个事务的ID,可以理解成当前已分配事务ID的上界

这里面max_trx_id不是某个已存在事务的最大ID,而是指向“下一个还没分配出去的事务ID”。所以,任何>= max_trx_id的版本,都属于ReadView生成之后才启动的事务,当前快照不可能看见。

3.2 可见性判断逻辑,一句话版本和伪代码

一句话版本:如果一个版本的修改事务ID比当前活跃事务中最小的ID还小,说明它已经提交,可见;如果比即将分配的最大ID还大,说明它还没出生,不可见;如果夹在中间,就看它是否还在活跃事务列表里,不在列表说明已经提交,可见,在列表说明还没结束,不可见;至于当前事务自己修改的版本,当然可见。

排列成伪代码更直观:

if trx_id == creator_trx_id: return 可见 if trx_id < min_trx_id: return 可见 if trx_id >= max_trx_id: return 不可见 if trx_id in m_ids: return 不可见 return 可见

整个过程很好理解。ReadView记录的是“我这一眼扫过去,当时还没提交的有哪些事务”。只要某个版本是这些活跃事务改的,哪怕数据已经写进磁盘,对当前读事务来说也等于不存在。

3.3 手把手推演一次快照读

假设表里最初由事务T100插入了一行,版本链只有一个节点V1,trx_id=100,已经提交。

现在事务T101执行:

BEGIN; SELECT * FROM t_user WHERE id = 1;

这个SELECT触发了快照读,创建ReadView R1。此时T101是最新事务,Active事务列表里可能包含T101自己(如果已分配事务ID),也可能不包含。为了简化场景,我们假设在创建ReadView时活跃事务列表为空或只有T101。

ReadView信息:

  • creator_trx_id = 101
  • m_ids = []
  • min_trx_id = 当前活跃列表最小,如果列表为空则取max_trx_id
  • max_trx_id = 下一个待分配ID,比如102

接下来版本链上V1的trx_id=100。按照规则:100 < min_trx_id(这里如果min是102,则100 < 102)或者100 < max,且不在m_ids,所以可见。查询返回一行。

再模拟一个更常见的“读不到已提交但未提交事务修改”的场景。事务T101更新age=21但未提交,事务T102此时执行快照读。

T102的ReadView里,m_ids中包含101,min_trx_id=101,max_trx_id=103。版本链从最新开始往回找:

  • 最新版本V2的trx_id=101,它等于creator_trx_id?不是,creator是102;它属于m_ids中的活跃事务,不可见。
  • 继续顺着回滚指针对到V1,trx_id=100,小于min_trx_id,可见。
  • 于是T102读到的是旧值age=20。

这就是MVCC底层最核心的一条链路:读操作从最新版本开始,沿着版本链从新往旧找,直到找到满足ReadView可见性规则的那个版本。

4. 隔离级别是怎么影响MVCC的

4.1 RC:每次SELECT都重新生成ReadView

ReadView虽然决定了快照读的数据口径,但它不是事务一开始就固定的,生成时机完全取决于隔离级别。

在读已提交(Read Committed,简称RC)隔离级别下,事务内每次执行快照读都会重新生成一份ReadView。这会导致一个现象:同一个事务里,第一次SELECT和第二次SELECT可能看到不同的已提交数据。

真实案例我在维护订单系统时遇到不止一次。事务A先开启事务,查询订单状态得到“待支付”,随后其他事务把订单状态改成“已支付”并提交,事务A下次再查询,就能直接读到“已支付”。原因就是RC下每次查询都是新的ReadView,都会把最新已提交版本纳入可见范围。这就是教科书上说的不可重复读,在RC下是预期行为。

4.2 RR:第一次SELECT就定终身

可重复读(Repeatable Read,简称RR)是InnoDB的默认隔离级别,它的做法是:事务第一次执行快照读时生成ReadView,之后整个事务期间复用这一份ReadView,不再重新生成。

还是上面那个例子,在RR下,事务A第一次查询读到“待支付”,哪怕其他事务后续提交了“已支付”,事务A的第二次查询依然只会看到“待支付”。因为ReadView是第一次查询时定下来的,它把后提交的事务都挡在可见范围之外,这就是可重复读的实现原理。

很多人在这个点上有一个错误认知:以为RR是“在BEGIN的时候”固定快照。不是的,是第一次执行快照读的时候才固定。如果一个事务BEGIN后一直不做查询,等别的数据都提交了才执行第一条SELECT,那这条SELECT生成的ReadView里,所有已提交事务都在其可见范围内,反而读不到事务开始时刻的历史快照。这个细节在工作中踩过坑的人应该明白。

4.3 快照读归MVCC,当前读归锁机制

MVCC只管快照读,也就是普通的不加锁SELECT。但数据库里还有很多操作属于“当前读”,包括:

SELECT ... FOR UPDATE SELECT ... LOCK IN SHARE MODE UPDATE ... DELETE ...

这些操作必须读取“当前最新已提交版本”,并且对相关行加锁,它们不走MVCC的可见性判定。当前读之间发生冲突时,靠的是行锁、间隙锁这些锁机制,而不是版本链。

这条边界一定要记牢。我见过不少开发者误以为RR级别下MVCC可以解决所有并发问题,于是在事务里用SELECT FOR UPDATE去判断是否重复下单,结果两个事务同时读到“没有记录”,然后同时插入,最后靠唯一索引兜底才没出事。MVCC和锁机制是两套互补的东西,读用MVCC,写靠锁,各有分工。

4.4 什么情况下MVCC也救不了幻读

按SQL标准,RR隔离级别应该彻底避免幻读,但InnoDB的RR也只是部分避免。它靠两套机制:快照读靠MVCC的固定ReadView来避免;当前读靠next-key lock(记录锁+间隙锁)来锁住查询范围,阻止其他事务插入新记录。

如果业务把查询从快照读改成当前读,又没走带间隙锁的索引范围,就可能出现幻觉式的数据。比如事务A对一批数据执行SELECT ... FOR UPDATE,事务B往同一范围插入新数据,如果事务A的当前读走的是普通二级索引且锁覆盖不全,B就能插入成功,A再次当前读时就会发现多了一行。

所以,别把“防幻读”无条件归功于MVCC。MVCC能保证的是快照读层面的一致性视图,真正要把幻读堵死,还得靠锁机制配合。

5. 实操验证:用三个终端看MVCC的真实行为

5.1 建一张测试表,准备数据

理论讲再多,不如实测一遍。我建议你跟着做,三分钟就能把MVCC行为看得明明白白。先在一台没有什么业务的MySQL上,创建测试库和表:

CREATE DATABASE test_mvcc; USE test_mvcc; CREATE TABLE t_mvcc ( id INT PRIMARY KEY, name VARCHAR(50), age INT ) ENGINE=InnoDB; INSERT INTO t_mvcc(id, name, age) VALUES(1, '张三', 20);

同时打开三个终端窗口,统称为终端A、终端B、终端C。把事务隔离级别先在会话里调整为RR:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

5.2 时间线:RR下重复读行为

按下面这张时间线操作,每一步都等上一步完成。

时间终端A终端B
T1BEGIN;
T2UPDATE t_mvcc SET age=21 WHERE id=1;
T3BEGIN; SELECT * FROM t_mvcc WHERE id=1;
T4COMMIT;
T5SELECT * FROM t_mvcc WHERE id=1;

执行完,你应该看到:T3时刻终端B读到的是age=20;T4之后事务A提交了,T5时刻终端B再查,依然读到age=20。

这就是RR隔离级别下MVCC的经典表现。终端B的第一次查询生成ReadView,之后所有快照读都复用这个视图,事务A的提交对B不可见。

5.3 时间线:RC下不可重复读行为

把隔离级别改成RC,重新执行一遍:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

然后按同样的时间线再跑一次。结果会变成:T3读到age=20,T5再查就读到age=21。因为在RC下,终端B的每次SELECT都会生成新ReadView,事务A已经提交的新版本自然进入了可见范围。

对比这两组实验,RC和RR在MVCC上唯一的差别就是ReadView的生成时机:RC每次SELECT都生成,RR只在第一次SELECT生成。理解了这个,不可重复读、可重复读就不再是抽象概念了。

5.4 查看当前事务ID和活跃事务列表

想做更深的观察,可以用information_schema里的INNODB_TRX表查看当前InnoDB运行中的事务信息:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.INNODB_TRX;

事务开启但还没分配真正的事务ID时,trx_id可能显示为0,这在MySQL 5.7及以上版本是正常的,很多只读事务会延迟分配事务ID,直到第一次写操作才拿到真正的ID。想看到事务ID生效,可以先执行一次UPDATE。

也可以配合performance_schema里的锁信息来观察当前读加锁情况,不过直观看MVCC行为,上面两组实验已经足够。常见的坑是:在BEGIN之后先执行一次SELECT,再在另一个连接里修改数据,然后在同一个事务里再SELECT,发现数据没变,这就是RR下的预期行为,不要慌。

6. 常见问题与排查技巧实录

6.1 长事务导致undo膨胀

运维同学经常会遇到一个问题:ibdata1或者undo表空间只涨不降,数据明明没怎么增长,磁盘却报警了。

处理这类问题,我第一步就是查information_schema.INNODB_TRX里有没有长时间未提交的事务:

SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_mysql_thread_id FROM information_schema.INNODB_TRX ORDER BY running_seconds DESC;

原因很直接:只要有一个活跃事务,它可能访问到的历史版本就不能被purge线程清理。更新产生的旧版本会一直在undo里存着,甚至导致表空间膨胀。尤其是事务里循环执行了大量UPDATE但一直不提交,undo会非常可观。

处理思路也很明确:先看是不是有被遗忘的测试会话,再考虑杀掉相关连接。如果是业务模块的事务偏长,就要从代码层面拆事务,别在一个大事务里处理上万行更新。

6.2 RR隔离级别下还是出现幻读

“明明配了RR,为什么还会查出发射出来的数据?”这类问题我在社区见到过很多。

首先确认查询是不是快照读。如果是普通SELECT,RR的MVCC基本能保证一致性视图,不会因为其他事务提交了插入数据就看到新行。但是,如果你在RR下用了当前读(SELECT FOR UPDATE、UPDATE、DELETE),那么是否出现幻读取决于锁能不能覆盖完整范围。索引缺失、条件无法用索引锁定时,gap lock覆盖不住,别的会话就能插入新数据。

解决办法要么保证当前读的查询条件能落到合适的索引上,让next-key lock锁住对应范围;要么干脆在业务上借助唯一索引、状态机关键字段去做兜底。不要执着于单纯靠隔离级别解决业务并发,很多问题需要表设计和锁机制一起配合。

6.3 死锁和MVCC到底有没有关系

死锁发生时,经常有人甩锅给“MVCC机制太复杂”。但死锁的根源从来都是当前读之间的锁竞争和加锁顺序不一致,和MVCC没有直接关系。

排查死锁,直接执行:

SHOW ENGINE INNODB STATUS;

重点看输出里的LATEST DETECTED DEADLOCK段。里面会列出两个事务的等待资源、加锁类型、SQL语句。实务里最常见的原因是:两个事务更新相同的多行数据,但顺序相反。比如事务A先更新id=1再更新id=2,事务B先更新id=2再更新id=1,互相等对方释放锁,就形成了死锁。

应对办法也简单:批量更新保持固定的排序顺序;或者把大事务化小,缩短锁持有时间。MVCC的存在只是让普通读不用蹭锁的流量,写写冲突依然要靠锁和事务设计来化解。

6.4 排查问题速查表

现象可能原因检查命令 / 处理建议
同一个事务两次SELECT结果不同隔离级别是RCSET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
RR下还是读到新数据查询属于当前读,不归MVCC管检查SQL是否FOR UPDATE/UPDATE/DELETE
undo表空间持续增大存在长事务查INNODB_TRX,杀掉超时事务
死锁报错频繁加锁顺序不一致SHOW ENGINE INNODB STATUS定位事务
BEGIN后第一条查询读不到旧快照快照是在第一条快照读时生成想固定快照,BEGIN后尽早执行SELECT

最后说一点我自己的实战习惯。排查任何和事务隔离相关的问题,我都会先确认三件事:当前会话的隔离级别是什么、出问题的SQL是快照读还是当前读、有没有未提交的长事务。这三件事一确认,大部分MVCC疑团都能瞬间解开。建议你也把上面那组三终端实验亲自跑一遍,亲眼看到版本链上的新旧版本切换之后,再回头读文档里的理论,你会发现一切都对上了。

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

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

立即咨询