深入浅出 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

HarmonyOS6半年磨一剑 - RcText组件核心架构与类型系统设计

HarmonyOS6半年磨一剑 - RcText组件核心架构与类型系统设计

文章目录 * 前言 * 项目简介 * rchoui官网 * 一、概述 * 二、组件架构设计 * 2.1 装饰器体系 * 2.2 参数状态管理 * 核心内容参数 * 样式定制参数 * 高级功能参数 * 2.3 类型系统设计 * 文本主题类型 * 尺寸规格类型 * 对齐方式类型 * 文本装饰类型 * 字体粗细类型 * 显示模式类型 * 三、核心设计模式 * 3.1 策略模式 - 主题颜色管理 * 3.2 计算属性模式 - 尺寸计算 * 3.3 适配器模式 - 对齐方式转换 * 3.4 装饰器模式 - 文本装饰转换 * 3.5

By Ne0inhk
Spring Boot + jQuery 前后端分离图书管理系统:从接口设计到问题排查

Spring Boot + jQuery 前后端分离图书管理系统:从接口设计到问题排查

图书管理系统 1.1 准备前端代码 在本地想要的可以去我的gitee中下载 library 的相关前端代码 1.2 约定前后端交互接口 需求分析 图书管理系统是⼀个相对较大一点的案例,咱们先实现其中的⼀部分功能. 用户登录 1. 登录接口 2. 图书列表展示 字段说明: 字段说明id图书 IDbookName图书名称author作者count数量price定价publish图书出版社status图书状态 1 - 可借阅 其他 - 不可借阅statusCN图书状态中文含义 3.4.3 服务器代码 创建图书类 BookInfo @Data public class BookInfo { //图书ID private Integer id; //书名 private String bookName; //作者 private String

By Ne0inhk

SQL常用语句大全!!!(语法+实战)

SQL(Structured Query Language,结构化查询语言)是操作关系型数据库的标准语言,无论是数据查询、新增、修改、删除,还是数据库架构设计、权限管理,都离不开 SQL。本文整理了 SQL 从入门到进阶的核心语句,涵盖日常开发 90% 以上的使用场景,既是新手入门的教程,也是资深开发者的实用速查手册。 一、基础入门:SQL 核心四大操作(DML) DML(Data Manipulation Language,数据操作语言)是最常用的 SQL 子集,主要用于操作数据表中的数据,核心包含查询(SELECT)、新增(INSERT)、修改(UPDATE)、删除(DELETE) 四大操作。 1. 新增数据(INSERT) 用于向数据表中插入一条或多条新数据,支持直接插入值、

By Ne0inhk