一、开篇故事:图书馆的借阅困境 📚
想象你在一个魔法图书馆工作:
场景1:没有MVCC的图书馆
小明:"我要看《MySQL指南》。"
管理员:"给你!" (把书锁在小明桌上🔒)
小红:"我也要看《MySQL指南》。"
管理员:"等着吧,小明还没看完..." 😴
小刚:"我只是想瞄一眼目录而已!"
管理员:"不行,被锁了,你也得等!" 💢
结果:大家排队等一本书,效率极低!
场景2:有MVCC的魔法图书馆
小明:"我要看《MySQL指南》。"
管理员:"给你!" (复制一个快照给小明📖)
小红:"我也要看《MySQL指南》。"
管理员:"给你!" (复制另一个快照给小红📖)
小刚:"我想改书的内容。"
管理员:"你改新版本,不影响别人看旧版本!" ✨
结果:大家都能同时看书,互不影响!✅
这就是**MVCC(Multi-Version Concurrency Control,多版本并发控制)**的魔力!
二、什么是MVCC?🤔
2.1 定义
MVCC是一种并发控制机制,通过保存数据的多个版本,让读操作不加锁,写操作也不阻塞读操作,从而实现高并发。
2.2 核心思想
传统方式:读写互斥
读操作 ←→ 写操作(互相阻塞)
MVCC方式:读写并发
读操作 ← 读旧版本
写操作 → 写新版本
(互不影响!)
2.3 生活类比
传统锁机制: 像一个单间厕所🚽
- 有人在里面,其他人必须等待
- 即使只是洗手也得等
MVCC机制: 像多个平行宇宙🌌
- 每个人都有自己的宇宙版本
- 你看到的是你进入时的宇宙快照
- 别人修改不影响你看到的版本
三、MVCC的核心组件 🎯
3.1 三大核心
MVCC = 隐藏字段 + Undo Log + Read View
↓ ↓ ↓
身份证 历史记录 时光眼镜
3.2 组件详解
| 组件 | 作用 | 生活类比 |
|---|---|---|
| 隐藏字段 | 记录事务ID和回滚指针 | 身份证(记录你的ID) |
| Undo Log | 存储历史版本 | 历史记录本(记录所有修改) |
| Read View | 判断版本可见性 | 时光眼镜(决定你能看到哪个版本) |
四、隐藏字段:数据行的身份证 🪪
4.1 三个隐藏列
每行数据除了你定义的字段,还有3个隐藏字段:
CREATE TABLE users (
id INT,
name VARCHAR(50),
age INT,
-- 下面是隐藏字段,你看不到但它们存在!
-- DB_TRX_ID (6字节):最后修改这行数据的事务ID
-- DB_ROLL_PTR (7字节):回滚指针,指向Undo Log
-- DB_ROW_ID (6字节):隐藏主键(如果没有主键才有)
);
4.2 字段说明
1. DB_TRX_ID(事务ID)
记录最后一次修改此行的事务ID
就像商品上的"生产日期"
例如:
张三的年龄 = 25,DB_TRX_ID = 100(事务100修改的)
2. DB_ROLL_PTR(回滚指针)
指向这条记录的上一个版本(在Undo Log中)
就像"历史版本链接"
例如:
当前版本 → 上一个版本 → 上上个版本 → ...
3. DB_ROW_ID(隐藏主键)
如果表没有主键和唯一索引,InnoDB自动生成
一般表都有主键,所以这个字段通常用不到
4.3 图解
假设有一行数据:id=1, name='张三', age=25
磁盘上实际存储:
┌────┬──────┬─────┬─────────────┬──────────────┬────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR │ DB_ROW_ID │
├────┼──────┼─────┼─────────────┼──────────────┼────────────┤
│ 1 │ 张三 │ 25 │ 100 │ 0x7F8B3C... │ NULL │
└────┴──────┴─────┴─────────────┴──────────────┴────────────┘
↑ ↑ ↑
用户字段 事务ID 回滚指针
五、Undo Log:历史版本仓库 📜
5.1 什么是Undo Log?
Undo Log是InnoDB存储的历史版本数据,用于:
- 事务回滚:撤销未提交的修改
- MVCC读取:读取历史版本
5.2 版本链
每次更新都会在Undo Log中创建一个历史版本,形成版本链。
示例:张三的年龄被多次修改
时刻1:事务100插入数据
id=1, name='张三', age=25, DB_TRX_ID=100
时刻2:事务200修改年龄为26
当前版本:age=26, DB_TRX_ID=200
↓ (回滚指针)
历史版本:age=25, DB_TRX_ID=100
时刻3:事务300修改年龄为27
当前版本:age=27, DB_TRX_ID=300
↓
历史版本:age=26, DB_TRX_ID=200
↓
历史版本:age=25, DB_TRX_ID=100
5.3 图解版本链
最新版本(磁盘)
┌─────────────────────┐
│ age=27 │
│ DB_TRX_ID=300 │──────┐
│ DB_ROLL_PTR ────────┼──┐ │
└─────────────────────┘ │ │
↓ │
Undo Log │ │
┌─────────────────────┐ │ │
│ age=26 │ ←┘ │
│ DB_TRX_ID=200 │──┐ │ 版本链
│ DB_ROLL_PTR ────────┼──┼───┼→ 通过回滚指针
└─────────────────────┘ │ │ 连接起来
↓ │
┌─────────────────────┐ │ │
│ age=25 │ ←┘ │
│ DB_TRX_ID=100 │ │
│ DB_ROLL_PTR = NULL │ ←────┘
└─────────────────────┘
↑
最早版本
5.4 生活类比
版本链就像Git历史:
git log
commit 300 (HEAD) age=27 ← 最新版本
commit 200 age=26 ← 中间版本
commit 100 age=25 ← 最早版本
你可以checkout到任何历史版本!
六、Read View:时光眼镜 👓
6.1 什么是Read View?
**Read View(读视图)**是事务开始时创建的快照,决定了当前事务能看到哪些版本的数据。
6.2 Read View的四个重要属性
class ReadView {
long m_low_limit_id; // 当前系统中最大事务ID + 1
long m_up_limit_id; // 当前系统中最小活跃事务ID
long[] m_ids; // 创建ReadView时的活跃事务ID列表
long m_creator_trx_id; // 创建ReadView的事务ID
}
通俗解释:
| 属性 | 含义 | 生活类比 |
|---|---|---|
| m_low_limit_id | 未来的事务ID | 明天出生的人,你看不到 |
| m_up_limit_id | 最早的活跃事务ID | 最早的还活着的人 |
| m_ids | 所有活跃事务ID | 正在工作的人列表 |
| m_creator_trx_id | 自己的事务ID | 你自己 |
6.3 可见性判断规则
核心问题: 数据的DB_TRX_ID(版本号)是否对当前事务可见?
boolean isVisible(long trx_id) {
// 规则1:如果是自己修改的,当然可见
if (trx_id == m_creator_trx_id) {
return true;
}
// 规则2:如果版本在ReadView创建前就提交了,可见
if (trx_id < m_up_limit_id) {
return true;
}
// 规则3:如果版本在ReadView创建后才开始,不可见
if (trx_id >= m_low_limit_id) {
return false;
}
// 规则4:如果版本在活跃事务列表中,不可见(未提交)
if (m_ids.contains(trx_id)) {
return false;
}
// 其他情况:可见
return true;
}
6.4 图解可见性
时间线:
← 过去 未来 →
─────────────────────────────────────────→
100 200 | 300 400 | 500 600
↑ ↑
ReadView创建 当前查询
m_up_limit_id = 300(最小活跃事务)
m_low_limit_id = 501(最大事务ID + 1)
m_ids = [300, 400](活跃事务列表)
判断:
trx_id=100:< m_up_limit_id → ✅可见(已提交)
trx_id=200:< m_up_limit_id → ✅可见(已提交)
trx_id=300:在m_ids中 → ❌不可见(未提交)
trx_id=400:在m_ids中 → ❌不可见(未提交)
trx_id=500:< m_low_limit_id 且不在m_ids → ✅可见(已提交)
trx_id=600:>= m_low_limit_id → ❌不可见(未来的事务)
七、MVCC读取流程:完整版 🎬
7.1 场景设置
-- 初始数据
INSERT INTO users (id, name, age) VALUES (1, '张三', 25);
-- DB_TRX_ID = 100
-- 事务200开始,修改年龄
BEGIN; -- trx_id = 200
UPDATE users SET age = 26 WHERE id = 1;
-- 还未提交!
-- 事务300开始,查询数据
BEGIN; -- trx_id = 300
SELECT * FROM users WHERE id = 1; -- 能看到什么?
7.2 Read View创建
事务300执行SELECT时创建Read View:
m_creator_trx_id = 300(自己)
m_up_limit_id = 200(最小活跃事务)
m_low_limit_id = 301(300 + 1)
m_ids = [200, 300](活跃事务列表)
7.3 查询流程
1. 从磁盘读取最新版本
┌─────────────────────┐
│ age=26 │
│ DB_TRX_ID=200 │ ← 事务200修改的
└─────────────────────┘
2. 判断是否可见
trx_id=200 在 m_ids 中 → ❌不可见(事务200未提交)
3. 通过回滚指针找上一个版本
┌─────────────────────┐
│ age=25 │
│ DB_TRX_ID=100 │ ← 事务100修改的
└─────────────────────┘
4. 判断是否可见
trx_id=100 < m_up_limit_id=200 → ✅可见(已提交)
5. 返回结果
age = 25
7.4 流程图
开始SELECT
↓
创建Read View
↓
读取最新版本
↓
┌─────────────────┐
│ 版本可见? │
└─────┬───────┬───┘
↓ ↓
是 否
↓ ↓
返回数据 通过回滚指针找上一版本
↓
(回到判断)
八、RC与RR的Read View差异 🔍
8.1 READ COMMITTED(RC)
每次查询都创建新的Read View
-- 事务A
BEGIN; -- trx_id = 100
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;
-- 事务B(RC隔离级别)
BEGIN; -- trx_id = 200
-- 第1次查询,创建ReadView1
SELECT age FROM users WHERE id = 1;
-- 结果:25(事务A未提交)
-- 事务A提交
-- 第2次查询,创建ReadView2(新的!)
SELECT age FROM users WHERE id = 1;
-- 结果:26(事务A已提交,新ReadView能看到)
COMMIT;
特点: 能读到别人已提交的修改(不可重复读)
8.2 REPEATABLE READ(RR)
事务开始时创建Read View,之后一直复用
-- 事务A
BEGIN; -- trx_id = 100
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;
-- 事务B(RR隔离级别)
BEGIN; -- trx_id = 200
-- 第1次查询,创建ReadView
SELECT age FROM users WHERE id = 1;
-- 结果:25
-- 事务A提交
-- 第2次查询,复用之前的ReadView
SELECT age FROM users WHERE id = 1;
-- 结果:25(还是25!因为ReadView没变)
COMMIT;
特点: 读取的是事务开始时的快照(可重复读)
8.3 对比表
| 隔离级别 | Read View创建时机 | 能否看到别人提交的修改 | 特点 |
|---|---|---|---|
| RC | 每次查询创建 | ✅ 能 | 不可重复读 |
| RR | 事务开始创建一次 | ❌ 不能 | 可重复读 |
九、MVCC解决了什么问题?💡
9.1 解决的问题
1. 脏读(Dirty Read)
✅ MVCC解决方案:
未提交的修改在m_ids中,不可见
2. 不可重复读(Non-Repeatable Read)
✅ RR隔离级别下解决:
ReadView不变,看到的始终是快照
❌ RC隔离级别下无法解决:
每次查询创建新ReadView
3. 幻读(Phantom Read)
⚠️ 部分解决:
快照读(普通SELECT):通过MVCC解决
当前读(SELECT FOR UPDATE):通过Next-Key Lock解决
9.2 性能优势
传统锁机制:
读操作需要加锁 → 阻塞写操作
写操作需要加锁 → 阻塞读操作
并发度:低 ❌
MVCC机制:
读操作不加锁 → 不阻塞写操作
写操作不阻塞读 → 只阻塞其他写
并发度:高 ✅
性能提升:
- 读操作:0锁开销
- 并发度:大幅提升
- 响应时间:显著降低
十、快照读 vs 当前读 📸
10.1 快照读(Snapshot Read)
定义: 读取的是历史快照版本(通过MVCC)
SQL语句:
SELECT * FROM users WHERE id = 1;
特点:
- ✅ 不加锁
- ✅ 读取Read View能看到的版本
- ✅ 高并发
10.2 当前读(Current Read)
定义: 读取的是最新版本(不走MVCC)
SQL语句:
-- 加共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- DML语句都是当前读
INSERT / UPDATE / DELETE
特点:
- ⚠️ 会加锁
- ⚠️ 读取最新版本
- ⚠️ 阻塞其他写操作
10.3 对比
| 类型 | 是否加锁 | 读取版本 | 并发性 | 使用场景 |
|---|---|---|---|---|
| 快照读 | ❌ 否 | 历史快照 | 高 | 普通查询 |
| 当前读 | ✅ 是 | 最新版本 | 低 | 需要最新数据或修改 |
十一、实战案例:转账场景 💰
案例1:快照读(可能不一致)
-- 账户表
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance DECIMAL(10,2)
);
INSERT INTO accounts VALUES (1, 1000), (2, 1000);
-- 事务A:转账
BEGIN; -- trx_id = 100
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 事务B:查询总额(RR隔离级别)
BEGIN; -- trx_id = 200
-- 第1次查询
SELECT SUM(balance) FROM accounts;
-- 结果:2000(事务A未提交,看到的是快照)
-- 事务A提交
-- 第2次查询
SELECT SUM(balance) FROM accounts;
-- 结果:2000(还是快照,虽然事务A已提交)
COMMIT;
问题: 快照读可能看不到最新数据
案例2:当前读(保证一致)
-- 事务C:查询总额(使用当前读)
BEGIN; -- trx_id = 300
SELECT SUM(balance) FROM accounts FOR UPDATE;
-- 结果:2000(当前最新值)
COMMIT;
十二、MVCC的局限性 ⚠️
1. 无法完全解决幻读
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 20; -- 查到3条
-- 事务B
INSERT INTO users VALUES (4, '李四', 25);
COMMIT;
-- 事务A
SELECT * FROM users WHERE age > 20; -- 快照读,还是3条 ✅
-- 但是!
UPDATE users SET name = '修改' WHERE age > 20; -- 当前读,4条都被修改 ⚠️
SELECT * FROM users WHERE age > 20; -- 现在查到4条了!(幻读)
2. Undo Log占用空间
大量长事务 → Undo Log堆积 → 磁盘空间占用 → 性能下降
解决:
- 及时提交事务
- 避免大事务
- 定期清理
3. 回滚链过长
频繁修改 → 版本链很长 → 查询需要遍历很多版本 → 性能下降
解决:
- 批量更新
- 减少修改频率
十三、面试高频问题 🎤
Q1: 什么是MVCC?
答: MVCC是Multi-Version Concurrency Control(多版本并发控制),通过保存数据的多个历史版本,让读操作不加锁,实现读写并发。
核心组件:
- 隐藏字段(DB_TRX_ID、DB_ROLL_PTR)
- Undo Log(版本链)
- Read View(可见性判断)
Q2: MVCC如何实现可重复读?
答: 在RR隔离级别下,事务开始时创建Read View,之后的查询都复用这个Read View,所以看到的始终是事务开始时的快照,实现可重复读。
Q3: RC和RR在MVCC上的区别?
答:
- RC:每次查询创建新的Read View,能看到其他事务已提交的修改
- RR:事务开始时创建Read View并一直复用,看不到之后其他事务的修改
Q4: 快照读和当前读的区别?
答:
- 快照读:普通SELECT,不加锁,读历史版本(MVCC)
- 当前读:SELECT FOR UPDATE、DML语句,加锁,读最新版本
Q5: MVCC能完全解决幻读吗?
答: 不能完全解决。快照读能解决幻读,但当前读(如UPDATE、DELETE)还是会有幻读问题,需要结合Next-Key Lock解决。
十四、最佳实践 💡
1. 避免长事务
-- ❌ 不好:长事务
BEGIN;
SELECT * FROM users; -- 创建ReadView
sleep(3600); -- 睡1小时
SELECT * FROM users;
COMMIT;
-- Undo Log堆积,影响性能
-- ✅ 好:及时提交
BEGIN;
SELECT * FROM users;
COMMIT;
2. 合理选择隔离级别
读多写少,对一致性要求高 → RR
读少写多,对实时性要求高 → RC
3. 避免频繁修改同一行
-- ❌ 不好:循环更新
FOR i IN 1..1000 LOOP
UPDATE users SET score = score + 1 WHERE id = 1;
END LOOP;
-- 版本链过长
-- ✅ 好:批量更新
UPDATE users SET score = score + 1000 WHERE id = 1;
4. 当需要最新数据时使用当前读
-- ❌ 不好:快照读可能看到旧数据
SELECT balance FROM accounts WHERE id = 1;
-- ✅ 好:当前读保证最新
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
十五、总结口诀 📝
MVCC多版本,
读写不阻塞。
隐藏字段记事务,
Undo Log存历史。
Read View判可见,
快照读不加锁。
RC每次新视图,
RR复用保一致。
当前读要加锁,
最新版本拿到手。
长事务要避免,
Undo Log会堆积。
理解MVCC原理,
高并发不用愁!
十六、知识拓展 🎓
Undo Log清理机制
Purge线程职责:
1. 清理不再需要的Undo Log
2. 条件:没有Read View需要这个版本
3. 长事务会阻止清理 → 空间占用
MVCC与锁的配合
MVCC(乐观) + 锁(悲观) = 完整并发控制
快照读:MVCC(不加锁)
当前读:锁(行锁、间隙锁、Next-Key Lock)
参考资料 📚
下期预告: 137-MySQL的索引数据结构为什么选择B+树而不是B树或Hash?🌳
编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0
愿你的数据库永远高并发! ⚡🚀