【MySQL百日打怪升级第21天】InnoDB MVCC 机制详解 —— 快照读、当前读、Undo 链

简介: 本篇详解InnoDB MVCC机制:通过Read View(快照视图)与Undo版本链实现无锁并发控制,厘清快照读(普通SELECT)与当前读(UPDATE/FOR UPDATE)的本质区别,揭示RR/RC隔离级差异根源,并剖析长事务对性能的致命影响。

【第21天】每天一个MySQL知识点,百日打怪升级

InnoDB MVCC 机制详解 —— 快照读、当前读、Undo 链


大家好,我是一名拥有10年以上经验的DBA老兵(没有那多)。

做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。

让我们开始今天的第21天内容。


昨天讲了行锁 vs 表锁,有兄弟问了一个特别好的问题:

"RR级别下,SELECT不是不加锁吗?那怎么做到的可重复读?"

不加锁,还能保证每次读到同样的结果——听起来像魔法。但这不是魔法,是 MVCC(Multi-Version Concurrency Control,多版本并发控制)

说白了:InnoDB 不直接覆盖旧数据,而是在旧数据旁边写一个新版本,读的人看旧版本,写的人更新版本。

跟游戏存档似的——你读的是存盘的那个版本,别人在玩最新的进度,互不干扰。(不过游戏存档是静态拷贝,Read View 是动态判定规则,不存具体数据,只存判据。)


MVCC 到底解决了什么问题?

先想一个场景:事务 A 和事务 B 同时开启。

事务 A: SELECT * FROM user WHERE id = 1;  -- 读到 name = 'Alice'
事务 B: UPDATE user SET name = 'Bob' WHERE id = 1;
事务 B: COMMIT;
事务 A: SELECT * FROM user WHERE id = 1;  -- 应该读到什么?

没有 MVCC 的话,事务 A 会读到 'Bob'——违反了 RR 隔离级别的承诺("同一个事务里,两次读结果必须一致")。

InnoDB 的方案是:事务 A 第一次读的时候,给它拍一张"快照"。后续所有读,都从这张快照里取,不管数据被改了多少次。

这就引出了两个核心概念。


两个读:快照读 vs 当前读

MVCC 把 SELECT 分成了两种:

读类型 对应 SQL 读哪个版本
快照读(Snapshot Read) 普通 SELECT 读事务快照建立时的版本(RR 下:普通 BEGIN 在第一次快照读时建立;WITH CONSISTENT SNAPSHOT 在 BEGIN 时建立)
当前读(Current Read) SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETE 读行的最新版本(未提交的会阻塞等待),并加锁

快照读:不加锁,只管读。RR 下读到事务快照建立时的数据版本,RC 下读到当前语句执行那一刻的快照。

当前读:加锁,读最新版。每次必须拿到最新的数据,如果该行正被其他事务修改(未提交),会阻塞等待锁释放,目的是让修改操作看到真实的世界。

-- 快照读:不加锁,读快照
SELECT * FROM user WHERE id = 1;

-- 当前读:加锁,读最新
SELECT * FROM user WHERE id = 1 FOR UPDATE;
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
UPDATE user SET name = 'Bob' WHERE id = 1;  -- 也是当前读
DELETE FROM user WHERE id = 1;              -- 也是当前读

面试必问

  • 快照读和当前读有什么区别?
  • RR 级别下,UPDATE 读到的数据会不会比 SELECT 更新?
  • MVCC 如何实现 RC 和 RR 的差异?

📝 面试解答

Q: 快照读和当前读有什么区别?

快照读是普通 SELECT,不加锁,从 Read View 中读取行的历史版本。当前读是 SELECT ... FOR UPDATE / LOCK IN SHARE MODE 以及 UPDATE/DELETE,读取行的最新版本(未提交的事务会阻塞等待)并加锁。RR 下当前读能看到比快照读更新的数据。

Q: RR 级别下,UPDATE 语句读取到的数据版本,会不会比普通 SELECT 读取到的更'新'?

会的。这就是经典的"更新丢失"场景的防护方式——UPDATE 本身就是当前读,它总是读取最新已提交版本并加锁。所以如果你先 SELECT(快照读)看到旧值,然后执行 UPDATE,InnoDB 会用当前读拿到别人改过的最新值再做修改。如果你用了 SELECT ... FOR UPDATE,那等锁释放后读到最新值再 UPDATE,效果一样。

Q: MVCC 如何实现 RC 和 RR 的差异?

核心在 Read View 的创建时机。RR 下:普通 BEGIN 后在第一次快照读时创建 Read View,整个事务期间复用;使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 则在 BEGIN 时就创建。RC 下:每条语句执行时都重新创建 Read View。所以 RR 能做到可重复读,RC 只能做到语句级别的读已提交。


Read View:MVCC 的"时光相机"

Read View 是 MVCC 最核心的数据结构。你可以把它理解成事务第一次快照读那一刻的"全局数据快照登记簿"

它记录了四个核心属性:

属性 含义 白话版
m_ids 创建 Read View 时,所有未提交的事务ID列表 这些事务还没结束,它们改的数据我看不见
min_trx_id m_ids 中的最小值 最小的"未提交"编号
max_trx_id 创建 Read View 时,系统下一个即将分配的事务ID(即当前已分配最大事务ID + 1) 大于等于这个编号的事务我全不认(还没开始)
creator_trx_id 创建这个 Read View 的事务自己的ID 我自己的修改我当然能看见

有了 Read View,InnoDB 判断一条记录的可见性就变成了"查户口":

-- 对于一条版本链上的数据,判断当前版本对当前事务是否可见:
-- 1. 如果版本的事务ID == creator_trx_id → 自己改的,可见
-- 2. 如果版本的事务ID < min_trx_id     → 已提交的旧事务,可见
-- 3. 如果版本的事务ID >= max_trx_id    → 未来事务,不可见
-- 4. 如果版本的事务ID 在 m_ids 中       → 未提交的事务,不可见
-- 不可见就沿着 Undo 链往上找,直到找到可见的版本

这个算法是 MVCC 的灵魂。它不锁任何行,就能实现事务隔离。 一句话总结就是:Read View 把所有事务分成三类——自己(可见)、已提交的老事务(可见)、未提交或未来的事务(不可见)。相比锁机制,MVCC 让读和写完全不冲突——你写你的,我读我的快照。


Undo Log 版本链:数据的时间旅行

InnoDB 的行结构里,有三个隐藏字段:

字段 含义
DB_TRX_ID 最近修改这行的事务ID
DB_ROLL_PTR 回滚指针,指向 Undo Log 中的旧版本记录
DB_ROW_ID 行ID,没有主键时 InnoDB 用它组织聚簇索引(不参与 MVCC 可见性判定)

每次 UPDATE 一个行,InnoDB 不是直接覆盖数据,而是:

  1. 把当前行的旧值写入 Undo Log
  2. 更新行数据,把 DB_ROLL_PTR 指向 Undo Log 中的旧版本
  3. 更新 DB_TRX_ID 为当前事务ID

这样就形成了一条版本链

当前行 (trx_id=20, name='Bob')
    ↑ DB_ROLL_PTR
旧版本1 (trx_id=15, name='Alice')     ← 在 Undo Log 里
    ↑ DB_ROLL_PTR
旧版本2 (trx_id=10, name='Charlie')   ← 在 Undo Log 里

Read View 沿着这条链从最新版本往下找,找到一个"对你可见"的版本就返回。版本链越长,查找越慢。

所以长事务是 MVCC 的天敌——事务一直不提交,它的 Read View 就一直在,Purge 线程不能清理这个 Read View 可能需要的任何旧版本(哪怕产生这些旧版本的事务早就提交了)。结果版本链越积越长,最终拖垮性能。

我在生产上见过最夸张的:一张表每行约 2KB,业务高峰期每秒约 50 次 UPDATE,一个事务跑了 8 小时没提交,这期间所有旧版本都被长事务的 Read View"保释"着不能 Purge。Undo 表空间飙到 200GB。长事务提交后,Purge 线程清理了 40 分钟才释放干净。


隔离级别的可视化对比

用一个具体的例子看 MVCC 在两个级别下的表现差异:

表数据:id=1, name='Alice', trx_id=10

时间线:
T1: 事务A (trx_id=20) BEGIN;
     -- 执行第一条 SELECT(RR 下 Read View 在此创建)
T2: 事务B (trx_id=21) BEGIN;
T3: 事务B UPDATE user SET name='Bob' WHERE id=1;  -- 提交版本 trx_id=21
T4: 事务B COMMIT;
T5: 事务A 再次 SELECT name FROM user WHERE id=1;
     -- RR: 读到 'Alice'(Read View 在 T1 创建时 B 还没开始,看不见 T2 之后的事务)
     -- RC: 读到 'Bob'(Read View 在 T5 重新创建,能看见已提交的 T4)

不过注意:RC 的 Read View 虽然每条语句重新创建,但它仍然只认已提交的数据——m_ids 中记录的是"此时未提交的事务",决不会读到脏数据。RC 和 RR 的区别是"要不要把同一版本保持到事务结束",而不是"能不能读未提交的数据"。

是不是发现了一件事?

RC 下没有"可重复读"的烦恼,但也意味着你的事务不安全。 同一个事务里,前后两次 SELECT 结果可能不同。

我踩过这个坑:一个报表统计任务跑 5 分钟,第一条 SELECT 和最后一条 SELECT 看到的"同一时间点"的数据不同——因为 RC 下每条语句都重新拍照。


最常见的 MVCC 认知误区

误区一:MVCC 对所有 SELECT 都有效

不对。SELECT ... FOR UPDATE 不走 MVCC,走当前读。 所以你在 RR 下用 FOR UPDATE 锁住的记录,不会被 MVCC 保护——你读到的是最新已提交版本。

这在幂等性校验的场景特别坑:

-- 事务A:
BEGIN;
SELECT * FROM order WHERE order_no = 'ORD001' FOR UPDATE;
-- 读到的是最新数据
-- 如果事务B已经插入了一条 ORD001,你会读到它(阻塞等B提交)

很多人以为 FOR UPDATE 和普通 SELECT 效果一样,只是加了锁——其实读的数据版本都不一样。

误区二:UPDATE 读取行时跟 SELECT 一样走快照读

很多人以为 UPDATE 和 SELECT 的读取机制一样,只是多了加锁。实际上 UPDATE 本身就是当前读,读取最新已提交版本并加锁。

不过有个细节——半一致性读(semi-consistent read)

当 UPDATE 的 WHERE 条件没有可用索引、需要全表扫描时,InnoDB 不会对所有扫描到的行都加 X 锁。它会先用当前读(最新已提交版本)判断 WHERE 条件是否满足——不满足的行直接跳过,不加锁;满足条件的行再加 X 锁并继续处理。

注意:半一致性读是 RR 级别下的一种优化,目的是避免无索引 UPDATE 时对全表所有行加锁。如果 WHERE 条件能走索引(聚簇索引或二级索引),InnoDB 直接通过索引定位行并加锁,不需要这个优化。

另外,在 RC 隔离级别下,UPDATE 的读取行为天然就是"半一致性"的——它总是读取最新已提交版本,这与 RR 级别下的选择性优化不同。

误区三:Undo Log 只在回滚时用

Undo Log 一半的使命是回滚,另一半就是 MVCC 版本链。即使你永远不回滚,只要 MVCC 需要读取旧版本,Undo Log 就活得好好的。所以 Undo 表空间一直涨不一定是有回滚,也可能是有长事务在阻止 Purge 线程清理。


一个完整的 MVCC 可见性判定流程

把前面所有概念串起来,看一条 SELECT 的执行路径:

1. InnoDB 收到 SELECT id=1 的请求
2. 从聚簇索引找到 id=1 的记录行
3. 拿到行上的 DB_TRX_ID = 21
4. 拿着 trx_id=21 去和当前事务的 Read View 比较:
   ┌─ 21 就是当前事务自己?→ 读这个版本
   ├─ 21 < min_trx_id?   → 已提交,读这个版本
   ├─ 21 >= max_trx_id?  → 不可见,沿 DB_ROLL_PTR 找上一个版本
   ├─ 21 在 m_ids 中?    → 未提交,不可见,沿链向上
   └─ 21 不在 m_ids 中?  → 已提交,读这个版本
5. 如果当前版本不可见,重复第 4 步
6. 直到找到可见的版本,或者遍历到链尾返回 NULL

这个判定每一条 SELECT 都在做。数据量不大时几乎没成本,但版本链深了就很慢。

把 MVCC 的组件关系画成一张图,就是这样的:

         Read View (快照登记簿)
               │
         ┌─────┴─────┐
         │   Check   │
         │  creator? │
         │ < min?    │
         │ >= max?   │
         │ in m_ids? │
         └─────┬─────┘
               │
   不可见则回溯 ▼
   ┌───────────┴───────────┐
   │    Undo 版本链        │
   │                       │
   │  当前行 (DB_TRX_ID    │
   │         DB_ROLL_PTR)  │
   │    ↑                  │
   │  旧版本1 (Undo Log)   │
   │    ↑                  │
   │  旧版本2 (Undo Log)   │
   │    ↑                  │
   │  旧版本3 ...          │
   └───────────────────────┘

Read View 定规则,版本链提供数据,两者配合实现了无锁的隔离读。


🤖 AI实战工具箱:让AI帮你造 MVCC 测试

MVCC 也是一样——原理看得懂,现象你得亲手跑一遍才记得住。

场景一:验证 RR 下快照读的行为

把下面这段粘给 AI:

帮我写一组 MySQL SQL,用两个终端演示 InnoDB RR 隔离级别下的 MVCC 快照读行为。创建一张 user 表(id 主键,name 字段),插入一条数据。终端 A 开启事务后先用 SELECT 读取数据,终端 B 修改并提交,终端 A 再次 SELECT——验证 RR 下两次读取结果一致(可重复读)。用 SLEEP 控制时序,每个步骤加注释说明 Read View 的变化。

场景二:验证快照读 vs 当前读的差异

帮我写一组 MySQL SQL,演示 RR 级别下快照读和当前读的差异。终端 A 先 SELECT(快照读)拿到旧值,然后执行 SELECT ... FOR UPDATE(当前读)拿到最新值。终端 B 在中间修改并提交数据。用 SLEEP 控制时序,注释说明两次读取为什么结果不同。

场景三:模拟长事务导致的 Undo 膨胀

帮我写一组 MySQL SQL,模拟长事务导致 Undo Log 无法清理的场景。开启一个事务但不提交,执行 3 次 UPDATE 修改同一行数据(每次修改让版本链增加 1 层)。然后查一下当前行的 Undo 信息(通过 information_schema 或 SHOW ENGINE INNODB STATUS),验证版本链的长度。最后提交事务,再次查看 Undo 状态。每个步骤加注释说明 Undo 的生命周期。

验证思路:跑完脚本后重点观察两次 SELECT 的结果是否一致(场景一)、FOR UPDATE 是否看到了更新后的值(场景二)、COMMIT 前后 Undo 空间的差异(场景三)。


思考题

🤔 互动时间:

  1. RR 级别下,事务 A 快照读读到 name='Alice',然后事务 B 提交了 name='Bob',事务 A 执行 UPDATE user SET name='CCC' WHERE name='Alice'——能更新到行吗?更新后 name 是多少?(提示:考虑 name 列有无索引两种情形。有索引时直接定位加锁,当前读拿到的是最新值 'Bob',条件不满足,不会更新;无索引时全表扫描 + 半一致性读,用快照判断条件,读到的是 'Alice',但加锁后真正改的是否还是这个版本?你可以用 AI 工具箱的场景一生成脚本来验证。
  2. 如果一个事务一直不提交,另一个事务执行快照读——性能会下降吗?为什么?
  3. 为什么 MySQL 在 RC 级别下不用 Next-Key Lock、只用 Record Lock?这和 MVCC 有什么关系?

总结

🎯 面试考点

  • MVCC 核心思想:读不阻塞写,写不阻塞读。通过多版本实现隔离,而不是通过锁
  • 快照读 vs 当前读:普通 SELECT 读快照,FOR UPDATE/UPDATE/DELETE 读最新并加锁
  • Read View:事务第一次快照读时的"数据全景登记簿"(使用 WITH CONSISTENT SNAPSHOT 则在 BEGIN 时创建),RR 下全局只创建一次,RC 下每条语句重新创建
  • Undo 版本链:每条记录串联了自己的修改历史,Read View 沿着链找可见版本
  • 可见性判定:根据事务ID与 Read View 的比较结果决定读哪个版本——核心就四个判断
  • 长事务危害:版本链不能清理,Undo 膨胀,MVCC 性能下降

下期预告:事务死锁的成因与避免 —— 面试必问!

全本合集《每天一个MySQL知识点,百日打怪升级》


有问题欢迎评论区交流,明天见!

相关文章
|
8天前
|
人工智能 开发工具 iOS开发
Claude Code 新手完全上手指南:安装、国产模型配置与常用命令全解
Claude Code 是一款运行在终端环境中的 AI 编程助手,能够直接在命令行中完成代码生成、项目分析、文件修改、命令执行、Git 管理等开发全流程工作。它最大的特点是**任务驱动、终端原生、轻量高效、多模型兼容**,无需图形界面、不依赖 IDE 插件,能够深度融入开发者日常工作流。
2970 7
|
10天前
|
Shell API 开发工具
Claude Code 快速上手指南(新手友好版)
AI编程工具卷疯啦!Claude Code凭借任务驱动+终端原生的特性,成了开发者的效率搭子。本文从安装、登录、切换国产模型到常用命令,手把手带新手快速上手,全程避坑,30分钟独立用起来。
3071 20
|
23天前
|
人工智能 JSON 供应链
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
LucianaiB分享零成本畅用JVS Claw教程(学生认证享7个月使用权),并开源GeoMind项目——将JVS改造为科研与产业地理情报可视化AI助手,支持飞书文档解析、地理编码与腾讯地图可视化,助力产业关系图谱构建。
23567 15
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
|
4天前
|
人工智能 Linux BI
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
JeecgBoot AI专题研究 一键脚本:Claude Code + JeecgBoot Skills + DeepSeek 全平台接入 一行命令装好 Claude Code + JeecgBoot Skills + DeepSeek 接入,无需翻墙使用 Claude Code,支持 Wind
1956 3
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
|
10天前
|
人工智能 JSON BI
DeepSeek V4-Pro 接入 Claude Code 完全实战:体验、测试与关键避坑指南
Claude Code 作为当前主流的 AI 编程辅助工具,凭借强大的代码理解、工程执行与自动化能力深受开发者喜爱,但原生模型的使用成本相对较高。为了在保持能力的同时进一步降低开销,不少开发者开始寻找兼容度高、价格更友好的替代模型。DeepSeek V4 系列的发布带来了新的选择,该系列包含 V4-Pro 与 V4-Flash 两款模型,并提供了与 Anthropic 完全兼容的 API 接口,理论上只需简单修改配置,即可让 Claude Code 无缝切换为 DeepSeek 引擎。
2464 3
|
8天前
|
人工智能 安全 开发工具
Claude Code 官方工作原理与使用指南
Claude Code 不是传统代码补全工具,而是 Anthropic 推出的终端 AI 代理,具备代理循环、双驱动架构(模型+工具)、全局项目感知、6 种权限模式等核心能力,本文基于官方文档系统解析其工作原理与高效使用技巧。
1342 0
|
8天前
|
存储 Linux iOS开发
【2026最新】MarkText中文版Markdown编辑器使用图解(附安装包)
MarkText是一款免费开源、跨平台的Markdown编辑器,主打所见即所得实时预览,支持Windows/macOS/Linux。内置数学公式、流程图、代码高亮、多主题及PDF/HTML导出,是Typora的轻量免费替代首选。(239字)