写在前面:如果说锁是数据库的"交警",那么MVCC就是数据库的"时光机"。它让不同的事务可以看到不同时间点的数据快照,从而在保证一致性的同时实现高并发。这就是InnoDB能同时支持事务和高并发的秘密。
开篇引入:同一个世界,不同的视角
想象一个场景:你在看一部电影,同时有人在编辑这部电影。
传统锁机制的做法是:你看电影时,别人不能动剪刀。这样安全,但效率低。
MVCC的做法是:给你一个"电影胶片副本",你在看你的版本时,别人可以编辑原版。最终再把大家的修改合并。这既保证了你看电影时不会被打扰,又允许了并发编辑。
MySQL的InnoDB引擎就是用这种方式,在REPEATABLE READ隔离级别下,既保证了可重复读,又支持高并发。
MVCC是什么?
MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制机制。它通过保存数据的多个版本来实现非阻塞读操作。
核心思想:
- 每次修改数据时,不是直接覆盖原数据,而是创建一个新版本
- 读操作不需要加锁,只读取符合条件的数据版本
- 不同事务可能看到不同版本的数据
时间线 →
T1时刻 T2时刻 T3时刻 T4时刻
│ │ │ │
▼ ▼ ▼ ▼
[v1] [v2] [v2] [v3]
↑ ↑
Session A Session B
(读v2) (读v3)
MVCC vs 传统锁对比
| 特性 | 传统锁机制 | MVCC |
|---|---|---|
| 读操作 | 需要获取锁 | 非阻塞读取 |
| 写操作 | 需要等待读锁释放 | 写锁+版本控制 |
| 并发度 | 低 | 高 |
| 复杂度 | 低 | 高 |
| 一致性 | 通过锁保证 | 通过快照+版本链保证 |
InnoDB的MVCC实现
关键概念
InnoDB在每行数据上额外存储了两个隐藏字段:
- DB_TRX_ID:最近一次修改该行的事务ID
- DB_ROLL_PTR:回滚指针,指向Undo Log中的旧版本
数据行的隐藏字段
-- InnoDB 行结构(概念上)
+--------+----------------------+----------------------+---------+
| 主键值 | DB_TRX_ID (6字节) | DB_ROLL_PTR (7字节) | 其他列 |
+--------+----------------------+----------------------+---------+
| 1 | 12345 | 指向Undo | name... |
+--------+----------------------+----------------------+---------+
Undo Log:版本链的链表
每条记录都有一个回滚指针,指向Undo Log中的旧版本,形成一条版本链:
当前记录 (id=1, name='Tom', tx_id=100)
│
│ DB_ROLL_PTR
↓
Undo Log记录 ← tx_id=99 (修改前name='John')
│
│ DB_ROLL_PTR
↓
Undo Log记录 ← tx_id=80 (修改前name='Alice')
│
▼
NULL
Read View:快照的眼睛
当事务开始读取数据时,MySQL会创建一个Read View(读取视图),这个视图包含了:
- m_ids:当前活跃事务的ID列表
- min_trx_id:最小活跃事务ID
- max_trx_id:创建Read View时最大事务ID+1
- creator_trx_id:当前事务ID
Read View的判断规则
读取数据时,通过以下规则判断是否可见:
如果 row.trx_id < min_trx_id:
可见(事务已提交)
如果 row.trx_id >= max_trx_id:
不可见(事务在Read View创建后开始)
如果 row.trx_id 在 m_ids 中:
不可见(事务还在进行中)
否则:
可见(事务已提交)
图解Read View
Read View 创建时刻
↓
活跃事务:[T1, T3, T5] ← m_ids
最小事务ID:T1 ← min_trx_id
最大事务ID:T7 ← max_trx_id (T6+1)
当前事务ID:T4 ← creator_trx_id
某记录的 trx_id = T2:
T2 < T1?不 → 进入下一步
T2 在 [T1,T3,T5]?否 → 可见 ✓
某记录的 trx_id = T3:
T3 < T1?不
T3 在 [T1,T3,T5]?是 → 不可见 ✗
快照读 vs 当前读
这是理解MVCC的关键点。
快照读(Snapshot Read)
普通的SELECT语句就是快照读,它读取的是数据在某个时间点的快照。
-- 快照读:读取开始时的数据状态
SELECT * FROM users WHERE id = 1;
-- 返回事务开始时的数据
当前读(Current Read)
带有锁的读取是当前读,它读取的是最新版本的数据。
-- 当前读:读取最新数据,并加锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 当前读:读取最新数据,并加共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 写入操作默认都是当前读
INSERT INTO users VALUES (1, 'Tom');
UPDATE users SET name = 'Jerry' WHERE id = 1;
DELETE FROM users WHERE id = 1;
为什么区分这两种读?
Session A Session B
──────── ────────
BEGIN;
SELECT * FROM orders BEGIN;
WHERE status = 'pending'; UPDATE orders SET status = 'completed'
WHERE id = 1;
COMMIT;
SELECT * FROM orders
WHERE status = 'pending';
-- A 还能看到 pending 状态的订单
-- 因为它是快照读
MVCC在REPEATABLE READ下的行为
REPEATABLE READ是MySQL InnoDB的默认隔离级别。
核心规则
在同一事务中,多次执行相同的SELECT,结果是一致的。
-- Session A
START TRANSACTION;
-- 第一次读取
SELECT * FROM orders WHERE amount > 1000;
-- 结果:[order_1: 1500, order_2: 2000]
-- Session B(在A的两次读取之间)
START TRANSACTION;
INSERT INTO orders VALUES (3, 1, 3000);
COMMIT;
-- 第二次读取(REPEATABLE READ)
SELECT * FROM orders WHERE amount > 1000;
-- 结果:[order_1: 1500, order_2: 2000]
-- 与第一次完全一致!B的插入对A不可见
Read View的创建时机
在REPEATABLE READ下,Read View在事务的第一次读取时创建。
事务开始
↓
第一次读取数据
↓
创建Read View(捕获当前活跃事务)
↓
后续所有读取都使用这个Read View
↓
事务结束
MVCC在READ COMMITTED下的行为
READ COMMITTED每次读取都会创建新的Read View。
-- Session A
START TRANSACTION;
-- 第一次读取
SELECT * FROM orders WHERE amount > 1000;
-- 创建Read View1: [T1]
-- 结果:[order_1: 1500]
-- Session B
INSERT INTO orders VALUES (2, 1, 2000);
COMMIT;
-- Session A 第二次读取
SELECT * FROM orders WHERE amount > 1000;
-- 创建Read View2: [T1](已更新,因为T2已提交)
-- 结果:[order_1: 1500, order_2: 2000]
-- 两次读取结果不同!
对比表
| 隔离级别 | Read View创建时机 | 重复读取结果 |
|---|---|---|
| REPEATABLE READ | 事务第一次读取 | 一致 |
| READ COMMITTED | 每次读取 | 可能不同 |
MVCC解决幻读问题
幻读(Phantom Read)是指同一事务中,两次查询返回的记录数不一样。
没有MVCC的情况(假想)
Session A
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 结果:5条
Session B
INSERT INTO orders VALUES (6, 1, 100);
COMMIT;
Session A
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 结果:6条!产生了"幻觉"
InnoDB如何解决
InnoDB使用Next-Key Lock和MVCC组合来解决幻读:
- 写操作使用Next-Key Lock:锁定查询范围内的所有记录和间隙
- 读操作使用MVCC:通过Read View确保一致性
-- Session A
START TRANSACTION;
SELECT * FROM orders WHERE user_id = 1 FOR UPDATE;
-- 锁定 user_id=1 的所有记录及间隙
-- Session B
INSERT INTO orders VALUES (6, 1, 100);
-- 被阻塞!因为间隙被锁定
Session A
INSERT INTO orders VALUES (6, 1, 100);
-- A 可以插入,因为是自己锁定的间隙
MVCC的代价
MVCC带来了高并发,但也有代价。
1. 存储空间增加
每条记录都需要额外存储事务ID和回滚指针,Undo Log也会占用空间。
-- 查看Undo Log使用
SHOW ENGINE INNODB STATUS;
-- 关注 Undo tablespaces 相关信息
2. 清理成本
旧版本的Undo Log需要被清理,否则会无限增长。
清理过程:
1. purge线程定期检查
2. 判断Undo Log是否不再需要
3. 释放空间
条件:没有活跃事务需要读取该版本
3. 查询成本
每次读取都需要遍历版本链,找到可见的版本。
读取流程:
1. 检查当前记录是否可见
2. 不可见则跟随回滚指针
3. 重复直到找到可见版本或链尾
实战:理解MVCC的表现
实验1:验证REPEATABLE READ
-- Terminal 1
START TRANSACTION;
SELECT * FROM users WHERE id = 1;
-- 结果:name='Alice'
-- Terminal 2
START TRANSACTION;
UPDATE users SET name = 'Bob' WHERE id = 1;
COMMIT;
-- Terminal 1
SELECT * FROM users WHERE id = 1;
-- 结果:name='Alice'(未变化!)
COMMIT;
-- 结果:name='Bob'(提交后可见新值)
实验2:验证READ COMMITTED
-- Terminal 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM users WHERE id = 1;
-- 结果:name='Alice'
-- Terminal 2
START TRANSACTION;
UPDATE users SET name = 'Charlie' WHERE id = 1;
COMMIT;
-- Terminal 1
SELECT * FROM users WHERE id = 1;
-- 结果:name='Charlie'(变化了!)
实验3:验证当前读被阻塞
-- Terminal 1
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 锁定 id=1 的记录
-- Terminal 2
START TRANSACTION;
SELECT * FROM users WHERE id = 1;
-- 这个读不会阻塞(快照读)
-- 但如果用 FOR UPDATE 或 LOCK IN SHARE MODE,会阻塞
MVCC配置与监控
相关配置参数
-- 查看相关配置
SHOW VARIABLES LIKE 'innodb_undo%';
-- innodb_undo_directory:Undo Log存储目录
-- innodb_undo_tablespaces:Undo Log表空间数量
-- innodb_undo_log_truncate:是否自动截断Undo Log
监控MVCC相关指标
-- 查看事务状态
SELECT
trx_id,
trx_state,
trx_started,
trx_rows_locked,
trx_rows_modified
FROM information_schema.INNODB_TRX;
-- 查看锁等待
SHOW ENGINE INNODB STATUS;
-- 关注 Lock wait time 和 各事务锁信息
小结
MVCC是InnoDB实现高性能事务的关键技术。
核心要点:
- MVCC核心思想:为每次修改保存新版本,读操作非阻塞
- 两个隐藏字段:事务ID + 回滚指针
- Undo Log版本链:通过回滚指针串联历史版本
- Read View:判断哪个版本对当前事务可见
- 快照读 vs 当前读:普通SELECT是快照读,FOR UPDATE/LOCK是当前读
- REPEATABLE READ:事务开始时创建Read View
- READ COMMITTED:每次读取创建新的Read View
实践建议:
- 理解快照读的"时间点"概念
- 注意当前读会阻塞
- 长事务会导致Undo Log膨胀
- 合理设置Undo Log大小
- 监控活跃事务和锁等待
延伸阅读:
- 《高性能MySQL(第4版)》第1章 - MVCC部分
- MySQL 8.0 Reference Manual - InnoDB Multi-Versioning
- InnoDB Row Format
- Understanding the InnoDB Transaction Model