什么是MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)是数据库管理系统中用于提高并发性能的一种重要机制。其核心思想是为每个数据行维护多个版本,使得读操作和写操作可以并发执行而不会相互阻塞。
在传统的锁机制中,当一个事务正在修改数据时,其他事务的读取操作必须等待,这严重影响了系统的并发性能。而MVCC通过保存数据在某个时间点的快照,让读操作可以访问数据的历史版本,从而避免了读写操作之间的相互阻塞。
存储引擎对MVCC的支持
不同的MySQL存储引擎对MVCC的支持情况不同:
| 存储引擎 | MVCC支持 | 锁机制 | 并发性能 |
|---|---|---|---|
| InnoDB | ✅ 支持MVCC | 行级锁 | 高并发 |
| MyISAM | ❌ 不支持MVCC | 表级锁 | 低并发 |
InnoDB存储引擎的MVCC实现
InnoDB是MySQL默认的存储引擎,完整支持MVCC机制:
- 使用行级锁提高并发性能
- 通过Undo Log实现多版本数据存储
- 支持四种标准隔离级别
MyISAM存储引擎的限制
MyISAM存储引擎不支持MVCC:
- 只支持表级锁,无法实现行级并发控制
- 不支持事务处理
- 适用于读多写少的场景
MVCC的核心组件
1. 隐藏字段
InnoDB存储引擎为每行数据自动添加三个隐藏字段:
-
DB_TRX_ID(6字节):记录最后一次修改该行数据的事务ID -
DB_ROLL_PTR(7字节):回滚指针,指向该行数据的undo log记录 -
DB_ROW_ID(6字节):行ID,当表没有主键和非空唯一索引时自动生成,否则不会生成此字段

2. Undo Log(回滚日志)
Undo Log是MVCC实现的关键组件,它记录了数据修改前的旧值。每当对数据进行修改时,都会生成对应的Undo Log记录。
Undo Log主要分为两种类型:
-
insert undo log:针对INSERT操作产生的undo log,事务提交后即可被删除 -
update undo log:针对DELETE和UPDATE操作产生的undo log,可能需要保留更长时间以支持MVCC
同一个事务内对同一行数据的多次修改会生成多个undo log记录。每次修改都会产生对应的undo log,并通过回滚指针(DB_ROLL_PTR)指向上一次修改生成的undo log,形成版本链,以支持事务回滚和MVCC机制
3. Read View(读视图)
Read View是事务在某个时间点看到的数据快照。它包含以下关键信息:
-
m_ids:创建Read View时活跃(未提交)的事务ID列表 -
min_trx_id:m_ids中的最小事务ID -
max_trx_id:创建Read View时系统即将分配给下一个事务的ID -
creator_trx_id:创建该Read View的事务ID
MVCC的工作机制
1. 数据版本链详解
当数据被修改时,不会直接覆盖原有数据,而是通过Undo Log将旧版本数据链接起来形成版本链。下面我们通过一个具体例子来说明这个过程。
初始状态
假设我们有一个用户表,初始状态下有一条记录:
用户表 users:
+----+--------+-----+
| id | name | age |
+----+--------+-----+
| 1 | Alice | 25 |
+----+--------+-----+
此时该行数据的隐藏字段为:
-
DB_TRX_ID: 100 (假设由事务100插入,事务ID全局唯一且有序) -
DB_ROLL_PTR: NULL (初始版本,无回滚指针) -
DB_ROW_ID: 不生成 (因为表有主键id)
第一次更新
事务200执行更新操作:
UPDATE users SET age = 26 WHERE id = 1;
执行后,系统会:
- 将原数据保存到Undo Log中
- 更新当前行数据
- 设置回滚指针
版本链结构如下:
当前版本 (事务200):
+----+--------+-----+-----------+-------------+
| id | name | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1 | Alice | 26 | 200 | ptr_to_log1 |
+----+--------+-----+-----------+-------------+
Undo Log版本1 (事务100):
+----+--------+-----+-----------+-------------+
| id | name | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1 | Alice | 25 | 100 | NULL |
+----+--------+-----+-----------+-------------+
第二次更新
事务300执行更新操作:
UPDATE users SET age = 27 WHERE id = 1;
执行后,系统会:
- 将当前版本(事务200的数据)保存到Undo Log中
- 更新当前行数据
- 更新回滚指针
版本链结构如下:
当前版本 (事务300):
+----+--------+-----+-----------+-------------+
| id | name | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1 | Alice | 27 | 300 | ptr_to_log2 |
+----+--------+-----+-----------+-------------+
Undo Log版本2 (事务200):
+----+--------+-----+-----------+-------------+
| id | name | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1 | Alice | 26 | 200 | ptr_to_log1 |
+----+--------+-----+-----------+-------------+
Undo Log版本1 (事务100):
+----+--------+-----+-----------+-------------+
| id | name | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1 | Alice | 25 | 100 | NULL |
+----+--------+-----+-----------+-------------+

2. 可见性判断算法
当事务读取数据时,InnoDB需要判断该数据版本对当前事务是否可见。判断逻辑如下:
// 简化的可见性判断逻辑
public boolean isVersionVisible(ReadView readView, long trxId) {
// 如果记录的事务ID小于Read View的最小事务ID
if (trxId < readView.min_trx_id) {
// 说明该事务在Read View创建前已提交,可见
return true;
}
// 如果记录的事务ID大于等于Read View的最大事务ID
if (trxId >= readView.max_trx_id) {
// 说明该事务在Read View创建后才开始,不可见
return false;
}
// 事务ID在[min_trx_id, max_trx_id)区间内
// 判断该事务是否在活跃事务列表中
if (readView.m_ids.contains(trxId)) {
// 在活跃列表中,说明事务还未提交,不可见
return false;
} else {
// 不在活跃列表中,说明事务已提交,可见
return true;
}
}
3. 版本链遍历过程
当当前版本不可见时,系统会沿着版本链向前查找,直到找到一个可见的版本或遍历完所有版本:
- 读取当前版本的数据 (MVCC在读取数据时,从当前数据行的版本开始,沿着undolog中的版本链逐步回溯)
- 判断该版本是否对当前事务可见
- 如果可见,则返回该版本数据
- 如果不可见,则通过
DB_ROLL_PTR找到上一个版本 - 重复步骤2-4,直到找到可见版本或版本链遍历完成
例如,假设事务400在READ COMMITTED隔离级别下执行查询:
事务400执行: SELECT * FROM users WHERE id = 1;
1. 读取当前版本(事务300): age=27, trx_id=300
2. 判断可见性: trx_id(300) >= max_trx_id(400) → 不可见
3. 通过DB_ROLL_PTR找到版本2(事务200): age=26, trx_id=200
4. 判断可见性: trx_id(200) >= max_trx_id(400) → 不可见
5. 通过DB_ROLL_PTR找到版本1(事务100): age=25, trx_id=100
6. 判断可见性: trx_id(100) < min_trx_id(400) → 可见
7. 返回版本1的数据: Alice, age=25
快照读与当前读
快照读(Snapshot Read)
快照读是指读取记录的可见版本,不加锁,是MVCC的核心实现方式。
特点
- 不加锁,读取的是数据的一个快照版本
- 通过Read View判断数据可见性
- 是InnoDB在READ COMMITTED和REPEATABLE READ隔离级别下普通SELECT的默认行为
示例
-- 普通SELECT语句,执行快照读
SELECT * FROM users WHERE id = 1;
SELECT name, age FROM employees WHERE department = 'IT';
当前读(Current Read)
当前读是指读取记录的最新版本,并且会对记录加锁,保证读取过程中数据不会被修改。
特点
- 读取的是记录的最新版本
- 会对读取的记录加锁(共享锁或排他锁)
- 保证读取过程中数据的一致性
触发当前读的SQL语句
-- 显式加共享锁的SELECT
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 显式加排他锁的SELECT
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- DML操作(INSERT/UPDATE/DELETE)中的SELECT
UPDATE users SET age = 26 WHERE id = 1; -- 先执行当前读找到记录
DELETE FROM users WHERE id = 1; -- 先执行当前读找到记录
INSERT INTO users (id, name) VALUES (2, 'Bob')
ON DUPLICATE KEY UPDATE name = 'Bob2'; -- 检查重复键时执行当前读
| 特性 | SELECT FOR UPDATE | LOCK IN SHARE MODE |
|---|---|---|
| 锁类型 | 排他锁(写锁) | 共享锁(读锁) |
| 其他事务读取 | 不允许 | 允许 |
| 其他事务写入 | 不允许 | 不允许 |
| 并发性 | 低 | 高 |
| 适用场景 | 读取后需要更新 | 只读操作 |
| 阻塞情况 | 阻塞其他读写操作 | 只阻塞写操作 |
事务隔离级别与MVCC
| 隔离级别 | MVCC行为 |
|---|---|
| READ UNCOMMITTED | 不使用MVCC,直接读取最新数据 |
| READ COMMITTED | 使用MVCC,每次SELECT都生成新的Read View |
| REPEATABLE READ | 使用MVCC,事务开始时生成Read View |
| SERIALIZABLE | 不使用MVCC,使用锁机制保证串行化 |
1. READ UNCOMMITTED(读未提交)
MVCC行为
- 不使用MVCC机制
- 直接读取数据的最新版本
- 可能读取到其他事务未提交的数据(脏读)
示例场景
-- 事务A
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;
-- 事务B(READ UNCOMMITTED隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 可能读取到1000(未提交数据)
-- 如果事务A回滚,事务B读取到的就是"脏数据"
2. READ COMMITTED(读已提交)
MVCC行为
- 使用MVCC机制
- 每次SELECT语句执行时都生成新的Read View
- 只能读取到已提交的数据
- 可能出现不可重复读和幻读
Read View生成机制
每次执行SELECT语句时:
- 获取当前系统中的活跃事务列表
- 创建新的Read View
- 根据Read View判断数据版本可见性
示例场景
-- 事务A
BEGIN;
SELECT * FROM users WHERE id = 1; -- 第一次查询
-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;
-- 事务A继续
SELECT * FROM users WHERE id = 1; -- 第二次查询,结果可能不同
COMMIT;
3. REPEATABLE READ(可重复读)
MVCC行为
- 使用MVCC机制
- 事务第一次执行SELECT时生成Read View,整个事务期间复用该Read View
- 保证同一事务内多次查询结果一致
- MySQL InnoDB的默认隔离级别
Read View生成机制
事务内第一次执行SELECT语句时:
- 获取当前系统中的活跃事务列表
- 创建Read View并缓存
- 后续SELECT语句复用该Read View
示例场景
-- 事务A(REPEATABLE READ隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1; -- 第一次查询,创建Read View
-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;
-- 事务A继续
SELECT * FROM users WHERE id = 1; -- 第二次查询,结果与第一次相同
COMMIT;
4. SERIALIZABLE(串行化)
MVCC行为
- 不使用MVCC机制
- 使用锁机制保证串行化访问
实现方式
- 对读取的数据加共享锁
- 对修改的数据加排他锁
- 事务完全串行执行
示例场景
-- 事务A(SERIALIZABLE隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM users WHERE id = 1; -- 加共享锁
-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1; -- 阻塞,等待事务A释放锁
写操作中的当前读机制
UPDATE操作的执行过程
当执行UPDATE语句时,InnoDB会执行以下步骤:
UPDATE users SET age = 26 WHERE id = 1;
-
当前读阶段:
- 对满足条件的记录加排他锁(X锁)
- 读取记录的最新版本(当前读)
- 如果记录被其他事务锁定,则等待
-
更新阶段:
- 创建新版本的数据
- 设置
DB_TRX_ID为当前事务ID - 将旧版本数据写入Undo Log
- 更新
DB_ROLL_PTR指向Undo Log
DELETE操作的执行过程
DELETE操作同样需要先执行当前读:
DELETE FROM users WHERE id = 1;
-
当前读阶段:
- 对满足条件的记录加排他锁(X锁)
- 读取记录的最新版本
-
删除阶段:
- 将记录标记为删除(而不是立即物理删除)
- 创建相应的Undo Log记录
INSERT操作的特殊性
INSERT操作通常不需要当前读,因为是插入新记录:
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25);
但如果遇到重复键需要执行更新操作,则可能涉及当前读:
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25)
ON DUPLICATE KEY UPDATE age = 26;
版本链不存在的情况处理
在实际的数据库操作中,会遇到版本链不存在的特殊情况,主要包括首次插入数据和长时间后Undo Log被清理的情况。
1. 首次插入数据(无历史版本)
当一条记录是首次插入数据库时,不存在历史版本链:
-- 首次插入记录
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25);
COMMIT;
-- 查询该记录
SELECT * FROM users WHERE id = 1; -- 直接访问当前版本
在这种情况下:
- 记录只有当前版本,没有历史版本链
-
DB_TRX_ID设置为插入时的事务ID -
DB_ROLL_PTR为空或指向空的Undo Log - 查询直接返回当前版本数据,无需版本链遍历
- MVCC在这种情况下退化为简单的当前版本读取
2. Undo Log被清理(版本链截断)
当旧的事务已经提交很久,相关的Undo Log已经被PURGE线程清理:
-- 事务A(很久之前)
BEGIN;
UPDATE users SET age = 25 WHERE id = 1; -- 创建Undo Log版本1
COMMIT; -- 事务提交
-- 事务B(稍后)
BEGIN;
UPDATE users SET age = 26 WHERE id = 1; -- 创建Undo Log版本2
COMMIT; -- 事务提交
-- 系统运行很长时间后,Undo Log被PURGE线程清理
-- 假设只有版本2的Undo Log被保留,版本1的被清理
-- 事务C(当前,REPEATABLE READ隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1; -- 只能看到版本2或更新的版本
在这种情况下:
- 只能访问当前版本和仍然存在的历史版本
- 被清理的版本无法访问,形成版本链截断
- 查询可能无法获取到期望的历史版本
- MVCC的完整历史视图能力受到限制
3. 长时间后读取的情况
当数据写入后很长时间才被读取:
-- 数据首次写入
INSERT INTO logs (id, message, created_at) VALUES (1, 'System started', NOW());
COMMIT;
-- 系统运行数月后读取
SELECT * FROM logs WHERE id = 1; -- 直接读取当前版本
-- 由于是首次插入且长时间未修改,不存在版本链
在这种情况下:
- 直接访问当前版本数据
- 无需复杂的版本链遍历
- 性能最优
4. 系统重启后的情况
系统重启后MVCC状态的恢复:
-- 系统重启前
UPDATE users SET age = 25 WHERE id = 1;
-- 系统重启...
-- 重启后查询
SELECT * FROM users WHERE id = 1; -- 读取重启后的当前版本
系统重启后:
- 活跃事务列表会被清空
- Read View需要重新创建
- 已持久化的数据版本仍然存在
- 但内存中的版本链信息需要重新构建
长时间运行事务的影响
1. 长事务导致的版本积累
长时间运行的事务会阻止Undo Log的清理:
-- 长时间运行的事务A
BEGIN;
SELECT * FROM users WHERE id = 1; -- 创建Read View
-- 长时间运行(几小时甚至几天)
-- 在此期间其他事务的修改都会保留Undo Log
-- 事务B、C、D等在此期间修改同一记录
UPDATE users SET age = 26 WHERE id = 1;
UPDATE users SET age = 27 WHERE id = 1;
UPDATE users SET age = 28 WHERE id = 1;
-- 长事务A再次查询时需要遍历很长的版本链
SELECT * FROM users WHERE id = 1;
2. 版本链过长的影响
-- 长事务保持Read View
BEGIN; -- REPEATABLE READ
SELECT COUNT(*) FROM users;
-- 其他事务频繁更新数据
-- 大量Undo Log无法清理
-- 长事务查询时性能下降
SELECT * FROM users WHERE id = 1; -- 需要遍历长版本链
这种情况会导致:
- 查询性能下降(需要遍历长版本链)
- 存储空间占用增加
- 系统整体性能受影响
MVCC在不同操作中的应用
SELECT操作
普通SELECT语句(一致性非锁定读):
- 根据当前事务隔离级别创建或复用Read View
- 遍历版本链找到对当前事务可见的数据版本
- 不加锁,提高并发性能
UPDATE操作
更新操作的处理流程:
- 加排他锁(X锁)
- 执行当前读,找到记录的最新版本
- 创建新版本数据,设置
DB_TRX_ID为当前事务ID - 将旧版本数据写入Undo Log,并设置
DB_ROLL_PTR
DELETE操作
删除操作的处理流程:
- 加排他锁(X锁)
- 执行当前读,找到记录的最新版本
- 将记录标记为删除,而不是立即物理删除
- 创建Undo Log记录
MVCC的优化与限制
优势
- 高并发性能:读写操作互不阻塞,显著提高并发处理能力
- 减少锁竞争:避免了读操作对写操作的阻塞,减少锁的使用
- 一致性保证:通过Read View机制保证事务看到一致的数据视图
- 支持多种隔离级别:灵活支持不同级别的隔离需求
限制与挑战
- 存储开销:需要存储多个数据版本,增加存储空间使用
- 清理机制复杂:需要PURGE线程定期清理不再需要的旧版本数据
- Undo Log管理:需要有效管理Undo Log的生命周期和存储
- 长事务影响:长时间运行的事务可能导致大量旧版本数据无法清理
Undo Log清理机制
InnoDB通过PURGE线程来清理不再需要的Undo Log:
-
清理条件:
- 事务已提交
- 没有活跃的Read View需要访问该版本
- Undo Log不再被任何事务引用
-
清理过程:
- 从Undo Log中删除旧版本数据
- 更新相关数据结构
- 释放存储空间
实际应用场景分析
场景1:高并发读写系统
在电商系统中,商品信息的读取和更新操作频繁:
-- 大量用户同时查询商品信息(读操作)
SELECT name, price, stock FROM products WHERE id = 1001;
-- 后台系统更新商品价格(写操作)
UPDATE products SET price = 99.99 WHERE id = 1001;
通过MVCC,读操作可以访问商品信息的一致性快照,而写操作可以并发进行,两者互不阻塞。
场景2:报表统计系统
在数据统计系统中,长时间运行的统计查询:
-- 长时间运行的统计查询
SELECT category, COUNT(*), AVG(price)
FROM products
GROUP BY category;
MVCC保证统计查询过程中看到的数据是一致的,不会受到其他事务修改的影响。
性能调优建议
1. 合理设置隔离级别
根据业务需求选择合适的隔离级别,避免不必要的性能损耗。
2. 控制事务长度
避免长时间运行的事务,减少对Undo Log清理的影响。
3. 监控Undo Log使用情况
定期监控Undo Log的使用情况,及时发现和处理异常。
4. 优化查询设计
合理设计查询,避免不必要的全表扫描和大数据量操作。
总结
MVCC是MySQL InnoDB存储引擎实现高并发性能的关键技术。通过维护数据的多个版本、使用Undo Log记录历史数据、以及Read View机制控制数据可见性,MVCC实现了读写操作的并发执行,显著提高了数据库系统的并发处理能力。
理解MVCC的工作原理对于数据库性能优化、事务设计和问题排查都具有重要意义。在实际应用中,需要根据业务特点合理选择隔离级别,控制事务长度,并监控相关性能指标,以充分发挥MVCC的优势。
特别需要注意的是,在版本链不存在的特殊情况下(如首次插入或长时间后读取),MVCC机制会退化为简单的当前版本读取,但这并不影响其整体性能优势。掌握这些特殊情况的处理方式,有助于更好地设计和优化数据库应用。