目录
[1. 为什么并发事务会产生问题](#1. 为什么并发事务会产生问题)
[1. 脏读(Dirty Read)](#1. 脏读(Dirty Read))
[2. 不可重复读(Non-repeatable Read)](#2. 不可重复读(Non-repeatable Read))
[3. 幻读(Phantom Read)](#3. 幻读(Phantom Read))
[1. 隔离级别对照](#1. 隔离级别对照)
[2. 对照表](#2. 对照表)
[四、Undo Log 原理](#四、Undo Log 原理)
[1. Undo Log 解决了什么问题](#1. Undo Log 解决了什么问题)
[2. DML 操作对应 Undo Log](#2. DML 操作对应 Undo Log)
[3. 事务回滚流程](#3. 事务回滚流程)
[1. 聚簇索引记录中的隐藏字段](#1. 聚簇索引记录中的隐藏字段)
[2. 版本链如何形成](#2. 版本链如何形成)
[六、Read View](#六、Read View)
[1. 什么是 Read View?](#1. 什么是 Read View?)
[2. Read View 的四大核心物理字段](#2. Read View 的四大核心物理字段)
[3. Read View 可见性判断原则](#3. Read View 可见性判断原则)
[七、MVCC 整体流程](#七、MVCC 整体流程)
[1. 快照读与当前读](#1. 快照读与当前读)
[3. 快照读的 MVCC 工作流](#3. 快照读的 MVCC 工作流)
[八、RC 与 RR 的区别](#八、RC 与 RR 的区别)
[1. Read View 生成时机对比](#1. Read View 生成时机对比)
[2. 为什么 RC 会出现不可重复读](#2. 为什么 RC 会出现不可重复读)
[3. 为什么 RR 能解决不可重复读?](#3. 为什么 RR 能解决不可重复读?)
[4. RR 是否完全解决幻读](#4. RR 是否完全解决幻读)
一、事务隔离问题回顾
在上一章中,我们初步认识了事务,并了解了数据库为了在安全与性能之间寻找平衡而设计的四种隔离级别。然而,这只是理论的冰山一角
在高并发的真实生产环境中,成百上千个事务同时交织读写,底层的物理冲突远比想象中复杂。本章我们将正式撕开数据库的底面,从微观的并发异象开始,一路下探到 InnoDB 存储引擎最核心的部分------MVCC 机制
1. 为什么并发事务会产生问题
从操作系统的物理视角来看,数据库本质上是一个多线程共享的内存与磁盘混合资源池
当我们执行SELECT或UPDATE操作时,InnoDB引擎会依次完成以下步骤:将所需数据页从磁盘加载到内存缓冲池,进行加锁 / 非加锁处理,修改数据,最后通过异步机制刷新磁盘
如果所有的业务请求都单线程运行,数据自然绝对安全。但在追求极致吞吐量的现代后端架构中,数万个线程同时对 Buffer Pool 中同一张表的同一行进行读写,就必然会导致内存状态机的错乱。这种由于多个物理线程对共享数据页的并发读写缺乏时序规范而导致的逻辑灾难,就是并发事务问题的根源
隔离级别与并发性能
为了解决无序的并发冲突,SQL 标准定义了四种隔离级别:读未提交 、读已提交 、可重复读 和串行化
这四种隔离级别的设计,本质上是通过对底层锁资源和内存开销的精准调控,在并发吞吐量与数据安全性之间寻求最佳平衡点
-
追求吞吐量:降低隔离级别可以减少锁机制带来的性能开销,使读写操作能够更高效地并发执行,从而显著提升系统的 TPS(每秒事务处理量)。然而,这种优化是以牺牲数据一致性为代价,可能导致各种异常读取现象频繁出现
-
追求绝对安全:提高隔离级别,尤其是到了串行化级别,数据库会通过强行加锁把所有的并发读写全部排成单线程。数据虽然绝对精准,但系统的整体吞吐量会直接归零,产生严重的线程阻塞
二、并发事务中的三大问题
当多个事务在缺乏隔离的环境中并发运行时,数据库底层会出现三种经典的并发读取异象
为了方便理解,下文所有的场景均假设存在事务 A 与事务 B 两个物理线程在时间线上交错执行
1. 脏读(Dirty Read)
定义
脏读是指事务 A 读取到了事务 B 已经修改、但尚未正式提交的数据
以银行转账为例,假设张三的初始余额为 1000 元
时间线 (Timeline)
|
|-- T1: 事务 A 开启,查询张三余额,结果为 1000 元
|
|-- T2: 事务 B 开启,执行 UPDATE,将张三余额扣减 200 元(变为 800 元),但未提交
|
|-- T3: 事务 A 再次查询张三余额,读取到了内存 Buffer Pool 中已被修改的 800 元
|
|-- T4: 事务 B 突发业务异常,执行了 ROLLBACK。张三的余额在磁盘和内存中被重置回 1000 元
v
此时事务 A 拿着读取到的 800 元 数据去继续执行后续的财务报表输出或风控计算。然而,这个 800 元 由于事务 B 的回滚,在现实世界中从来没有真正存在过 。事务 A 读取到的就是一段脏数据
2. 不可重复读(Non-repeatable Read)
定义
不可重复读是指事务 A 在其生存周期内,多次读取同一行记录,但在前后两次读取的间隔中,事务 B 对该行数据执行了修改并正式提交,导致事务 A 前后两次读取到的具体数值完全不一致
张三的初始余额为 1000 元
时间线 (Timeline)
|
|-- T1: 事务 A 开启,首次读取张三的余额,结果为 1000 元
|
|-- T2: 事务 B 开启,将张三余额修改为 800 元,并立刻执行了 COMMIT
|
|-- T3: 事务 A 在当前事务内,再次发起对张三余额的读取指令
v
此时,由于事务 B 已经提交,事务 A 读到了最新的 800 元。站在事务 A 的视角:这笔业务还没结束前,我明明没有改过张三的钱,为什么前后两秒钟查出来的余额突然变了?
这打破了事务内数据应当保持静态快照的逻辑预期,使得事务 A 无法做出前后一致的业务决策
3. 幻读(Phantom Read)
定义
幻读是指事务 A 按照某一范围条件(如 age > 20)批量检索数据,首次读取时得到了 N 行记录。随后,事务 B 在该范围内新插入或删除了几行记录并正式提交。当事务 A 再次以相同的条件检索时,惊讶地发现记录行数变成了 N+1 或 N-1 行,仿佛产生了幻觉
假设银行需要统计存款大于 50 万元的高净值客户总数,初始状态下只有 3 个人
时间线 (Timeline)
|
|-- T1: 事务 A 开启,执行范围查询:balance >= 500000;
| 存储引擎返回结果:3 人
|
|-- T2: 事务 B 开启,新开户了一个李四,存款 60 万元
| 并且事务 B 立刻执行了 COMMIT 提交
|
|-- T3: 事务 A 为了二次复核,再次执行相同的范围查询
v
此时事务 A 发现结果突然变成了 4 人。多出来的李四就像幽灵(Phantom)一样凭空冒了出来
误区辨析 : 很多时候容易把不可重复读 和幻读混淆。在面试或技术方案评审中,必须精准指出两者的物理区别:
不可重复读的焦点是:UPDATE / DELETE 。它指的是原本就存在的某一行特定记录,其内部的字段值在前后读取时发生了改变
幻读的焦点是:INSERT 。它指的是原本的单行记录并没有变,而是由于并发插入,导致整体数据集合的记录行数或范围空间发生了突变
| 并发问题 | 触发问题的核心 SQL 类型 | 并发事务 B 的提交状态 | 异常表现 | 底层物理本质 |
|---|---|---|---|---|
| 脏读 | UPDATE / INSERT / DELETE | 尚未提交 | 读取到了注定会随着回滚而消亡的中间内存数据 | 读写完全没有隔离,读取了内存中的不可靠脏页 |
| 不可重复读 | 由 UPDATE 或 DELETE 触发 | 已经提交 | 同一行特定记录,前后读取的字段内容发生了改变 | 事务无法锁定/维持住已有数据行的快照稳态 |
| 幻读 | 由 INSERT 触发 | 已经提交 | 相同范围条件检索,前后得到的记录条数/行数发生了改变 | 事务仅能控制已知行,无法锁住行与行之间的间隙 |
三、隔离级别与问题对照
针对脏读、不可重复读和幻读这三大问题,数据库设计者并没有强制要求系统必须全盘封锁。相反,SQL 标准通过划分四种不同的隔离级别,明确了每一档级别能够防御哪些问题,又对哪些异象做出了妥协
1. 隔离级别对照
读未提交
在该级别下,数据库底层的读操作几乎不加任何限制或锁控制
-
底层行为:一个事务在读取数据时,直接去读取内存缓冲池中被其他并发事务修改后的最新字节页,而完全不检查这个修改事务是否已经提交
-
防御边界 :没有任何防御能力
-
代价与问题 :在此级别下,脏读、不可重复读、幻读三大问题全部会发生。它属于数据库一致性中的裸奔状态,在实际工业级生产中绝不可轻易启用
读已提交
从该级别开始,数据库开始构筑真正的一致性防线
-
底层行为:执行引擎通过机制确保:任何读操作只能看到那些已经发出 COMMIT 命令、进入物理稳态的数据
-
防御边界 :完美解决脏读问题。因为未提交的临时中间修改对其他事务是完全不可见的
-
代价与问题 :由于它在事务内部的每次独立查询都会实时去读取最新的物理快照,导致在并发 UPDATE 或 INSERT 发生并提交时,当前事务前后查询的结果依然会发生突变。因此,不可重复读和幻读问题在此级别下依然存在
可重复读
该级别是 MySQL InnoDB 存储引擎的默认事务隔离级别
-
底层行为:在此级别下,事务启动并执行第一次查询时,系统会为当前数据库状态存储一张内存快照。在接下来的整个事务生命周期内,无论其他并发事务如何修改并提交数据,当前事务的后续读取都只会定格在当初那张快照的画面中
-
防御边界 :完美解决脏读、不可重复读问题
-
针对幻读的特殊说明 :按照原生标准,该级别原则上是无法阻止幻读的。但是 MySQL 的 InnoDB 存储引擎通过 MVCC 与间隙锁,已经基本上解决了幻读问题
串行化
Serializable 是隔离级别的终极形态,它代表着对并发性能的完全绝缘
-
底层行为 :在此级别下,MVCC 这种弱隔离机制会部分失效,数据库会退回到最原始的基于锁的并发控制。所有的普通 SELECT 语句都会被底层隐式自动升级为加共享锁的 SELECT ... FOR SHARE 模式。读操作会阻塞写操作,写操作也会阻塞读操作
-
防御边界 :同时解决脏读、不可重复读、幻读
-
代价与问题:因为所有的读写碰撞都演变成了单线程排队阻塞,系统的并发吞吐量会发生灾难性的跌落。通常仅用于极度敏感的金融账目总清算等无并发要求的特殊场景
2. 对照表
为了在工程设计时能够一目了然地评估风险,SQL 标准将四种隔离级别与并发异象的防御映射关系整理成了如下的标准对照表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 物理加锁/吞吐量开销 |
|---|---|---|---|---|
| Read Uncommitted | 无法防御 | 无法防御 | 无法防御 | 几乎无锁,吞吐量极大 |
| Read Committed | 完美解决 | 无法防御 | 无法防御 | 锁极少,读写分离,吞吐量优秀 |
| Repeatable Read | 完美解决 | 完美解决 | 标准 SQL 无法解决 (InnoDB 基本解决) | 中等,读借助快照,写借助行/间隙锁 |
| Serializable | 完美解决 | 完美解决 | 完美解决 | 极高,读写全部排队,吞吐量极低 |
四、Undo Log 原理
MVCC(多版本并发控制)犹如一座雄伟的建筑,而 Undo Log(回滚日志)正是支撑这座建筑的地基。在 InnoDB 存储引擎中,Undo Log 的精妙机制不仅保障了事务的原子性(回滚能力),更是 MVCC 多版本机制得以实现的关键基础
1. Undo Log 解决了什么问题
定义
Undo Log 是一种逻辑日志 。当事务对数据库进行修改时,InnoDB 不仅会在内存缓冲池中改写字节,还会在专门的 Undo 页面中,将这次修改的逆向操作 以结构化的形式记录下来
解决的两个核心问题
-
实现事务的原子性(回滚):当系统发生业务报错、用户主动调用 ROLLBACK 命令,或者服务器发生断电需要崩溃恢复时,InnoDB 会依据 Undo Log 的记录信息,将数据逆向恢复
-
构建 MVCC 版本链(隔离性) :当一个事务在读已提交或可重复读级别下读取某行记录,而该行正巧被其他事务修改时,执行引擎不会去等待锁,而是会顺着 Undo Log 逆向推导回退,在内存中找出该记录在过去某个节点的快照,从而实现不加锁的高并发快照读
2. DML 操作对应 Undo Log
为了追求极致的性能与空间压缩,InnoDB 针对 INSERT、UPDATE 和 DELETE 三种不同的修改行为,在底层设计了完全不同的 Undo Log 记录格式
INSERT 对应的 Undo Log
当一个事务往表中插入一行全新记录时,对于 "过去的世界" 而言,这行记录是完全不存在的
-
记录内容 :InnoDB 的 TRX_UNDO_INSERT_REC 类型的日志非常精简,它只需要记录该新插入记录的主键 ID
-
生命周期 :极其短暂。因为该记录在事务开启前不存在,没有任何其他并发事务可能通过 MVCC 跨时空访问到它。因此,只要当前 INSERT 事务一旦提交,该插入类型的 Undo Log 就可以立刻被系统直接销毁或回收
UPDATE 对应的 Undo Log
更新操作是 MVCC 的核心演进动力,其对应的 TRX_UNDO_UPD_EXIST_REC 日志最为复杂
-
记录内容 :它会详细记录当前行被修改前的旧值,以及本次修改所涉及的列信息
-
生命周期 :由于其他并发事务可能正在以 "可重复读" 的规格读取当前行的旧版本,因此即便当前 UPDATE 事务已经提交了,其对应的 Undo Log 也绝对不能立刻删除 。它必须一直存放在 Undo 页面中,直到整个系统中所有可能用到该旧版本的事务全部终结后,才由后台的 Purge 线程进行统一的异步物理清理
DELETE 对应的 Undo Log
在 InnoDB 的 B+ 树物理结构中,如果你对某行执行了 DELETE FROM 表 WHERE id = 1,执行引擎并不会瞬间将这行数据从页面的物理链表中删除。因为其他并发事务可能正在读取该页面
-
底层机制:
-
第一阶段:在事务执行删除时,InnoDB 仅仅是将该行记录的头信息中的 delete_mask 标志位修改为 1(逻辑上标记删除)。此时该记录依然在 B+ 树的叶子节点中
-
第二阶段:当事务提交,且没有任何其他并发事务再需要看到这行历史数据时,后台的 Purge 线程才会真正将其从 B+ 树的行链表中物理移除
-
-
Undo Log 的职责:在第一阶段执行时,系统会生成一条删除类型的 Undo Log。如果要回滚,系统只需要将该行的 delete_mask 重新改回 0 即可
| INSERT | UPDATE | DELETE | |
|---|---|---|---|
| 日志类型 | TRX_UNDO_INSERT_REC | TRX_UNDO_UPD_EXIST_REC | TRX_UNDO_DEL_MARK_REC |
| 记录内容 | 仅记录主键 ID | 记录被修改列的历史旧值 | 记录主键及逻辑删除状态 |
| 回滚时行为 | 执行逆向 DELETE | 将字段值重写为旧值 | 将标志位置回 0 |
| 事务提交后 | 立刻释放/复用 | 挂入版本链,等待 Purge 线程 | 挂入版本链,等待 Purge 线程 |
3. 事务回滚流程
我们用前文的 t_balance 表(张三的初始余额为 1000 元)来模拟一次事务崩塌后的回滚轨迹

当收到 ROLLBACK 指令后,InnoDB 执行引擎的工作流如下:
-
反向检索:引擎通过当前事务的控制块,定位到该事务产生的最后一条 Undo Log 记录
-
提取旧值:解析该 Undo 记录,发现主键为 1 的行,其 balance 字段的历史旧值是 1000.00
-
逆向重写:引擎直接在内存和日志中,对主键为 1 的记录执行一次反向的重写,把 balance 从 400 强行改回 1000.00
-
清理释放:当所有逆向操作执行完毕,数据状态完全回到了事务开启前的状态,回滚成功并安全释放该事务所持有的行锁资源。对于外界而言,这笔修改如同从未发生
五、版本链机制
在 InnoDB 存储引擎中,每一行记录在任何时候都绝不仅仅呈现为一个单一的稳态,而是由一系列历史版本相互勾连组合而成的。这种将数据的 "过去" 与 "现在" 串联起来的数据结构,就是 版本链(Version Chain)
而版本链的构建,完全依赖于底层数据行中的隐藏字段
1. 聚簇索引记录中的隐藏字段
当我们定义了一张表并插入数据时,InnoDB 在将其存入 B+ 树的叶子节点时,会自动在每一行记录的头部塞入 2 到 3 个对用户不可见的隐藏列:
DB_TRX_ID(6 字节,事务 ID)
-
物理职责 :用来记录最近一次对当前行执行了 INSERT 或 UPDATE 操作的事务 ID
-
运作逻辑:如果事务 100 执行了一条更新语句修改了这行数据,那么这行记录的 DB_TRX_ID 字段就会被立刻改写为 100。它是后续进行隔离性可见性判断的核心
DB_ROLL_PTR(7 字节,回滚指针)
-
物理职责 :这是一个指向对应的 Undo Log 记录的物理指针
-
运作逻辑:每当当前行被修改时,InnoDB 都会把修改前的旧快照塞进 Undo Page 里。然后,当前行记录的 DB_ROLL_PTR 就会写入一个指针值,指向刚刚生成的这条 Undo Log
DB_ROW_ID(6 字节,行 ID)
- 物理职责:当且仅当该表既没有显式定义主键,也没有定义任何非空的唯一索引时,InnoDB 会自动生成这个单调递增的隐藏主键
2. 版本链如何形成
为了理解版本链从无到有、逐步拉长的全过程,我们用前文的 t_balance 表来进行一次推演
阶段一:初始插入状态(事务 ID = 50)
假设一个历史事务(ID 为 50)执行了插入语句,数据如下:
-
当前 B+ 树叶子节点中的行记录:
-
account_id = 1, user_name = '张三', balance = 1000.00
-
DB_TRX_ID = 50
-
DB_ROLL_PTR = NULL(因为是全新插入,没有更远的前世)
-

阶段二:事务 60 介入,修改余额为 800 元
此时并发事务 60 启动,执行:UPDATE t_balance SET balance = 800 WHERE account_id = 1,但尚未提交
底层物理变化:
-
InnoDB 拷贝出当前行的旧值(1000.00),在 Undo 页面中生成一条 UPDATE 类型的 Undo Log,假设其物理地址为 0x00AA
-
引擎直接修改 B+ 树叶子节点中的当前行,把 balance 改为 800
-
当前行的隐藏列发生更替:DB_TRX_ID 变为 60 ;DB_ROLL_PTR 写入刚才的 Undo 日志地址 0x00AA

阶段三:事务 70 再次介入,将余额修改为 600 元
紧接着,另一个并发事务 70 启动,对同一行再次执行了:UPDATE t_balance SET balance = 600 WHERE account_id = 1,同样尚未提交
底层动作:
-
InnoDB 再次拷贝出当前行的旧值(此时行的旧值是 balance = 800, 且其 roll_ptr 为 0x00AA)
-
在 Undo 页面中生成一条全新的 Undo Log,假设其物理地址为 0x00BB。这条新日志的内部,会完美保留它复制出来的旧指针 0x00AA
-
引擎修改 B+ 树叶子节点中的当前行,把 balance 改为 600
-
当前行的隐藏列再次刷新:DB_TRX_ID 变为 70 ;DB_ROLL_PTR 指向最新的 Undo 日志地址 0x00BB

经历了上述动作,主键为 1 的张三这一行记录,在 InnoDB 底层实际上已经演变成了一个单向链表结构:
从上图我们可以清晰地解构出版本链的几大核心物理特征:
-
链表头永远是最新:放在 B+ 树索引页里的那行记录,永远代表着数据在内存/磁盘中最实时的现状(此时 balance = 600,被事务 70 占据)
-
链表身由 Undo Log 构成 :随着 DB_ROLL_PTR 指针向后回溯,我们可以依次翻出
balance = 800(事务60) 以及最古老的 balance = 1000(事务50)
-
无需加锁的原因 :当一个只读事务在并发乱序环境下想要读取张三的余额时,它根本不需要去抢占这行记录的锁。它只需要顺着 DB_ROLL_PTR 构成的链表一路向后,直到找到一个它 "有资格" 看到的历史节点,并在内存中把那个节点的数值提取出来即可
六、Read View
在上一节中,我们见证了由隐藏字段和 Undo Log 构成的版本链。然而,面对链表上一条记录的多个历史快照,当前正在运行的事务究竟如何判断自己应该读取哪一个版本?
这就需要 InnoDB 存储引擎在 MVCC 中的 Read View(一致性视图)
1. 什么是 Read View?
定义
Read View 是一个事务在执行快照读(Snapshot Read)时,由 InnoDB 存储引擎在内存中创建的一个物理快照数据结构

它并不复制真实的数据库行数据,而是像一台相机,在开启的那一瞬间,给整个 MySQL 实例中所有当前正在活跃(已启动但尚未提交)的事务 ID 拍一张快照。拿着这张快照,当前事务在遍历版本链时,就能精准判断出哪些版本是已经落盘,哪些版本属于尚未提交
2. Read View 的四大核心物理字段
为了在底层进行精确切割,一个刚生成的 Read View 内部必然包含以下四个核心物理变量:
m_ids
一个核心列表,记录了在生成 Read View 的那一瞬间,整个 MySQL 系统中正处于活跃状态(即已经启动,但还没执行 COMMIT 提交)的事务 ID 集合。这是判断可见性的核心参照物
min_trx_id
一个单调边界值,代表生成 Read View 时,当前系统中所有活跃事务中最小的那个事务 ID。通常情况下,它就是 m_ids 列表中的最小值(min_trx_id = min(m_ids))
max_trx_id
很多人会想当然地认为它是活跃事务列表里的最大值,这是错误的
在物理定义上,max_trx_id 代表生成 Read View 时,系统即将分配给下一个全新事务的 ID 值。也就是说,它是 "未来边界"
creator_trx_id
记录了创建当前这个 Read View 的事务自身的事务 ID。只有执行写操作(DML)的事务才会被分配一个真正的数字 ID;如果是纯只读事务,这个值通常默认为 0
3. Read View 可见性判断原则
有了上述四个核心字段,当当前事务去读取某行记录,并拿到该记录当前版本的 DB_TRX_ID(记为 trx_id)时,InnoDB 会在底层运行一套区间判断算法
为了让这套算法变得直观易懂,我们将 min_trx_id 和 max_trx_id 视作两条物理隔离带,将整个数据世界划分为过去、现在、未来:
判断逻辑按照以下四条原则执行:
自产自销原则(命中 creator_trx_id)
-
条件:如果被读取版本的 trx_id == creator_trx_id
-
结论 :绝对可见。这说明这个历史版本就是当前事务自己亲手修改出来的,自己看自己的改动天经地义

过去世界原则(小于 min_trx_id)
-
条件:如果被读取版本的 trx_id < min_trx_id
-
结论 :绝对可见。这说明修改该版本的那个事务,在当前 Read View 诞生之前,就已经完成了提交。它已经属于尘埃落定的历史稳态数据,可以安全读取

未来世界原则(大于等于 max_trx_id)
-
条件:如果被读取版本的 trx_id <= max_trx_id
-
结论 :绝对不可见 。这说明修改该版本的事务,是在当前 Read View 生成之后才刚刚开启的。对于当前的 Read View 而言,它属于未来的未知世界,因此必须直接拒绝,顺着版本链继续向后回溯寻找更老的版本

混沌空间原则(介于 min_trx_id 与 max_trx_id 之间)
-
条件:如果被读取版本的 min_trx_id <= trx_id < max_trx_id
-
判决工作流:此时,需要拿着这个 trx_id 去跟活跃事务列表 m_ids 进行比对:
-
情况 A :如果 trx_id 存在于 m_ids 列表中。说明在当前 Read View 诞生时,修改这行数据的事务还处于 "活跃/未提交" 状态。根据隔离性,【不可见】。必须向后回溯版本链
-
情况 B :如果 trx_id 不存在于 m_ids 列表中。说明修改这行数据的事务虽然起步晚(ID 比最老的活跃事务大),但在当前 Read View 诞生前,它已经抢先一步完成了提交。既然已提交,那么对当前 Read View 而言就是【可见】的
-

七、MVCC 整体流程
MVCC(多版本并发控制)是指在同一时刻,通过维护数据的多个历史版本,来实现并发事务的 "读-写" 操作互不冲突、无需排队排他的一种控制机制
在传统的数据库并发设计中,为了防止读到未提交的脏数据,当一个事务在修改某行时,必须对该行加排他锁,此时并发的查询事务只能卡死排队等待
这种传统的 "加锁读" 会造成严重的吞吐量瓶颈。而 MVCC 的诞生实现了真正的读写不冲突
1. 快照读与当前读
在进入 MVCC 的具体执行流之前,我们必须首先在代码和底层区分两种完全不同的读取行为。并不是所有的 SELECT 都会走 MVCC 引擎
快照读
-
特征描述 :普通不加锁的 SELECT 语句
sqlSELECT * FROM t_balance WHERE account_id = 1; -
物理机制 :快照读完全依赖 MVCC 驱动。它不需要去抢占任何行锁,而是通过 Read View 顺着版本链去读取历史快照
当前读
-
特征描述 :凡是需要显式加锁的读取指令,或者涉及到数据变更的 DML 语句,都属于当前读
sqlSELECT * FROM t_balance WHERE account_id = 1 FOR SHARE; -- 共享锁 SELECT * FROM t_balance WHERE account_id = 1 FOR UPDATE; -- 排他锁 INSERT ... / UPDATE ... / DELETE ... -- 必须读取最新行进行修改 -
物理机制 :当前读完全不走 MVCC 版本链。它的执行引擎会强制穿透到 B+ 树的叶子节点,去读取当前最新、最实时的那行记录。如果那行记录正好被别的事务锁住了,当前读事务就会立刻进入阻塞锁等待状态
3. 快照读的 MVCC 工作流
现在,让我们完整拆解一个普通的 SELECT 指令在底层的 MVCC 寻址算法链路:
-
生成 Read View: 事务发起快照读。InnoDB 瞬间在内存中为该会话生成(或复用)一个 Read View,记录下当前全系统的活跃事务快照
-
定位最新记录 : 通过 B+ 树索引结构,快速定位到该行数据在磁盘或 Buffer Pool 里的聚簇索引叶子节点。此时,拿到的是链表头部的最新版本数据
-
获取事务 ID: 读取当前最新版本记录头部的隐藏字段 DB_TRX_ID
-
运行可见性算法: 拿着这个 DB_TRX_ID 与 Read View 进行判断
-
可见:说明这个版本是安全的、已提交的。直接将该版本的字段内容打包返回给客户端,当前快照读平稳结束
-
不可见:说明这个最新版本是个尚未提交的脏幻象,或者是在 "未来世界" 产生的
-
-
顺指针回溯 :若是不可见,执行引擎通过隐藏字段 DB_ROLL_PTR 提取出指向 Undo Log 的物理指针,顺着版本链滑行到上一个更老的数据版本
-
回滚 : 到达上一个历史版本后,再次提取其对应的 DB_TRX_ID,重新返回到 步骤 4 再次判断。直到在 Undo Log 版本链中找到一个符合规则、确认可见的历史节点,将其拼装成结果返回
(注:如果一整条链到了尽头还是全不可见,则证明这行数据在当前事务开启时根本不存在,返回空结果)
八、RC 与 RR 的区别
通过前面对 MVCC 的拆解,我们明白了一个核心逻辑:事务可见性的判决,完全取决于 Read View 内部的四个核心变量
然而,在 MySQL InnoDB 存储引擎中,读已提交(Read Committed,简称 RC)与 可重复读(Repeatable Read,简称 RR)这两个隔离级别的本质区别,既不在于 Undo Log 格式的不同,也不在于隐藏字段的差异,而仅仅在于快照读生成 Read View 的时机不同
1. Read View 生成时机对比
RC(读已提交)的生成规则
在 RC 隔离级别下,一个事务内部的防线是随动且短寿的
-
物理行为 :事务每执行一条普通的 SELECT(快照读)语句,执行引擎都会在底层擦除旧的视图,并重新生成一个新的 Read View
-
本质:这意味着,在同一个事务里,每一次独立的查询都在给当时当刻的全实例活跃事务做最新检测
RR(可重复读)的生成规则
在 RR 隔离级别下,一个事务内部的防线则是稳固且长寿的
-
物理行为 :当事务启动后,只有在执行第一条 SELECT (快照读)语句时,系统才会生成一个 Read View 。在这个事务随后的生命周期里,无论它再执行多少次查询,都会复用第一次生成的 Read View
-
本质:后续的所有可见性算法都是基于开启第一枪时的系统状态做判决
2. 为什么 RC 会出现不可重复读
我们用具体的事务时序流转来复盘 RC 的物理缺陷:
-
初始状态:张三的余额为 1000 元
-
事务 A 开启,执行第一次查询:SELECT balance FROM t_balance WHERE id = 1
- 此时生成 Read View 1。假设当前系统没有其他活跃事务,张三行的 DB_TRX_ID 属于历史已提交世界,算法判决可见,返回 1000 元
-
并发事务 B 启动,执行:UPDATE t_balance SET balance = 800 WHERE id = 1 并且立刻执行了 COMMIT(假设事务 B 的 ID 为 105)
-
事务 A 再次执行相同的查询 。由于是 RC 级别,底层立刻废弃 Read View 1,强行生成 Read View 2
-
此时,执行引擎去扫描版本链表头(最新值 balance = 800,DB_TRX_ID = 105)
-
在重新生成的 Read View 2 看来,事务 105 已经不在活跃列表 m_ids 中了(因为已经提交)。根据混沌空间的判决规则(不在活跃列表中即代表已提交可见),算法判断:【可见】
-
-
结果 :事务 A 顺利读到了最新的 800 元。同一事务内前后两次读取结果发生了改变,不可重复读就此诞生
3. 为什么 RR 能解决不可重复读?
同样的场景,切回 MySQL 默认的 RR 隔离级别:
-
事务 A 开启 ,执行第一次查询。生成 Read View 1(记录当前即将分配的下一个事务 ID max_trx_id = 101)。算法判决可见,返回 1000 元
-
并发事务 B 启动 (被系统分配事务 ID = 105),将余额修改为 800 元并 COMMIT
-
事务 A 再次执行相同的查询 。由于是 RR 级别,底层拒绝创建新视图,直接沿用最初的 Read View 1
-
底层判断链:
-
引擎扫描到最新行(balance = 800, DB_TRX_ID = 105)
-
拿着 105 去比较 Read View 1,发现:105 >= max_trx_id(101)
-
根据未来世界原则,大于等于 max_trx_id 的版本属于未来不可见
-
引擎顺着 DB_ROLL_PTR 物理指针向后滑行,找出上一代 Undo Log(balance = 1000)。再次判断,符合可见性规则,直接返回 1000 元
-
-
结果 :无论事务 B 提交了多少次,在旧 Read View 下,事务 A 前后读取到的数值永远是恒定的 1000 元。不可重复读被完美解决
4. RR 是否完全解决幻读
教科书通常会写:RR 级别无法解决幻读,只有 Serializable 才能解决。但这句话在 MySQL 的 InnoDB 中并不完全准确
标准结论:MySQL InnoDB 在 RR 隔离级别下,通过 MVCC 与 锁机制,已经基本上(99%)解决了幻读。但在极少数特定的业务代码,依然会发生幻读
场景一:普通的快照读 ------ 完美解决
如果事务 A 在整个生命周期内全部采用普通的 SELECT 指令。由于 RR 级别下 Read View 是不会改变,后续无论别的并发事务新 INSERT 了多少条幽灵记录,这些新记录的 DB_TRX_ID 都会大于当前 Read View 的 max_trx_id(属于未来世界)。MVCC 版本链会把它们全部过滤抹去,因此在纯快照读下,绝对不会发生幻读
场景二:加锁的当前读 ------ 完美解决
如果事务 A 一开启就执行 FOR UPDATE。此时,InnoDB 会使用间隙锁和临键锁 。 它会把物理磁盘空隙全部死死封锁锁住。此时,并发事务 B 尝试执行 INSERT 插入新用户时,其线程会直接卡死在锁等待上。写被成功阻断,当前读下的幻读也被完美防御。
既然两种读取方式都能防御,幻读究竟在什么时候才会发生?
它发生于在同一个事务里,混用了 "快照读" 与 "当前读/写操作" 的链路中
sql
-- 步骤 1:事务 A 开启,执行纯快照读,查询 account_id = 99 的用户。
SELECT * FROM t_balance WHERE account_id = 99;
-- 结果:此时表中无此人,返回【空结果】
-- 步骤 2:并发事务 B 瞬间开启,插足进来,悄悄插入了 account_id = 99 的行,并立刻 COMMIT 提交
-- 步骤 3:事务 A 莫名其妙地执行了一条全表更新或者针对该行的 UPDATE(当前读操作)
UPDATE t_balance SET balance = 500 WHERE account_id = 99;
-- 因为 UPDATE 属于当前读,它会穿透 MVCC 看到事务 B 刚刚提交的真实最新行
-- 于是,这条 UPDATE 居然神奇地执行成功了,影响行数显式为:Affected rows: 1
-- 更致命的是:随着 UPDATE 的成功执行,这一行记录头部的隐藏字段 DB_TRX_ID 被强制修改为了当前事务 A 的 ID!
-- 步骤 4:事务 A 再次执行快照读查询该行
SELECT * FROM t_balance WHERE account_id = 99;
原本在步骤 1 中完全不存在的 account_id = 99 的行,居然在步骤 4 里面凭空冒了出来!
在步骤 4 执行可见性判断时,执行引擎去检查这一行的 DB_TRX_ID,发现它正好等于当前事务自身的 creator_trx_id(因为步骤 3 里自己刚刚改过它)。根据自产自销原则,当前事务自己改的数据绝对可见。于是,MVCC 被攻破,幽灵行彻底显形
这就是在 MySQL InnoDB 中,RR 级别无法完全利用 MVCC 屏蔽幻读的唯一漏洞。因此,在工程实践中,我们必须遵循一条原则:在一个事务的生命周期内,应当保持读取风格的纯粹,切勿将快照读与当前读盲目混用
总结
综上所述,我们深入分析了 MySQL 事务隔离背后的底层实现机制,从脏读、不可重复读、幻读等并发问题出发,理解了不同事务隔离级别产生的原因以及它们之间的区别。随后,我们又进一步学习了 Undo Log、版本链、Read View 以及 MVCC 等核心技术,并将它们串联起来,完整理解了事务一致性读的实现流程
至此,我们已经能够回答事务中的许多经典问题:为什么事务可以回滚、为什么普通 SELECT 不需要加锁也能读取历史数据,以及为什么 Read Committed 和 Repeatable Read 会表现出不同的隔离效果
不过,在实际开发中,除了事务之外,MySQL 还提供了一种十分重要的数据库对象------视图(View)。视图本身并不存储数据,却能够像一张普通的数据表一样参与查询,它不仅能够简化复杂 SQL,还能够提高数据安全性和代码复用性
因此,在下一篇中,我们将正式学习 MySQL 视图的相关内容,包括视图的创建、修改、更新特性以及实际应用场景,进一步完善 MySQL 数据库对象体系
