MySQL 的 MVCC (Multi-Version Concurrency Control,多版本并发控制) 是 InnoDB 存储引擎实现高并发事务隔离的核心机制。它的核心目标是:让读操作不加锁,读写互不阻塞,同时保证事务的隔离性(特别是读已提交 RC 和可重复读 RR 级别)。
为了让你彻底理解,我将通过核心组件图解 、版本链形成过程 、ReadView 可见性判断 以及不同隔离级别的差异四个部分进行详细拆解。
一、MVCC 的三大核心组件(图解基础)
MVCC 的实现依赖于三个隐形或显性的组件,它们共同构成了一个"时间机器",让每个事务能看到不同时间点的数据快照。
1. 隐藏字段 (Hidden Columns)
InnoDB 会在每一行记录中自动添加三个隐藏字段(你建表时看不到,但底层一定有):
表格
| 字段名 | 含义 | 作用 |
|---|---|---|
| DB_TRX_ID | 最近修改该行数据的事务 ID | 记录是谁最后一次改动了这行数据 |
| DB_ROLL_PTR | 回滚指针 (Rollback Pointer) | 指向该行数据的上一个版本在 Undo Log 中的地址 |
| DB_ROW_ID | 隐藏行 ID | 如果没有主键,用它做聚簇索引键(与 MVCC 逻辑关系不大,略过) |
2. Undo Log (版本链)
当数据被修改时,旧数据不会被直接覆盖,而是被写入 Undo Log。
- 新数据写在当前行。
- 旧数据通过
DB_ROLL_PTR指针串联起来,形成一条链表。 - 这条链表被称为 版本链 (Version Chain)。
3. ReadView (读视图)
这是 MVCC 的"灵魂"。当事务进行快照读(普通 SELECT)时,会生成一个 ReadView。
- 它记录了当前系统中活跃事务列表(即那些已经开始但还没提交的事务 ID)。
- 事务利用 ReadView 中的规则,去版本链上寻找"对自己可见"的那个数据版本。
二、场景演示:版本链是如何形成的?
假设有一行数据 id=1, name='A'。我们模拟三个事务的操作,看看版本链怎么变。
初始状态:
plain
[当前行]
name: 'A'
DB_TRX_ID: 10 (创建者)
DB_ROLL_PTR: null
步骤 1:事务 T1 (ID=20) 修改数据
事务 T1 将 name 改为 'B'。
- 把旧值
'A'写入 Undo Log。 - 更新当前行:
name='B',DB_TRX_ID=20。 DB_ROLL_PTR指向 Undo Log 中的'A'。
此时版本链:
plain
[当前行] (name='B', trx_id=20)
⬇️ (DB_ROLL_PTR)
[Undo Log 1] (name='A', trx_id=10)
步骤 2:事务 T2 (ID=30) 修改数据
事务 T2 将 name 改为 'C'。
- 把当前旧值
'B'写入 Undo Log。 - 更新当前行:
name='C',DB_TRX_ID=30。 DB_ROLL_PTR指向刚才生成的 Undo Log ('B')。
此时版本链:
plain
[当前行] (name='C', trx_id=30)
⬇️
[Undo Log 2] (name='B', trx_id=20)
⬇️
[Undo Log 1] (name='A', trx_id=10)
关键点:无论数据被修改多少次,所有历史版本都通过指针串在一起,最新的数据在最前面,最老的数据在最后面。
三、核心算法:ReadView 如何判断数据可见性?
当一个事务(比如事务 T4, ID=40)执行 SELECT 时,它会拿着自己的 ReadView 去遍历上面的版本链。
1. ReadView 的结构
主要包含两个关键信息(简化版):
- m_ids : 当前活跃事务 ID 列表(即:开始但未提交的事务)。例如
[20, 30]。 - min_trx_id: 活跃事务中最小的 ID (20)。
- max_trx_id: 生成 ReadView 时系统分配给下一个事务的 ID (假设为 40)。
2. 可见性判断规则 (核心逻辑)
对于版本链上的某一个版本,其事务 ID 为 trx_id:
- 若 **
**trx_id**< ****min_trx_id**:- 说明该版本是由已经提交的老事务创建的。
- ✅ 可见。
- 若 **
**trx_id**>= ****max_trx_id**:- 说明该版本是由将来才启动的事务创建的(不可能发生,除非逻辑错误)或者是在当前事务启动后才启动的事务。
- ❌ 不可见。
- 若 **
**min_trx_id**<=**trx_id**< ****max_trx_id**:- 说明该事务在 ReadView 生成时是"活跃"的。
- 检查
trx_id是否在m_ids列表中:- 在列表中 :说明该事务还没提交。❌ 不可见(继续沿着指针找下一个旧版本)。
- 不在列表中 :说明该事务在 ReadView 生成前已经提交了。✅ 可见。
3. 图解查找过程
假设事务 T4 (ID=40) 发起查询,此时活跃事务列表 m_ids = [20, 30] (假设 T2 还没提交,T1 也没提交)。
- 检查当前行 (trx_id=30) :
- 30 在
m_ids中吗?在。 - 结论:T2 未提交,不可见。👉 沿指针向下找。
- 30 在
- 检查第二个版本 (trx_id=20) :
- 20 在
m_ids中吗?在。 - 结论:T1 未提交,不可见。👉 沿指针向下找。
- 20 在
- 检查第三个版本 (trx_id=10) :
- 10 <
min_trx_id(20)。 - 结论:老事务已提交,可见!
- 返回结果:
**name='A'**。
- 10 <
即使当前数据库里最新的数据是 'C',只要修改它的事务没提交,或者在特定隔离级别下不符合可见性规则,你就只能看到 'A'。这就是快照读。
四、关键差异:RC (读已提交) vs RR (可重复读)
MVCC 在这两个隔离级别下的行为差异,完全取决于 ReadView 生成的时机。
表格
| 特性 | 读已提交 (RC) | 可重复读 (RR)****(MySQL 默认) |
|---|---|---|
| ReadView 生成时机 | 每次 执行 SELECT 时都会重新生成一个新的 ReadView。 |
只在第一次 执行 SELECT 时生成,后续复用同一个 ReadView。 |
| 现象描述 | 能读到其他事务刚刚提交的最新数据。 | 无论其他事务提交多少次,我看到的都是事务开始时的数据快照。 |
| 解决幻读 | 不能解决幻读(配合间隙锁也只能部分解决)。 | 结合间隙锁 (Next-Key Lock),基本解决了幻读。 |
场景对比图解
假设数据初始为 v1。
- 事务 A (ID=100) 启动。
- 事务 B (ID=101) 启动,修改数据为
v2并提交。 - 事务 C (ID=102) 启动,修改数据为
v3并提交。
如果是 RC 级别:
- 事务 A 执行
SELECT-> 生成 ReadView_1 (看到v1)。 - 事务 B 提交
v2。 - 事务 A 再次
SELECT-> 生成新的 ReadView_2 。- 此时 B 已提交,不在活跃列表。
- 结果:看到
v2。(数据变了)
- 事务 C 提交
v3。 - 事务 A 再次
SELECT-> 生成新的 ReadView_3 。- 结果:看到
v3。(数据又变了)
- 结果:看到
如果是 RR 级别:
- 事务 A 执行
SELECT-> 生成 ReadView_1 (看到v1)。【锁定快照】 - 事务 B 提交
v2。 - 事务 A 再次
SELECT-> 复用 ReadView_1 。- 虽然 B 提交了,但在 ReadView_1 生成时,B 是活跃的(或未开始的,视具体时序),根据规则判定不可见(或者因为快照锁定)。
- 结果:依然看到
v1。(数据不变)
- 事务 C 提交
v3。 - 事务 A 再次
SELECT-> 复用 ReadView_1 。- 结果:依然看到
v1。(实现可重复读)
- 结果:依然看到
五、总结与注意事项
- 当前读 (Current Read) :
SELECT ... FOR UPDATE,SELECT ... LOCK IN SHARE MODE,UPDATE,DELETE,INSERT。- 这些操作不使用 MVCC,它们读取的是最新版本的数据,并且会加锁(排他锁或共享锁),确保读到的是最新的且防止并发修改。
- 快照读 (Snapshot Read) :
- 普通的
SELECT。 - 使用 MVCC,不加锁,效率高,读取的是历史版本。
- 普通的
- 长事务的危害 :
- 如果一个长事务一直不提交,它生成的 ReadView 会一直保留,导致它需要的旧版本数据无法被 Purge 线程清理。
- 这会导致 Undo Log 无限膨胀,占用大量磁盘空间,甚至拖慢数据库性能。
- 幻读问题 :
- MVCC 主要解决的是行级的读写冲突和不可重复读。
- 对于范围查询 导致的幻读(插入新行),InnoDB 在 RR 级别下还需要配合 间隙锁 (Gap Lock) 或 临键锁 (Next-Key Lock) 来彻底解决。
通过这套机制,MySQL InnoDB 在保证数据一致性的前提下,极大地提升了并发读取的性能,实现了"读写不阻塞"。