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

OpenClaw 从入门到精通:本地优先 AI 助手,一文吃透架构、部署与实战

OpenClaw 从入门到精通:本地优先 AI 助手,一文吃透架构、部署与实战

适合人群:前端/全栈开发者、AI 爱好者、私有化部署玩家 阅读收益:理解设计思想 → 10 分钟部署落地 → 掌握二次开发思路 一、OpenClaw 到底是什么? OpenClaw 是开源、本地优先、可自动执行任务的个人 AI 助手。 它不只是聊天,而是能接管你的电脑、文件、浏览器、IM 工具,用自然语言完成真实工作。 核心定位 • 私有化:数据不上云,全在本地 • 能干活:文件管理、浏览器操作、消息收发、脚本执行 • 全渠道:Telegram/Discord/Slack/iMessage 等一键接入 • 插件化:Skills 技能系统,无限扩展 核心优势 • 🌐 Gateway 统一网关:所有通道、

By Ne0inhk
MySQL查看命令速查表

MySQL查看命令速查表

🎬 个人主页:艾莉丝努力练剑 ❄专栏传送门:《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录》 《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》 ⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平 🎬 艾莉丝的简介: 文章目录 * 1 ~> MySQL 查看类命令大全 * 1.1 查看数据库 * 1.2 查看表 * 1.3 查看数 * 1.4 查看用户 / 权限 * 1.5 最常用组合(截图里就是这套) * 2 ~> MySQL常用核心命令速查表 * 2.1 MySQL 常用核心命令速查表 * 2.

By Ne0inhk
基于 DeepSeek V3.2 与 Go 语言构建智能日志分析系统实战深度解析

基于 DeepSeek V3.2 与 Go 语言构建智能日志分析系统实战深度解析

前言 在现代运维与软件开发体系中,日志数据是洞察系统健康状态的核心资产。面对海量且非结构化的日志信息,传统的基于规则(Rule-based)或关键词匹配的分析手段往往难以应对复杂的故障模式。随着大语言模型(LLM)能力的飞跃,利用生成式 AI 进行语义级日志分析已成为提升运维效率的关键路径。本文将深入剖析如何基于 Ubuntu 环境,利用 Go 语言的高并发与强类型特性,结合 DeepSeek V3.2 模型的推理能力,从零构建一个流式智能日志分析器。文章将涵盖环境部署、运行时配置、API 交互协议设计、流式数据处理及最终的实战验证。 第一章:Linux 基础环境初始化与依赖管理 构建稳健的应用始于可靠的底层环境。在 Ubuntu 20.04/22.04/24.04 LTS 系统中,保持软件包的最新状态是确保依赖兼容性与系统安全性的首要步骤。 1.1 系统源更新与升级 在执行任何安装操作前,必须同步包管理器的索引文件,

By Ne0inhk
【多模态大模型面经】现代大模型架构(一): 组注意力机制(GQA)和 RMSNorm

【多模态大模型面经】现代大模型架构(一): 组注意力机制(GQA)和 RMSNorm

🧔 这里是九年义务漏网鲨鱼,研究生在读,主要研究方向是人脸伪造检测,长期致力于研究多模态大模型技术;国家奖学金获得者,国家级大创项目一项,发明专利一篇,多篇论文在投,蓝桥杯国家级奖项、妈妈杯一等奖。 ✍ 博客主要内容为大模型技术的学习以及相关面经,本人已得到B站、百度、唯品会等多段多模态大模型的实习offer,为了能够紧跟前沿知识,决定写一个“从零学习 RL”主题的专栏。这个专栏将记录我个人的主观学习过程,因此会存在错误,若有出错,欢迎大家在评论区帮助我指出。除此之外,博客内容也会分享一些我在本科期间的一些知识以及项目经验。 🌎 Github仓库地址:Baby Awesome Reinforcement Learning for LLMs and Agentic AI 📩 有兴趣合作的研究者可以联系我:[email protected] 文章目录 * 前言 * 一、现如今的”Transformer“ * 二、Attention Serious * 2.1 Multi-Head

By Ne0inhk