【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知识点,百日打怪升级》


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

相关文章
|
1月前
|
人工智能 关系型数据库 MySQL
【第3天】每天一个MySQL知识点,百日打怪升级
本系列由10年经验DBA主理,系统讲解MySQL安装(RPM/二进制/源码)与核心配置,涵盖`my.cnf`优先级、`innodb_buffer_pool_size`调优、连接与日志参数设置、四种生效方式对比,并附生产避坑指南与面试高频考点,助力快速入门与实战进阶。(239字)
207 2
|
2月前
|
SQL 存储 关系型数据库
【第1天】每天一个MySQL知识点,百日打怪升级
本系列以“每天一个知识点”形式,系统讲解MySQL核心原理。首日聚焦Client/Server通信流程,详解三层架构(连接层→SQL层→存储引擎层)、连接管理、查询解析优化及执行计划,直击面试高频考点,助你从CRUD进阶到懂原理、能排障。(239字)
234 0
|
SQL 缓存 运维
PostgreSQL 事务号回卷分析
## XID 定义 xid 是个啥东西?xid 就是 PostgreSQL 里面的事务号,每个事物都会分配一个 xid。PostgreSQL 数据中每个元组头部都会保存着 插入 或者 删除 这条元组的事务号,即 xid,然后内核通过这个 xid 进行元组的可见性判断。简单理解,比如有两个事务,xid1=200,xid2=201,那么 xid1 中只能看到 t_xmin 200 的元组。 ```c
|
5天前
|
人工智能 数据可视化 安全
阿里云百炼Token Plan是什么?核心定义、功能及优惠订阅方案详解
随着AI大模型应用从个人尝鲜走向企业规模化落地,模型调用的成本管控、额度管理、团队协作与服务稳定性成为核心痛点。传统按量付费模式虽灵活,但易出现账单波动、预算不可控、高峰调用受限等问题,难以适配团队长期、高频、稳定的AI使用需求。阿里云百炼平台作为一站式大模型服务平台,推出的Token Plan订阅方案,正是为解决这些痛点而生。
293 0
|
人工智能 运维 关系型数据库
智能运维+多模型服务能力,阿里云 RDS AI 助手旗舰版正式上线!
RDS AI 助手旗舰版在 RDS AI 助手专业版智能运维能力的基础上,提供灵活模型选择、智能模型路由、多模型灾备、API Key 集成等更自主可控、灵活便捷的模型服务,并支持纳管运维各类环境部署的数据库。
智能运维+多模型服务能力,阿里云 RDS AI 助手旗舰版正式上线!
|
5天前
|
人工智能 弹性计算 Serverless
阿里云最新AI产品优惠权益解析:千问旗舰模型助力AI落地,轻量云2核2G38元起,9.9元快速部署OpenClaw
阿里云2026年最新AI普惠权益,覆盖个人开发者、学生及企业用户。核心权益包括:阿里云百炼Token Plan支持多模型灵活切换,首购低至4.5折,标准/高级/尊享三档套餐满足不同用量需求;视频生成模型HappyHorse限时8折,720P每秒仅0.72元起;高校学生完成认证可领300元无门槛抵扣金;轻量应用服务器2核2G低至38元/年,9.9元可快速部署OpenClaw;另有超30款AI产品及7000万tokens免费试用,AI组合购套餐78元起,以及百炼"先用后返"最高返200元活动,全方位降低AI应用落地门槛。
|
1月前
|
SQL 关系型数据库 MySQL
【MySQL百日打怪升级第14天】 LIMIT 分页的性能优化:深分页到底慢在哪?
本文深入剖析MySQL深分页(如`LIMIT 100000,20`)性能瓶颈:本质是OFFSET导致全量扫描与丢弃,页码越深,扫描行数线性增长。详解三种实战优化方案——游标分页(高效稳定,需有序唯一字段)、延迟关联(兼容OFFSET,索引覆盖减回表)、范围分页(极简但场景受限),并附EXPLAIN对比与避坑指南。(239字)
195 6
|
1月前
|
SQL 算法 关系型数据库
【MySQL百日打怪升级第10天】JOIN的底层原理与优化:NLJ、Hash Join 与 Merge Join
本文系统解析MySQL三大JOIN算法:NLJ(含Simple/Index/Block变体)、8.0.18引入的Hash Join(O(N+M)复杂度,专治无索引大表连接),以及面试常考但MySQL原生不支持的Sort-Merge Join,附实战EXPLAIN识别与优化指南。(239字)
192 5
|
2月前
|
SQL 关系型数据库 MySQL
EXPLAIN 执行计划:一眼看穿你的SQL慢在哪
数据库小学妹带你轻松掌握SQL性能诊断!通过EXPLAIN查看执行计划,精准识别索引失效、全表扫描(ALL)、key为NULL等瓶颈。聚焦type、key、rows等6个关键字段,结合实战案例与避坑指南(如函数滥用、最左前缀破坏),让优化有的放矢。学完即用,告别盲目调优!
|
存储 虚拟化 Docker