news 2026/10/20 5:40:30

深入拆解 MySQL ReadView:MVCC 可见性判断原理与 RR/RC 快照机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入拆解 MySQL ReadView:MVCC 可见性判断原理与 RR/RC 快照机制

面试 MySQL 八股时,ReadView 这三个字母几乎是 MVCC 部分绕不开的门槛。很多人能背出“RR 快照只生成一次、RC 每次语句重新生成”,但再追问一句:ReadView 里面到底存了哪几个字段?它凭什么判断一个版本对你可见?回答基本就卡壳了。这篇文章就把 ReadView 的结构和工作原理彻底拆开,从隐藏列、版本链讲起,到源码里的可见性判断逻辑,最后给一个最小可复现的案例。不管你是刚开始啃 InnoDB 的开发者,还是准备面试想把这个点吃透的人,都可以把它当作一份能对照操作的参考资料。

1. 先从版本链说起:ReadView 要解决什么问题

1.1 一条记录为什么会有“多个版本”

要理解 ReadView,先得有版本链的概念。InnoDB 的表在物理上是一棵 B+ 树的叶子节点,每一行除了你定义的业务字段,还藏着几个隐藏列:DB_TRX_ID 占 6 字节,记录“最近一次修改这一行的事务 ID”;DB_ROLL_PTR 占 7 字节,指向“修改前的老版本在 undo log 里的位置”;还有 DB_ROW_ID(仅在你没定义主键时才用来充当行标识)。这几个字段平时几乎没人注意,但 MVCC 全靠它们撑起来。

当你执行一条 UPDATE 时,InnoDB 并不是简单地把新值覆盖上去。它先把当前这一行(也就是修改前的值)连同相关事务信息写进 undo log,再把这行的 DB_TRX_ID 改成你自己的事务 ID,DB_ROLL_PTR 指向刚写的那条 undo 记录,最后才把业务字段改成新值。这样一来,“当前值”和“历史值”之间就被 DB_ROLL_PTR 串成了一条链,顺着它你能一路回溯到最初 INSERT 时的那个版本。我们常说的“这条记录的多个版本”,指的就是这条链上的不同状态。

这个设计的目的非常直白:如果事务 A 改了某一行但还没提交,事务 B 想读这一行,B 绝对不能读到 A 的中间状态,也不能被 A 阻塞。没有版本链的话,B 只能等 A 提交或者加锁等待。有了版本链,B 就能顺着链找到“A 修改之前、且在自己可见范围内”的旧版本返回。也就是说,一条行记录在物理上只有一个最新值,但在逻辑上同时提供了多个“快照选项”。

1.2 ReadView 是 MVCC 的“判官”

版本链给你提供了多个备选版本,具体要选哪个,由 ReadView 来判决。ReadView 本身不存数据,它是事务在执行一致性读(也就是普通 SELECT)时,在内存里构建的一份快照元信息。它记录的是“我创建这个快照的那一刻,数据库里哪些事务已经提交了、哪些还在活跃”。之后 SELECT 顺着版本链往回找的时候,每遇到一个版本就要拿这个版本的 DB_TRX_ID 去问一遍 ReadView:这个事务的修改,我看得见吗?看得见就返回这个版本,看不见就继续往链深处找。整个过程中没有加任何行锁、表锁,所以一致性读对读写并发特别友好,这也正是 MVCC 把“读写互不阻塞”做到极致的基础。

我见过不少人把 ReadView 理解成“把整张表拷贝了一份数据”,这是错误的。它只是节省到极致的元信息,大多数场景下只有几十个字节(m_ids 列表里存几个事务 ID),却能决定成千上万行记录的可见性。搞懂这一点,你就不会再纠结“快照到底占多少内存”这种问题了。

2. ReadView 的结构:核心字段逐个拆解

2.1 五个字段对照表

ReadView 在 InnoDB 源码里是一个 C++ 类,MySQL 8.0 中定义在 read0types.h / read0read.h 里,核心成员是下面这五个:

字段创建时如何赋值通俗理解
m_low_limit_id取事务系统里当前最大的事务 ID(max_trx_id)高水位/截止线:ID 大于等于它的,都是快照之后才开始做事的事务
m_up_limit_id取当前活跃事务里最小的那个 ID低水位/出生线:ID 小于它的,都是快照之前就提交完的
m_creator_trx_id创建这个 ReadView 的事务自己的 ID我自己,自己的修改必须可见
m_ids当前全部活跃事务 ID 的集合快照那一刻还没结束的事务名单
m_low_limit_no事务系统记录的最大提交序号给 purge 判断哪些旧版本可以彻底删除用的下界

必须强调一句:这里的“活跃事务”,是那些已经分配了事务 ID、但还没有提交或回滚的事务。只做了查询但没写过数据的事务,在较新的 MySQL 版本里根本不会被分配事务 ID,也不会出现在任何人的 m_ids 中,另一个事务创建快照时完全感知不到它。这一点很多人会忽略,排查问题时容易被绕进去。

2.2 命名陷阱:低水位和高水位是反直觉的

第一次看源码的人,几乎都会被 m_low_limit_id 和 m_up_limit_id 这两个名字带偏。m_low_limit_id 名字里有 low,实际它存的是高水位;m_up_limit_id 名字里有 up,实际它存的是低水位。源码注释写得很清楚:m_low_limit_id 的语义是“trx_id 大于等于这个值的版本一律不可见”,所以它是一道截止线;m_up_limit_id 的语义是“trx_id 小于这个值的版本一律可见”,所以它是一道出生线。

把这两个值当成“水位”而不是“上限/下限”就顺了。想象一条河里有两个水位标:低水位是最小的活跃事务 ID,低于它的水都“沉淀”成了已提交的历史,可以放心读;高水位是系统下一个要分配的事务 ID,高于它的水还没来得及进入这个视角,自然看不见。中间这段区间就是“活跃事务 ID 散落在高低水位之间”的灰色地带,单靠水位区分不了,必须靠 m_ids 名单来精确点名。

2.3 创建 ReadView 时,内存里发生了什么

InnoDB 在事务第一次执行一致性读时,会通过 MVCC 管理器创建一个 ReadView。这一步大致是这样做的:先在事务系统这个全局结构上加锁,防止并发创建时状态被破坏;接着把 trx_sys 里 max_trx_id 的当前值取出来填到 m_low_limit_id;然后遍历所有活跃事务,把最小的事务 ID 填到 m_up_limit_id,同时把完整的活跃事务 ID 列表拷贝进 m_ids;最后把自己的事务 ID 填进 m_creator_trx_id。做完这些,释放锁。

关键点在于,整个过程是在一瞬间“一口气”完成的。并发下一堆事务反复提交、启动,但创建 ReadView 的那一刻,数据库状态被定格了:所有“比我早提交的”和“还没开始干的”,都被明确登记进了字段里。之后哪怕别的事务提交了再多数据,ReadView 也不会回头更新自己,这正是“一致性”的来源。

3. 可见性判断:一条数据到底能不能被看见

3.1 源码里的判断逻辑

ReadView 判断一个版本是否可见的流程,可以浓缩成下面这段伪代码,顺序基本对应源码里 changes_visible 函数的实际检查路径:

if (trx_id < m_up_limit_id || trx_id == m_creator_trx_id) { return 可见; // 早于低水位提交的,或者自己改的 } if (trx_id >= m_low_limit_id) { return 不可见; // 快照之后才分配的 ID,一定不可见 } if (m_ids 为空) { return 可见; // 没有活跃事务,中间无灰色地带 } if (trx_id 在 m_ids 中) { return 不可见; // 还活跃着,它的修改不能看 } return 可见; // 介于高低水位之间,但已经提交了

这里每一步都有讲究。第一步先挡掉“铁定可见”的,是最高频路径,很多版本一眼就能判定,不用往下走。第二步把“铁定不可见”的挡掉,因为高水位是创建快照那一刻的最大事务 ID,之后分配 ID 的事务必然是在快照之后才启动的。第三步和第四步才是真正的精细化点名:ID 落在灰色地带的事务,如果当时还没提交,就在 m_ids 名单里;如果已经提交,就不在名单里。名单里有没有它,就是“活跃”与“已提交”的唯一判据。

3.2 用一个 UPDATE 案例把规则跑一遍

假设初始数据是id=1, balance=100,它由事务 T1(trx_id=1)插入并提交。现在有两个事务并发:

  • 事务 A:trx_id=10,执行UPDATE account SET balance=200 WHERE id=1,未提交。
  • 事务 B:此时创建自己的 ReadView。

在 B 创建 ReadView 那一刻,活跃事务只有 A,所以 B 的 m_up_limit_id=10,m_low_limit_id=11(此时系统最大事务 ID 为 11),m_ids=[10],m_creator_trx_id 填 B 自己的事务 ID(B 是只读事务时可为 0)。

B 执行SELECT balance FROM account WHERE id=1时,它面对的版本链是这样:

链上的版本DB_TRX_IDbalance判定过程结果
最新版10200不小于低水位 10,不等于自己,小于高水位 11,且在 m_ids=[10] 中不可见
老版本11001 < 10,早于低水位可见,返回 100

这个例子把规则串起来之后,你会发现所谓“一致性读”,其实就是从链头开始,反复执行同一个小函数,直到找到第一个“可见”的版本。版本越多,这次读付出的代价就越大,这也是为什么版本链拉长之后,性能会肉眼可见地下降。

3.3 为什么“自己事务改的”必须可见

m_creator_trx_id 这一项解决的是“读写自己”的问题。你在事务里先 UPDATE 了一行,同一个事务里再 SELECT 这一行,这时会顺着版本链碰到自己创建的新版本,它的 DB_TRX_ID 就是你的事务 ID。如果按常规判断,你自己还在活跃列表里,这个版本多半会被判成不可见,那就尴尬了:自己改完的数据自己读不出来,业务逻辑全崩。所以源码里把“等于 creator”单独拎出来直接放行。

这个设计也顺带解释了为什么 InnoDB 里“读不阻塞写、写不阻塞读”:你读到的历史版本已经在 undo log 里躺好了,不需要等写事务提交;“自己写自己读”又不依赖提交,靠 creator 特判就解决了。两条路径都不需要锁,这是 MVCC 能跑得这么轻快的重要原因。

4. RR 与 RC:同一个快照,两种生命周期

4.1 REPEATABLE READ:事务内只建一次快照

可重复读隔离级别下,ReadView 的生命周期是“一次事务,一个快照”。准确说,是在事务第一次执行一致性读时创建,后续的所有一致性读都复用这一个。因为快照不变,所以你在同一个事务里反复 SELECT,看到的结果始终一样,这就是“可重复读”名字的由来。

有个细节特别容易错:BEGIN或START TRANSACTION本身并不会创建 ReadView。事务即使开启了,只要一条 SELECT 都没跑过,快照就不存在。如果事务开启之后、第一次 SELECT 之前,另一个事务提交了若干修改,那么你的第一次 SELECT 会把那些修改包含进来。想要“一开启就锁定快照”,得用START TRANSACTION WITH CONSISTENT SNAPSHOT,这个语法会立即为事务创建一致性快照。

4.2 READ COMMITTED:每条语句建一次快照

读已提交隔离级别下,每条一致性读语句开始前都会新建一个 ReadView,旧的在语句结束后就可以丢弃。新快照会纳入所有已经提交的事务,所以同一个事务里,前一条 SELECT 和后一条 SELECT 的结果可以不一样,因为它俩用的不是同一张快照。

这带来的行为差异值得记一笔:RC 下你能看到“别人刚提交的修改”,但看不到“别人还没提交的修改”,因为新快照里的 m_ids 依然会挡住活跃事务;RR 下你连“别人已经提交的修改”都可能看不到,前提是你的快照创建时间早于对方提交。用一句话总结:隔离级别的差异,本质上就是 ReadView 创建时机的差异。

4.3 两张快照的对照表

维度REPEATABLE READREAD COMMITTED
快照创建时机事务内第一次一致性读每条一致性读语句开始
同一个事务多次 SELECT结果一致结果可以不同
能否看到他人已提交修改若对方提交晚于快照创建,则不能能
能否看到他人未提交修改不能不能
BEGIN 是否立即建快照不,除非用 WITH CONSISTENT SNAPSHOT不

对照这张表,再看那句“RR 可重复读、RC 读已提交”,本质就是在回答一个问题:你什么时候创建快照。理解了这个,面试时被问到“RC 下为什么两次 SELECT 结果不一样”,就不会只会背结论了。

5. 实操验证:用最小案例把 ReadView 的动线看清楚

5.1 准备环境与初始数据

建议用 MySQL 5.7 或 8.0 的任意版本,先建一张最简表:

CREATE DATABASE IF NOT EXISTS mvcc_demo DEFAULT CHARSET=utf8mb4; USE mvcc_demo; CREATE TABLE account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINE=InnoDB; INSERT INTO account VALUES (1, 100);

这里把 balance 设成 INT 而不是 DECIMAL,纯粹为了演示方便。接下来需要开三个会话窗口,分别扮演事务 A、事务 B 和观察者。

5.2 三步复现可重复读

第一步,在会话 A 执行:

-- 会话 A START TRANSACTION; UPDATE account SET balance = 200 WHERE id = 1;

此时不要提交。第二步,在会话 B 执行:

-- 会话 B START TRANSACTION; SELECT balance FROM account WHERE id = 1; -- 返回 100

B 读到 100,因为 A 还在活跃列表里,200 这个版本对 B 不可见,它顺着版本链找到了老版本 100。第三步,回会话 A 执行COMMIT;,再回会话 B 执行:

-- 会话 B(事务还没结束) SELECT balance FROM account WHERE id = 1; -- RR 下仍返回 100

如果会话 B 在 READ COMMITTED 隔离级别下跑同样的流程,第二次 SELECT 就会返回 200,因为第二次 SELECT 重建了快照,A 已提交,200 对新快照可见。

提示:要复现 RC 行为,记得先执行SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;,再开启事务。步骤完全相同,两次结果会形成鲜明对比。

5.3 从系统表里观察事务的踪迹

ReadView 本身不提供直接的查询接口,但我们可以从旁边验证它的存在和影响。执行:

SELECT trx_id, trx_state, trx_started, trx_rows_modified FROM information_schema.innodb_trx;

能看到 A 的事务 ID、状态、启动时间和修改行数。在 A 未提交时,它的 trx_id 一定会出现在其他事务创建快照时的活跃列表里。再执行SHOW ENGINE INNODB STATUS\G,里面有一项History list length,这个数字反映 undo log 里保留的历史版本数量。只要事务 B 一直开着 RR 快照不提交,A 又反复修改同一行,你会看到 history list length 持续上涨,这就是 ReadView 阻止 purge 线程回收旧版本的直接证据。想更严谨,还可以打开SET GLOBAL innodb_status_output=ON;配合定期采样。对绝大多数排查场景,innodb_trx 加 history list length 两个观测点已经足够定位“哪个事务开着快照不放”了。

6. 常见问题与避坑清单

6.1 长事务 + 旧快照:数据库最怕的慢性病

线上性能劣化,很大比例都跟“长事务开着 RR 快照不放”有关。一个事务从早上 SELECT 了一下就一直挂着,哪怕它什么都没做,只要它的 ReadView 还存在,系统就不能放心清理早于这个快照的旧版本。别的业务持续 UPDATE 同一条记录,每次 UPDATE 都往 undo log 里追加历史版本,于是版本链越拉越长,history list 越涨越高,最终表现为:undo 表空间膨胀、磁盘被吃掉、查询变慢甚至报空间不足。

排查套路很简单:先看information_schema.innodb_trx里有没有 trx_state 为 RUNNING 但 trx_started 早已过去很久的事务;再看SHOW ENGINE INNODB STATUS里的 history list length 是否异常大。两相对照基本就能锁定元凶。处理手段就是断开或 kill 那个事务,让它的 ReadView 释放,purge 线程才能把垃圾版本回收掉。

6.2 快照读和当前读混用:事务里“自己打自己”

还有一个高频迷惑现场。RR 隔离级别下,你在事务里先做了普通 SELECT,ReadView 建立;随后另一个事务提交了新数据;你再在这个事务里执行 SELECT ... FOR UPDATE 或 UPDATE,结果居然看到了新数据,跟之前 SELECT 的结果对不上。很多人以为 RR 失效了,其实没有。SELECT ... FOR UPDATE、UPDATE、DELETE 走的是“当前读”,它们读的是行的最新已提交版本,不经过 ReadView 裁判,而是加锁读取。所以 RR 只保证普通一致性读的可重复读,不保证当前读和你之前快照的结果一致。

注意:可重复读只管普通 SELECT(一致性读),不管 SELECT ... FOR UPDATE / UPDATE / DELETE(当前读)。如果业务里混用了两种读,先想清楚自己到底要哪种语义,再选隔离级别。

6.3 只读事务同样会持有快照

另一个常被忽略的点是:只读事务也可能长期持有 ReadView。只要它发起过一次 SELECT,它就有了自己的快照,即使它没有任何写操作,purge 也要照顾它的快照。所以排查时别只盯着写事务,只读的长连接一样是隐患。我见过一个案例,某报表应用用连接池跑批量查询,一个连接执行完查询不释放事务,导致 undo 一直涨,最后把磁盘填满。这个坑在连接池场景里特别常见,因为连接复用后事务边界往往被忽略。

6.4 排查问题速查表

症状优先检查项常见处理
history list 持续上涨innodb_trx 找长事务、SHOW ENGINE 看事务年龄kill 长事务 / 释放连接
undo 表空间膨胀undo 表空间大小、history list清理长事务后观察回落
RR 事务内看到新数据是否混用当前读、是否 BEGIN 后隔很久才首次 SELECT用 WITH CONSISTENT SNAPSHOT 或避免混用
RC 下两次 SELECT 不一致这是 RC 的正常语义无,换 RR 再谈
连接池连接挂半天代码里是否手动开启了事务确保提交/回滚再归还连接

7. 几个实操中的体会

7.1 排障时我养成的两个习惯

ReadView 这套机制,理解结构是第一步,真正值钱的是把它的生命周期和业务代码对应起来。我自己的习惯是:写代码时给所有手动事务固定一个模板——开启事务、尽早完成首个读、尽快提交或回滚。这个模板看起来最简单,却能挡掉绝大多数“事务挂半天不提交”的事故。另一个习惯是定期扫长事务,我在项目里加了一个定时任务,每隔五分钟查一次 information_schema.innodb_trx,凡是 trx_started 超过阈值的连接立刻告警,再配合 history list length 的走势判断是不是快照拖住了 purge。

7.2 写代码和读源码时的小建议

如果你想再深入一层,建议直接打开 MySQL 源码里 read0types.h 和 read0read.h 两个文件,对照着看 ReadView 类成员和 changes_visible 函数。不同版本实现细节会有差异,但核心字段和判断顺序很稳定,按这篇文章的结构去对照任何版本都能快速对上号。面试被问到“RR 为什么不能完全避免幻读”,你也可以从 ReadView 的角度答:快照读靠 ReadView 能避免幻读,当前读走加锁机制,InnoDB 用 next-key lock 兜底。把这条动线讲清楚,比死记“RR 靠间隙锁防幻读”这一句有说服力得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 5:40:30

基于Copula的风光联合出力场景生成:从相关性建模到不确定性量化

1. 为什么风光联合建模绕不开Copula如果你做过新能源功率预测或者电力系统规划&#xff0c;大概率会遇到一个很头疼的问题&#xff1a;光伏和风电出力明明高度相关&#xff0c;但把两者分开建模再简单叠加&#xff0c;算出来的总出力概率分布总是跟实测对不上。我早期做某跨平台…

作者头像 李华
网站建设 2026/10/20 5:39:04

Keras大文件训练内存溢出解决方案:三层数据流架构

1. 为什么“大文件无法一次性读入内存”是Keras用户绕不开的坎&#xff1f;Keras作为高层深度学习API&#xff0c;最大的优势是简洁、直观、上手快——写个model.fit(X_train, y_train)就能跑通一个模型。但这个看似简单的接口背后&#xff0c;藏着一个被新手反复踩坑、被老手默…

作者头像 李华
网站建设 2026/10/20 5:37:58

传输层:从端口复用到可靠传输的工程实践指南

1. 为什么“传输层”是网络工程师真正开始“看懂流量”的分水岭很多人学完《计算机网络》前四章——物理层、数据链路层、网络层&#xff0c;脑子里还是一团浆糊&#xff1a;IP地址怎么寻址的&#xff1f;MAC地址到底在哪儿用&#xff1f;ARP请求为什么发不出去&#xff1f;这些…

作者头像 李华
网站建设 2026/10/20 5:37:44

为什么Vibe Coding个人用得很顺手,公司用不起来?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 5:36:47

永磁同步电机RLS在线参数辨识实战指南

简介&#xff1a;本资源聚焦永磁同步电机&#xff08;PMSM&#xff09;在线参数辨识核心问题&#xff0c;面向电气工程、自动控制及电机驱动方向的研究生、工程师与科研人员&#xff0c;提供基于递推最小二乘法&#xff08;RLS&#xff09;的完整建模与辨识实践方案。资源包共3…

作者头像 李华
网站建设 2026/10/20 5:36:32

GPU内核驱动开发:HAL硬件抽象层的设计与实现要点

写GPU内核态驱动&#xff08;KMD&#xff09;的&#xff0c;第一课就得跟硬件抽象层&#xff08;HAL&#xff09;打交道。很多新手一上来就急着翻寄存器手册、直接操作MMIO映射&#xff0c;结果代码写了两三个月&#xff0c;换个芯片版本就崩了&#xff0c;然后回头补HAL这个坑…

作者头像 李华