深入浅出 MVCC —— 从零理解 MySQL 并发控制

深入浅出 MVCC —— 从零理解 MySQL 并发控制

本文面向初学者,从最基础的概念讲起,一步步带你理解 MySQL 中 MVCC(多版本并发控制)的工作原理。不需要任何前置知识,看完就能在面试中讲清楚 MVCC。
希望能对大家有帮助!


一、为什么需要 MVCC?从一个故事说起

1.1 没有并发控制的世界

想象一个银行账户系统,张三的账户余额是 1000 元。

场景一:同时读写

时刻线程A(转账)线程B(查询)
T1读取余额:1000
T2读取余额:1000
T3扣款200,更新为800
T4显示余额:1000(旧值!)

线程B看到了一个"过时"的数据。这叫做脏读不可重复读问题。

场景二:同时写

时刻线程A(转入500)线程B(扣款200)
T1读取余额:1000
T2读取余额:1000
T31000+500=1500,写入
T41000-200=800,写入(覆盖了A!)

最终余额是800,线程A的转入操作被"丢失"了。这叫做更新丢失问题。

1.2 最简单的解决方案:加锁

最直观的解决方案是加锁:谁在操作数据,其他人都等着。

线程A拿到锁 → 读1000 → 改成800 → 释放锁 ↓ 线程B拿到锁 → 读800 → ... 

问题:这太慢了!

  • 读和读之间本来不冲突,也要排队
  • 一个长事务会阻塞所有其他事务
  • 在高并发系统中,性能完全无法接受

1.3 MVCC 的思路:空间换时间

MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:

不加锁,而是给数据保留多个版本。每个事务看到的是属于自己的"快照",互不干扰。

就像 Git 一样:

  • 你在 feature-A 分支改代码,我在 feature-B 分支改代码
  • 我们各自看到自己版本的代码,互不影响
  • 最终合并时才需要解决冲突

MVCC 让数据库实现了:

  • 读不阻塞写:你在读旧版本,我可以同时写新版本
  • 写不阻塞读:我在写新数据,你照样能读到你该看到的版本
  • 只有写和写之间才需要加锁

二、MVCC 的核心组件

要理解 MVCC 怎么工作,需要先认识三个核心组件:

2.1 隐藏字段:每行数据的"身份证"

InnoDB 在每行数据后面,偷偷加了几个隐藏字段:

字段名大小含义
DB_TRX_ID6 字节最后修改这行的事务ID
DB_ROLL_PTR7 字节回滚指针,指向 undo log 中这行的上一个版本
DB_ROW_ID6 字节隐藏主键(如果表没有主键才会有)

重点是前两个

  • DB_TRX_ID:告诉我们"这行是被谁改的"
  • DB_ROLL_PTR:告诉我们"这行的上一个版本在哪"

举个例子,假设有这样一行数据:

+----+--------+------------+--------------+ | id | name | DB_TRX_ID | DB_ROLL_PTR | +----+--------+------------+--------------+ | 1 | 张三 | 100 | 0x12345678 | +----+--------+------------+--------------+ 

这行数据是被事务100修改的,DB_ROLL_PTR 指向这行在 undo log 中的上一个版本。

2.2 Undo Log:数据的"历史档案馆"

每当一行数据被修改,InnoDB 不会直接覆盖旧数据,而是:

  1. 旧版本存到 Undo Log 里
  2. DB_ROLL_PTR 指向这个旧版本
  3. 然后才更新当前行

这样就形成了一条版本链

当前数据(最新版本) ↓ DB_ROLL_PTR Undo Log(上一个版本) ↓ DB_ROLL_PTR Undo Log(更早的版本) ↓ DB_ROLL_PTR Undo Log(最初版本) ↓ NULL 

具体例子

假设 name 字段经历了三次修改:

版本链: ┌─────────────────────────────────────┐ │ 当前数据: name='王五', TRX_ID=300 │ └─────────────┬───────────────────────┘ ↓ ROLL_PTR ┌─────────────────────────────────────┐ │ Undo Log: name='李四', TRX_ID=200 │ └─────────────┬───────────────────────┘ ↓ ROLL_PTR ┌─────────────────────────────────────┐ │ Undo Log: name='张三', TRX_ID=100 │ └─────────────┴───────────────────────┘ ↓ ROLL_PTR = NULL(最初版本) 

为什么叫 Undo Log?

因为它最初的作用是支持回滚(Rollback):如果事务执行到一半失败了,可以根据 Undo Log 恢复到修改前的状态。后来发现它还能用来实现 MVCC,一举两得。

2.3 Read View:事务的"快照时刻"

这是 MVCC 最核心的概念!

当一个事务开始读取数据时(准确说是执行第一条 SELECT 时),InnoDB 会给这个事务创建一个 Read View(读视图)。

Read View 记录了这一瞬间的事务状态:

字段含义
m_ids当前所有活跃(未提交)事务的 ID 列表
min_trx_idm_ids 中的最小值(最老的活跃事务)
max_trx_id下一个将要分配的事务 ID(当前最大事务ID + 1)
creator_trx_id创建这个 Read View 的事务自己的 ID

举个例子

假设现在有以下事务正在运行:

  • 事务 100:已提交
  • 事务 200:正在执行(未提交)
  • 事务 300:正在执行(未提交)
  • 事务 400:刚开始,要创建 Read View

那么事务 400 的 Read View 是:

m_ids = [200, 300] // 当前活跃的事务 min_trx_id = 200 // 活跃事务中最小的 max_trx_id = 401 // 下一个要分配的事务ID creator_trx_id = 400 // 自己的ID 

三、MVCC 的可见性判断(核心!)

有了 Read View 和版本链,MVCC 就可以判断:当前事务能看到哪个版本的数据?

3.1 判断规则

拿到一行数据的 DB_TRX_ID(修改这行的事务ID),按以下规则判断:

规则一:自己修改的,肯定能看到

如果 DB_TRX_ID == creator_trx_id → 可见(是我自己改的) 

规则二:在我之前就已经提交的,能看到

如果 DB_TRX_ID < min_trx_id → 可见(这个事务在我创建 Read View 之前就提交了) 

规则三:在我之后才开始的,看不到

如果 DB_TRX_ID >= max_trx_id → 不可见(这个事务是在我之后才开始的) 

规则四:在 min 和 max 之间的,要看是否在活跃列表中

如果 min_trx_id <= DB_TRX_ID < max_trx_id 如果 DB_TRX_ID 在 m_ids 列表中 → 不可见(这个事务还没提交) 否则 → 可见(这个事务已经提交了) 

3.2 完整的判断流程图

 读取一行数据 ↓ 获取该行的 DB_TRX_ID ↓ ┌───────────────┴───────────────┐ ↓ ↓ DB_TRX_ID == 自己? DB_TRX_ID < min_trx_id? ↓ 是 ↓ 是 【可见】 【可见】 ↓ 否 ↓ 否 └───────────────┬───────────────┘ ↓ DB_TRX_ID >= max_trx_id? ↓ 是 【不可见】 ↓ 否 DB_TRX_ID 在 m_ids 中? ↓ 是 【不可见】 ↓ 否 【可见】 

3.3 如果不可见怎么办?

如果当前版本不可见,就顺着 DB_ROLL_PTR 找到 Undo Log 中的上一个版本,重新判断。

一直往前找,直到找到一个可见的版本,或者找到 NULL(说明这行数据对当前事务来说"不存在")。


四、实战举例:一步步模拟 MVCC

场景设定

初始状态:表中有一行数据

id=1, name='张三', DB_TRX_ID=50, DB_ROLL_PTR=NULL

(事务50很久以前就提交了)

现在有三个事务并发执行:

事务操作
事务100读取 id=1
事务200修改 name=‘李四’
事务300读取 id=1

执行过程

T1:事务200 开始,修改数据

-- 事务200BEGIN;UPDATEuserSET name ='李四'WHERE id =1;-- 注意:还没有 COMMIT!

执行后,数据变成:

当前数据: name='李四', DB_TRX_ID=200, DB_ROLL_PTR → Undo Log ↓ Undo Log: name='张三', DB_TRX_ID=50, DB_ROLL_PTR=NULL 

T2:事务100 开始读取

-- 事务100BEGIN;SELECT name FROMuserWHERE id =1;

事务100 创建 Read View:

m_ids = [200] // 事务200正在活跃 min_trx_id = 200 max_trx_id = 301 // 下一个事务ID creator_trx_id = 100 

判断过程:

  1. 读取当前数据:DB_TRX_ID = 200
  2. 200 不等于 100(不是自己改的)
  3. 200 不小于 200(不是在 Read View 之前提交的)
  4. 200 不大于等于 301
  5. 200 在 m_ids [200] 中 → 不可见!
  6. 顺着 ROLL_PTR 找到 Undo Log:DB_TRX_ID = 50
  7. 50 < 200 → 可见!

结果:事务100 读到的是 name='张三'

T3:事务200 提交

-- 事务200COMMIT;

T4:事务300 开始读取

-- 事务300BEGIN;SELECT name FROMuserWHERE id =1;

事务300 创建 Read View:

m_ids = [] // 事务200已经提交,没有活跃事务了 min_trx_id = ∞ // m_ids为空,设为无穷大(简化理解) max_trx_id = 301 creator_trx_id = 300 

判断过程:

  1. 读取当前数据:DB_TRX_ID = 200
  2. 200 不等于 300(不是自己改的)
  3. 200 < 301(在 max_trx_id 之前)
  4. m_ids 为空,200 不在其中 → 可见!

结果:事务300 读到的是 name='李四'

总结

事务读取时机看到的值原因
事务100事务200未提交时张三200在活跃列表中,不可见
事务300事务200已提交后李四200不在活跃列表中,可见

这就是 MVCC 的魔法:不同事务根据自己的 Read View,看到不同版本的数据!


五、Read View 的生成时机:RC vs RR

MVCC 的行为在不同隔离级别下有所不同,关键区别在于 Read View 什么时候生成

5.1 READ COMMITTED(读已提交,RC)

每次 SELECT 都生成新的 Read View

-- 事务ABEGIN;SELECT name FROMuserWHERE id =1;-- 生成 Read View #1-- ... 等一会儿,事务B提交了 ...SELECT name FROMuserWHERE id =1;-- 生成 Read View #2(新的!)COMMIT;

因为每次读都用新的 Read View,所以:

  • 如果在两次 SELECT 之间,其他事务提交了修改
  • 第二次 SELECT 能看到新提交的数据
  • 这就是"读已提交"的含义

问题:两次读可能得到不同的结果(不可重复读)

5.2 REPEATABLE READ(可重复读,RR)

只在事务第一次 SELECT 时生成 Read View,后续复用

-- 事务ABEGIN;SELECT name FROMuserWHERE id =1;-- 生成 Read View #1-- ... 事务B提交了修改 ...SELECT name FROMuserWHERE id =1;-- 复用 Read View #1(不是新的!)COMMIT;

因为始终用同一个 Read View,所以:

  • 无论其他事务怎么修改和提交
  • 在同一个事务内,多次读同一行数据,结果始终一致
  • 这就是"可重复读"的含义

MySQL InnoDB 默认使用 REPEATABLE READ 隔离级别

5.3 对比表格

隔离级别Read View 生成时机同一事务内多次读
READ COMMITTED每次 SELECT 都生成新的可能读到不同值
REPEATABLE READ第一次 SELECT 生成,后续复用保证读到相同值

六、MVCC 解决了哪些问题?没解决哪些?

6.1 MVCC 解决的问题

问题是否解决说明
脏读✅ 解决未提交的事务对其他事务不可见
不可重复读✅ 解决(RR级别)Read View 锁定快照
读阻塞写✅ 解决读的是历史版本,写的是当前版本
写阻塞读✅ 解决同上

6.2 MVCC 没有解决的问题

幻读(Phantom Read):MVCC 不能完全解决幻读。

什么是幻读?

-- 事务ABEGIN;SELECTCOUNT(*)FROMuserWHERE age >20;-- 结果:5条-- 事务B 插入一条 age=25 的新数据并提交SELECTCOUNT(*)FROMuserWHERE age >20;-- 结果可能还是5条(MVCC保护)-- 但如果事务A执行 UPDATE:UPDATEuserSETstatus=1WHERE age >20;-- 会更新6条!包括事务B插入的SELECTCOUNT(*)FROMuserWHERE age >20;-- 结果变成6条了!

这就是幻读:同一个事务内,同样的查询条件,前后读到的行数不一样。

MySQL InnoDB 的解决方案:用 Next-Key Lock(临键锁)来防止幻读,这是在 MVCC 之外的锁机制。

6.3 写-写冲突

MVCC 不解决写-写冲突,两个事务同时写同一行时,还是需要加锁

  • 先到的事务获得行锁
  • 后到的事务等待

这叫做当前读(Current Read),会读取最新版本并加锁。


七、快照读 vs 当前读

7.1 快照读(Snapshot Read)

使用 MVCC 机制,读取的是历史快照版本,不加锁。

-- 普通的 SELECT 就是快照读SELECT*FROMuserWHERE id =1;

7.2 当前读(Current Read)

读取的是数据的最新版本,并且会加锁。

-- 以下都是当前读,会加锁SELECT*FROMuserWHERE id =1FORUPDATE;-- 加排他锁SELECT*FROMuserWHERE id =1LOCKINSHAREMODE;-- 加共享锁INSERTINTOuserVALUES(...);-- 加排他锁UPDATEuserSET name ='x'WHERE id =1;-- 加排他锁DELETEFROMuserWHERE id =1;-- 加排他锁

关键区别

类型读取版本是否加锁典型语句
快照读历史快照不加锁SELECT ...
当前读最新版本加锁SELECT ... FOR UPDATE, INSERT, UPDATE, DELETE

八、面试答案模板(直接背诵版)

问题:请解释一下 MySQL 的 MVCC 机制?

MVCC 是多版本并发控制,InnoDB 用它来实现读写不阻塞。核心思想是:不删除旧数据,而是保留多个版本,每个事务根据自己的"快照"来决定能看到哪个版本

MVCC 有三个核心组件:

第一是隐藏字段:每行数据都有 DB_TRX_ID(最后修改的事务ID)和 DB_ROLL_PTR(指向 Undo Log 的指针)。

第二是 Undo Log:每次修改数据时,旧版本会存到 Undo Log 里,通过 ROLL_PTR 串成一条版本链。

第三是 Read View:事务读数据时会创建一个 Read View,记录当前有哪些事务正在活跃(未提交)。然后根据版本链上每个版本的 TRX_ID,判断这个版本是否对当前事务可见。

判断规则简单说就是:已提交的能看到,未提交的看不到,自己改的能看到

RC 和 RR 隔离级别的区别在于 Read View 的生成时机:RC:每次 SELECT 都生成新的 Read View,所以能读到其他事务新提交的数据RR:只在第一次 SELECT 时生成,后续复用,所以同一事务内多次读结果一致

需要注意的是,MVCC 只用于快照读(普通 SELECT)。SELECT FOR UPDATEINSERTUPDATEDELETE 这些是当前读,会加锁,不走 MVCC。

九、常见面试追问

Q1:Undo Log 会无限增长吗?什么时候清理?

不会。InnoDB 有一个 Purge 线程,专门负责清理不再需要的 Undo Log。

清理条件:当没有任何活跃的 Read View 需要访问某个历史版本时,这个版本就可以被清理了。

Q2:MVCC 和锁是什么关系?

  • MVCC 解决读写并发问题:读不阻塞写,写不阻塞读
  • 锁解决写写并发问题:两个事务同时写同一行时加锁
  • 两者是互补的,不是替代关系

Q3:为什么 InnoDB 默认用 RR 而不是 RC?

  • RR 提供更强的一致性保证(可重复读)
  • 配合 Next-Key Lock 可以解决幻读
  • 对大多数业务场景来说,RR 的行为更符合直觉

Q4:MVCC 和乐观锁有什么区别?

维度MVCC乐观锁
层面数据库引擎层实现应用层实现
冲突检测通过版本链判断可见性通过版本号/时间戳检测
用途读写并发控制写写冲突检测
代码无需修改业务代码需要在代码中加版本判断

十、总结

概念一句话解释
MVCC多版本并发控制,读写不阻塞
DB_TRX_ID每行数据记录"谁最后改的我"
DB_ROLL_PTR指向 Undo Log 中的上一个版本
Undo Log存储数据的历史版本,形成版本链
Read View事务的快照,记录活跃事务列表
快照读普通 SELECT,走 MVCC,不加锁
当前读FOR UPDATE/INSERT/UPDATE/DELETE,加锁
RC vs RRRC 每次 SELECT 新建 Read View;RR 只建一次

恭喜你看完了! 如果你能把上面的面试答案模板讲清楚,MVCC 这个知识点就算过关了。

建议配合动手实验加深理解:

-- 开两个 MySQL 客户端,分别执行事务,观察隔离级别的效果SETTRANSACTIONISOLATIONLEVELREADCOMMITTED;SETTRANSACTIONISOLATIONLEVELREPEATABLEREAD;

有问题欢迎在评论区交流!

Read more

深入浅出解析Stable Diffusion核心网络架构:VAE、U-Net与CLIP Text Encoder

深入浅出解析Stable Diffusion核心网络架构:VAE、U-Net与CLIP Text Encoder

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 * 前言 * 引言 * 一、Stable Diffusion整体架构初识 * 1.1 架构概览 * 1.2 模型规模与参数分布 * 二、核心组件一:VAE(变分自编码器)—— 图像的“压缩与重建引擎” * 2.1 VAE的核心作用 * 2.2 VAE的高阶能力:控制图像色彩与细节 * 2.3 VAE的网络结构细节 * 2.4 VAE的训练逻辑与损失函数 * 2.5 实战:用Diffusers加载VAE并测试重建效果 * 三、核心组件二:U-Net—— 噪声预测与图像生成的“核心大脑” * 3.1 U-Net的核心作用 * 3.2 U-Net的网络结构细节

By Ne0inhk

Stable Diffusion 3.5发布:图像质量与社区友好双提升

Stable Diffusion 3.5-FP8:当高质量生成遇上高效部署 在文生图模型的赛道上,性能与可用性之间的拉锯战从未停止。几年前,我们还在为能否让模型稳定输出一张不崩坏的文字海报而头疼;如今,Stability AI 发布的 Stable-Diffusion-3.5-FP8,已经能以近乎无损的质量、仅需12GB显存的代价,生成包含精确排版和复杂语义的高分辨率图像。 这不只是参数量或架构的堆叠升级,而是一次真正面向落地场景的工程突破——它把原本属于高端实验室的生成能力,带进了普通开发者的笔记本电脑里。 从“争议闭源”到“社区回归”:SD3.5 的战略转向 回顾去年 SD3 初发布时的情景,不少开发者对新许可条款感到寒心:商用限制严苛,连微小盈利项目都可能踩线。结果是社区活跃度骤降,Hugging Face 上的衍生模型增长几乎停滞。 但这次不一样。 Stable Diffusion 3.5 直接宣布:研究用途、非商业应用、年收入低于100万美元的小型商业项目均可免费使用。这一政策迅速点燃了生态热情,短短一周内,

By Ne0inhk

3分钟变身AI绘画大师:SDXL Prompt Styler如何让你的提示词拥有魔法?

3分钟变身AI绘画大师:SDXL Prompt Styler如何让你的提示词拥有魔法? 【免费下载链接】sdxl_prompt_styler 项目地址: https://gitcode.com/gh_mirrors/sd/sdxl_prompt_styler 在AI绘画的世界里,你是否也曾遇到这样的困境:明明脑海中已经有了清晰的画面,却无法用文字精准传达给AI?🤔 别担心!今天要介绍的SDXL Prompt Styler就像一位神奇的翻译官,能把你的创意灵感转化为AI能理解的艺术语言,让你的作品瞬间提升几个level! 为什么你的AI绘画总差一口气? 想象一下,你想画一个"森林中的未来帐篷",直接输入提示词可能得到一张普通图片。但如果给提示词加上"奥斯卡级视觉效果、专业摄影、超细节刻画"这样的魔法前缀,结果会怎样?✨ SDXL Prompt Styler就是这样一个给提示词"化妆"的神器,

By Ne0inhk
AIGC 异步回调系统实现

AIGC 异步回调系统实现

AIGC 异步回调系统实现 目录 * 一、系统概述 * 二、核心文件说明 * 三、数据流详解 * 四、配置说明 * 五、开发指南 * 六、运维指南 一、系统概述 1.1 背景与目标 本系统实现了一套通用的异步回调架构,用于处理 AIGC 服务(视频生成、图片生成等)的长耗时任务。 核心目标: * ✅ 快速响应:接收请求后立即返回 task_id,不阻塞 * ✅ 状态管理:通过数据库追踪任务状态 * ✅ 回调通知:完成后自动回调后端 * ✅ 统一管理:一套架构支持多种业务类型 * ✅ 可观测性:完整的时间戳链路追踪 1.2 两种回调地址(核心概念) 回调地址说明示例配置方式algorithm_callback_urlAIGC 供应商回调我们的地址https://algorithm.

By Ne0inhk