MVCC,正是MySQL实现"高并发、低阻塞"的核心技术------它让"读操作不用等写操作,写操作也不用等读操作"成为可能,其实核心就是"给数据存多个版本,不同事务按规则读对应版本"。
一、 MVCC初印象:数据库的"时光机"
1. 为什么需要MVCC?
想象一下图书馆的场景:
- 没有MVCC:一本书只能一个人看,其他人必须排队等待(传统锁机制)
- 有MVCC :每个人都可以拿到这本书的"
副本",同时阅读不同版本,互不干扰
MVCC(多版本并发控制) 就是数据库的"时光机 "技术,它维护数据的多个历史版本,在不加锁的前提下,让读操作不阻塞写操作,写操作也不阻塞读操作。
2. 核心概念对比:当前读 vs 快照读
| 类型 | 工作机制 | 类似场景 | 示例SQL |
|---|---|---|---|
| 当前读 | 读取最新数据,并加锁防止别人修改 |
排队买限量商品,买完前别人不能动 | SELECT ... FOR UPDATE UPDATE ... DELETE ... |
| 快照读 | 读取某个时间点的数据版本,不加锁 |
查看历史交易记录,不影响当前交易 | 普通 SELECT |
bash
-- 示例:银行转账场景
-- 当前读(加锁,防止并发问题)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 锁定账户
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 扣款
-- 快照读(不加锁,不影响别人操作)
SELECT * FROM transaction_history WHERE date = '2024-01-01'; -- 查看历史记录
关键:不同隔离级别下的快照读差异
快照读的"快照生成时机",会随事务隔离级别的不同而变化,这直接影响读取结果的一致性:
Read Committed(读已提交,RC):每次执行select都会生成一个新的快照(读最新的历史版本);Repeatable Read(可重复读,RR):一个事务中,只有select第一次执行时才生成快照,后续所有select都复用这个快照(保证多次读结果一致);Serializable(串行化):最严格的隔离级别,快照读会退化为当前读(加锁阻塞,完全放弃并发)。
重点:MySQL默认隔离级别是RR(可重复读),正是靠MVCC的"快照复用"实现了"可重复读",避免了"不可重复读"问题。
1.3 MVCC核心定义:多版本并发控制
MVCC 全称 Multi-Version Concurrency Control(多版本并发控制),官方定义:通过维护数据的多个版本,让读写操作不冲突,从而实现非阻塞读的并发控制技术。
核心要点拆解:
- "多版本":每条数据被修改时,都会生成一个新的版本,旧版本保留(存在Undo Log中);
- "并发控制":通过规则让不同事务读取到自己"有权限"的版本,实现读写不阻塞;
- 实现载体:快照读(简单select)是MVCC的具体应用场景,当前读不依赖MVCC。
二、 MVCC三大支柱:隐藏字段、Undo Log、ReadView
MVCC不是单一技术,而是由"数据记录的隐藏字段 ""Undo Log版本链 ""ReadView(读视图)"三个组件协同实现的。就像"时光相册"能正常使用,需要"照片的元信息""相册的排序""访问相册的权限清单"三者配合。
1. 隐藏字段:每条记录的"身份证"
InnoDB引擎会给每张表的每条记录,自动添加3个隐藏字段(不用手动定义,数据库底层维护),用于标记数据的版本和关联历史版本:
bash
-- 实际存储结构(你看不到,但确实存在)
CREATE TABLE invisible_fields (
id INT PRIMARY KEY,
-- 以下是隐藏字段 --
DB_TRX_ID BIGINT, -- 最后修改的事务ID
DB_ROLL_PTR POINTER, -- 指向上一个版本的指针
DB_ROW_ID BIGINT, -- 隐藏主键(如果没有主键)
-- 你的表字段 --
name VARCHAR(50),
age INT
);
字段详解:
DB_TRX_ID:最后修改这条记录的事务ID。(事务ID是自增的,越大表示事务越新)。比如:事务10插入一条记录,DB_TRX_ID=10;之后事务20修改了这条记录,DB_TRX_ID就更新为20。DB_ROLL_PTR:指向这条记录上一个版本的指针(类似链表),相当于"时光相册"里的"上一张照片"索引。旧版本的数据存在Undo Log中,通过这个指针就能串联起所有历史版本,形成"版本链"。DB_ROW_ID:隐藏主键,保证每行记录唯一。如果表没有手动指定主键(PRIMARY KEY),InnoDB会自动生成这个隐藏字段作为主键(自增);如果表已有主键,这个字段就不会生成。作用是保证每条记录的唯一性,和MVCC的版本控制间接相关。
可视化理解 :一条记录的结构就像"照片+标签"------数据内容是"照片",DB_TRX_ID、DB_ROLL_PTR是"标签",标注了这张照片的"拍摄者(事务ID)"和"上一张照片的位置(回滚指针)"。
2. Undo Log(回滚日志):数据的"后悔药"和"时光胶囊"
Undo Log不仅是回滚工具,还是MVCC的"版本库":
bash
// Undo Log的两种角色
class UndoLog {
// 1. 回滚日志(事务失败时撤销操作)
function rollback() {
// 回滚INSERT:删除新记录
// 回滚UPDATE:恢复旧值
// 回滚DELETE:恢复已删除记录
}
// 2. MVCC版本链(存储历史版本)
function createVersionChain() {
// 每次修改都保留旧版本
// 新版本指向旧版本,形成链表
}
}
先回顾Undo Log的核心特性
- Insert操作的Undo Log:只在事务回滚时需要,事务提交后会立即删除(因为插入的记录只有当前事务可见,其他事务不会读插入前的"空版本");
- Update/Delete操作的Undo Log:不仅用于回滚,还用于MVCC的快照读(其他事务可能需要读修改前的旧版本),所以事务提交后不会立即删除,要等没有事务需要这个版本时才会被清理(由purge线程负责)。
Undo Log版本链的形成过程
当不同事务或同一事务多次修改同一条记录时,会生成多条Undo Log,这些日志通过"回滚指针(DB_ROLL_PTR)"串联成一条"版本链"。版本链的特点:
- 链表的"头部":最新的旧版本(离当前时间最近的修改版本);
- 链表的"尾部":最早的旧版本(第一次修改前的原始版本);
- 每条版本都包含自己的
DB_TRX_ID(修改该版本的事务ID)和DB_ROLL_PTR(指向上一个版本的指针)。
示例:版本链如何生成?
假设表user(id, name)(id为主键),有3个事务依次修改同一条记录(id=1,初始name="张三"):
- 事务10(TRX_ID=10):将name改为"李四",生成一条Update Undo Log,记录旧值name="张三",DB_TRX_ID=10,DB_ROLL_PTR指向null(此时是第一个版本);
- 事务20(TRX_ID=20):将name改为"王五",生成一条Update Undo Log,记录旧值name="李四",DB_TRX_ID=20,DB_ROLL_PTR指向事务10生成的版本;
- 事务30(TRX_ID=30):将name改为"赵六",生成一条Update Undo Log,记录旧值name="王五",DB_TRX_ID=30,DB_ROLL_PTR指向事务20生成的版本。
最终形成的版本链:
bash
【当前最新版本(name="赵六",TRX_ID=30)】 ← 【版本2(name="王五",TRX_ID=20)】 ← 【版本1(name="李四",TRX_ID=10)】 ← 【原始版本(name="张三")】
3. ReadView(读视图):决定你能看到什么
有了"版本链"(时光相册里的所有照片),还需要一个"规则 "来判断:当前事务能读取版本链中的哪一个版本?这个规则就是 ReadView (读视图)。
ReadView的核心作用:记录并维护"当前系统中活跃的事务ID"(未提交的事务),作为快照读时判断版本可见性的依据。就像"访问相册的权限清单",规定了哪些"照片(版本)"可以看。
bash
class ReadView {
// 四个核心属性
long creator_trx_id; // 创建者的事务ID
long[] m_ids; // 活跃事务ID列表(未提交的事务)
long min_trx_id; // 最小活跃事务ID
long max_trx_id; // 最大事务ID + 1
// 核心方法:判断版本是否可见
boolean isVisible(Version version) {
// 判断逻辑(后面详细讲解)
}
}
ReadView的4个核心字段
每个ReadView都包含4个固定字段,用于判断版本可见性:
m_ids:当前活跃的事务ID集合(所有未提交的事务ID列表);min_trx_id:当前活跃事务ID中的最小值(最小的"未提交事务ID");max_trx_id:预分配的下一个事务ID(当前系统中最大的事务ID + 1,因为事务ID是自增的);creator_trx_id:创建这个ReadView的事务ID(当前执行快照读的事务ID)。
三、 版本链访问规则:判断是否可见
快照读时,InnoDB会先生成ReadView,再从版本链的"头部"(最新旧版本)开始,依次判断每个版本的DB_TRX_ID(修改该版本的事务ID)是否符合规则。如果符合,就读取这个版本;如果不符合,就通过回滚指针找下一个版本,直到找到符合规则的版本(或版本链结束,返回空)。
1. 判断版本可见性的四大规则
bash
// 简化版可见性判断逻辑
boolean isVersionVisible(Version version, ReadView readView) {
// 规则1:自己修改的,自己能看到
if (version.trx_id == readView.creator_trx_id) {
return true;
}
// 规则2:版本事务ID < 最小活跃事务ID → 已提交,可见
if (version.trx_id < readView.min_trx_id) {
return true;
}
// 规则3:版本事务ID >= 最大事务ID → 将来事务创建的,不可见
if (version.trx_id >= readView.max_trx_id) {
return false;
}
// 规则4:版本事务ID在活跃事务列表中 → 未提交,不可见
if (readView.m_ids.contains(version.trx_id)) {
return false;
}
// 其他情况:事务已提交且不在活跃列表中,可见
return true;
}
以下是通用的版本可见性判断规则(RC和RR隔离级别共用这组规则,差异仅在于ReadView的生成时机):
- 如果当前版本的DB_TRX_ID == creator_trx_id(修改这个版本的事务,就是当前执行快照读的事务):
→ 可见(自己修改的版本,自己当然能看); - 如果当前版本的DB_TRX_ID < min_trx_id(修改这个版本的事务,在当前所有活跃事务之前就已提交):
→ 可见(事务已提交,其修改的版本对其他事务可见); - 如果当前版本的DB_TRX_ID >= max_trx_id(修改这个版本的事务,是在当前ReadView生成之后才启动的):
→ 不可见(事务还没开始,其修改的版本还没生成,自然看不到); - 如果当前版本的min_trx_id <= DB_TRX_ID < max_trx_id(修改这个版本的事务,在当前活跃事务范围内):
→ 再判断DB_TRX_ID是否在m_ids(活跃事务集合)中:- 若在:不可见(事务未提交,其修改的版本还不能被其他事务看);
- 若不在:可见(事务已提交,其修改的版本对其他事务可见)。
简化记忆:
- 自己改的:能看;
- 比所有未提交事务都早提交的:能看;
- 还没开始的事务改的:不能看;
- 正在运行(未提交)的事务改的:不能看;已提交的:能看。
2. 实战示例:银行账户余额变化
假设账户初始余额为1000元:
bash
-- 事务执行顺序
-- 事务100:INSERT INTO accounts(id, balance) VALUES(1, 1000);
-- 事务101:UPDATE accounts SET balance = 800 WHERE id = 1;
-- 事务102:UPDATE accounts SET balance = 900 WHERE id = 1;
-- 事务103:SELECT balance FROM accounts WHERE id = 1; -- 此时事务101已提交,事务102未提交
版本链:
bash
V3: balance=900 (trx_id=102, 未提交) ← 指向 V2
↑
V2: balance=800 (trx_id=101, 已提交) ← 指向 V1
↑
V1: balance=1000 (trx_id=100, 已提交) ← 链表尾部
- DB_TRX_ID:102
m_ids:[102];min_trx_id:[102];max_trx_id:[104];creator_trx_id:创建这个ReadView的事务ID(当前执行快照读的事务ID)。
不同事务的读取结果:
-
事务103(创建ReadView时活跃事务:[102]):
- 检查V3:trx_id=102在活跃列表中 → 不可
- 检查V2:trx_id=101 < min_trx_id → 可见 → 返回
800
-
如果是事务102自己查询:
- 检查V3:trx_id=102 = creator_trx_id → 可见 → 返回
900
- 检查V3:trx_id=102 = creator_trx_id → 可见 → 返回
四、 隔离级别的实现差异
1. READ COMMITTED(读已提交)
核心特点 :每次快照读都创建新的ReadView。这意味着,每次快照读都会获取"当前最新的活跃事务状态",只能读到"已提交的最新版本"。
示例场景:
事务A(TRX_ID=100)执行快照读,此时系统中有活跃事务B(TRX_ID=200,未提交)和已提交事务C(TRX_ID=150)。
- 第一次select :生成ReadView(m_ids=[200], min_trx_id=200, max_trx_id=201, creator_trx_id=100);
→ 版本链中,事务C(150)的版本符合规则(150 < 200),读取事务C的版本; - 事务B提交(TRX_ID=200);
- 第二次select :重新生成ReadView(m_ids=[], min_trx_id=201, max_trx_id=201, creator_trx_id=100);
→ 版本链中,事务B(200)的版本符合规则(200 < 201),读取事务B的版本; - 结论:两次select读到不同版本(不可重复读),这就是RC隔离级别的特点。
2. REPEATABLE READ(可重复读)
核心特点 :一个事务中,只有第一次执行select (快照读)时生成ReadView,后续所有快照读都复用这个ReadView。这意味着,后续即使有其他事务提交,当前事务也不会看到其修改的版本,从而实现"可重复读"。
示例场景: :
用上面的场景:事务A(TRX_ID=100)执行快照读,系统中有活跃事务B(TRX_ID=200,未提交)和已提交事务C(TRX_ID=150)。
- 第一次select :生成ReadView(m_ids=[200], min_trx_id=200, max_trx_id=201, creator_trx_id=100);→ 读取事务C(150)的版本;
- 事务B提交(TRX_ID=200);
- 第二次select :复用第一次的ReadView(m_ids仍为[200],min_trx_id仍为200);
→ 事务B的DB_TRX_ID=200,在m_ids中(虽然事务B已提交,但ReadView没更新,仍认为它是活跃的),所以事务B的版本不可见;继续找下一个版本,还是读取事务C(150)的版本; - 结论:两次select读到相同版本(可重复读),这就是MySQL默认隔离级别的实现原理。
关键差异 :RC和RR的版本访问规则完全相同,唯一差异是ReadView的生成时机------RC每次快照读都生成新的,RR只生成一次并复用。这也是MVCC实现不同隔离级别一致性的核心。
3. 两种隔离级别的对比
| 特性 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| ReadView创建时机 | 每次快照读都创建 | 第一次快照读创建 |
| 可见性变化 | 能看到其他事务的已提交修改 | 看不到其他事务的已提交修改 |
| 幻读问题 | 可能出现幻读 | 通过MVCC避免幻读 |
| 适用场景 | 数据实时性要求高 | 数据一致性要求高 |
| 性能影响 | 频繁创建ReadView,开销较大 | ReadView复用,开销较小 |
五、 MVCC完整工作流程
1. 数据插入流程
bash
-- 插入一条新记录
INSERT INTO users(id, name, age) VALUES(1, '张三', 25);
-- MVCC内部执行:
-- 1. 分配事务ID(假设trx_id=100)
-- 2. 设置隐藏字段:
-- DB_TRX_ID = 100
-- DB_ROLL_PTR = NULL(没有历史版本)
-- 3. 创建Undo Log(用于回滚)
2. 数据更新流程
bash
-- 更新记录
UPDATE users SET age = 26 WHERE id = 1;
-- MVCC内部执行(假设事务ID=101):
-- 1. 创建新版本(Copy-on-Write)
-- 2. 设置新版本:
-- DB_TRX_ID = 101
-- DB_ROLL_PTR → 指向旧版本
-- 3. 修改旧版本的DB_ROLL_PTR指向新版本
-- 4. 创建Undo Log记录旧值
3. 数据读取流程
bash
-- 读取记录(假设在事务102中)
SELECT * FROM users WHERE id = 1;
-- MVCC内部执行:
-- 1. 找到记录的最新版本(DB_TRX_ID=101)
-- 2. 创建ReadView(或在RR中复用已有)
-- 3. 从最新版本开始,沿版本链回溯:
-- a. 检查版本101:是否可见?
-- b. 如果不,继续检查版本100
-- c. 找到第一个可见的版本返回
六、 MVCC与锁的协同工作
1. 什么时候用MVCC?什么时候用锁?
bash
-- 场景1:只读查询 → 使用MVCC(快照读)
SELECT * FROM products WHERE category = '电子产品';
-- 不加锁,读取历史版本,不影响其他事务写操作
-- 场景2:读写冲突 → 使用锁(当前读)
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
UPDATE orders SET status = '已发货' WHERE id = 100;
-- 加锁,确保数据一致性
-- 场景3:混合场景 → MVCC + 锁
BEGIN;
-- 快照读:使用MVCC读取库存
SELECT stock FROM products WHERE id = 1; -- MVCC
-- 当前读:修改库存时加锁
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 加锁
COMMIT;
2. MVCC的优势与局限
优势:
- 高并发:读写不冲突,大幅提升并发性能
- 无阻塞读:读取操作永远不需要等待
- 避免死锁:读操作不加锁,减少死锁概率
- 实现隔离级别:支持RC和RR隔离级别
局限:
- 存储开销:需要额外空间存储多版本
- 版本清理:需要定期清理过期版本
- 写冲突:写操作仍然需要加锁处理
- 历史数据:长期运行的事务可能阻止旧版本清理
七、 实战:MVCC如何解决并发问题
1. 脏读问题解决
bash
-- 事务A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 未提交
-- 事务B(使用MVCC)
BEGIN;
-- 脏读:如果事务B能读到未提交的修改,就是脏读
SELECT balance FROM accounts WHERE id = 1; -- MVCC会返回之前已提交的版本
-- 结果:不会看到事务A未提交的修改
2. 不可重复读问题解决
bash
-- 事务A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 返回1000
-- 事务B提交修改
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
-- 事务A再次查询
SELECT balance FROM accounts WHERE id = 1; -- 在RR级别下,MVCC保证仍返回1000
-- 结果:可重复读
3. 幻读问题解决
bash
-- 事务A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 返回5
-- 事务B插入新记录
INSERT INTO orders(status) VALUES ('pending');
COMMIT;
-- 事务A再次查询
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 在RR级别下,MVCC保证仍返回5
-- 结果:没有幻读
八、 MVCC性能优化与监控
1. 监控MVCC相关指标
bash
-- 查看Undo Log使用情况
SHOW ENGINE INNODB STATUS\G
-- 查看"TRANSACTIONS"部分的History list length
-- 查看长事务(可能阻止版本清理)
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;
-- 查看等待清理的版本
SELECT
NAME AS table_name,
NUM_ROWS,
CLUST_INDEX_SIZE,
OTHER_INDEX_SIZE
FROM information_schema.INNODB_SYS_TABLESTATS;
2. 优化建议
bash
-- 1. 控制事务大小,及时提交
-- 不好的做法:
BEGIN;
-- 执行大量操作...
-- 长时间不提交,占用版本链
COMMIT;
-- 好的做法:
BEGIN;
-- 快速完成操作
COMMIT;
-- 2. 避免长查询
-- 设置查询超时
SET max_execution_time = 5000; -- 5秒超时
-- 3. 定期维护
-- 在业务低峰期执行
OPTIMIZE TABLE large_table;
九、 常见问题解答
Q1:MVCC能完全避免锁吗 ?
A:不能。MVCC主要优化了读操作,避免了读锁。但写操作仍然需要加锁来保证数据一致性,尤其是:
- 当前读操作(SELECT ... FOR UPDATE)
- INSERT、UPDATE、DELETE操作
- 唯一约束检查
Q2:版本链会无限增长吗?
A :不会。InnoDB有purge线程专门清理不再需要的旧版本。清理条件:
- 没有活跃事务需要这个版本
- 版本已提交且超过一定时间
- Undo Log空间需要回收
Q3:MVCC对存储有什么影响?
A:MVCC会增加存储开销,主要体现在:
- 额外字段:每行数据多3个隐藏字段
- Undo Log:存储历史版本
- 版本链 :维护多个版本
但相比带来的并发性能提升,这个开销通常是值得的。
Q4:为什么RC级别下能看到已提交的修改?
A:因为RC级别每次快照读都创建新的ReadView,新的ReadView能看到之前已提交的事务。
Q5:MVCC和锁哪个性能更好?
A:不同场景适用不同技术:
- 高并发读 :MVCC性能远优于锁
-高并发写:锁机制更直接高效 - 混合负载:MVCC+锁的组合最优
十、 总结
MVCC的本质的是"用空间换时间":通过保留数据的历史版本(占用Undo Log空间),避免了读写操作的阻塞(节省并发等待时间)。其核心逻辑可以总结为:
- 数据修改时:生成新版本,记录DB_TRX_ID和DB_ROLL_PTR,旧版本存入Undo Log并串联成版本链;
- 快照读时:生成ReadView(RC每次生成,RR只生成一次);
- 版本判断:从版本链头部开始,用ReadView的规则判断版本可见性,找到符合规则的版本并读取。
MVCC的核心价值
- 高并发:实现读写不阻塞,大幅提升并发访问性能(MySQL能支撑高并发,MVCC功不可没);
- 一致性:配合隔离级别,实现不同程度的数据一致性(如RR的可重复读);
- 高效回滚:Undo Log不仅支撑MVCC,还支撑事务回滚,一举两得。
必记的核心要点
- MVCC只作用于快照读(简单select),当前读(加锁select、DML)不依赖MVCC;
- 三大核心组件:隐藏字段(DB_TRX_ID、DB_ROLL_PTR)、Undo Log版本链、ReadView;
- RC和RR的差异:ReadView生成时机不同(每次vs一次),导致是否可重复读;
- Undo Log的作用:回滚 + 存储数据历史版本(支撑MVCC)。