在数据库领域,并发控制是保证数据一致性的核心挑战。当多个事务同时读写数据时,如何避免脏读、不可重复读、幻读等问题,同时又能最大化并发性能?MVCC(Multi-Version Concurrency Control,多版本并发控制)给出了优雅的答案。
一、引言
在探讨 MVCC 之前,我们先明确一个前提:数据库的本质是"共享资源" ,多个事务同时操作时必然会产生冲突。最直观的冲突场景有三种:
- 读-写冲突:事务 A 读取数据时,事务 B 正在修改该数据
- 写-读冲突:事务 A 修改数据时,事务 B 正在读取该数据
- 写-写冲突:两个事务同时修改同一数据
早期数据库用"锁机制"解决这些冲突:读操作加共享锁(多个读可共存),写操作加排他锁(阻塞所有读写)。但这种方式有明显缺陷:
- 性能低下:读操作会被写操作阻塞,写操作也会被读操作阻塞,并发能力差
- 灵活性不足:无法满足不同场景对"一致性"和"并发度"的平衡需求
MVCC 的出现正是为了突破锁机制的局限。它的核心思想是:通过维护数据的多个版本,让读写操作在不同版本上工作,从而实现"读不加锁、读写不冲突"。
二、MVCC 核心概念
要理解 MVCC 的工作原理,必须先掌握以下几个核心概念,它们是构建多版本控制的基础。
2.1 事务 ID(Transaction ID)
数据库会为每个事务分配一个唯一的、递增的 ID(以下简称 trx_id)。这个 ID 有两个关键作用:
- 标记事务的"出生时间":ID 越大,事务启动越晚
- 标记数据的"修改者":每条数据的最新版本都会记录最后修改它的事务 ID
2.2 隐藏字段
InnoDB 存储引擎会给表中的每一行数据自动添加隐藏字段:
| 隐藏字段 | 含义 |
|---|---|
DB_TRX_ID | 记录最后一次修改该数据的事务 ID(新增、更新都会改变这个值) |
DB_ROLL_PTR | 回滚指针,指向该数据的上一个版本(存储在 undo log 中) |
DB_ROW_ID | 隐藏主键,当表没有指定主键时,InnoDB 会用这个字段作为默认聚簇索引键 |
举个例子:当我们创建一张 user 表时:
CREATE TABLE user (id INT, name VARCHAR(10));
InnoDB 实际存储的每行数据结构类似:
id: 1, name: '张三', DB_TRX_ID: 100, DB_ROLL_PTR: 0x000001, DB_ROW_ID: 1
重要说明:
DB_ROW_ID只有在表没有显式定义主键时才会生成。建议所有表都定义显式主键。
2.3 Undo Log
undo log(回滚日志)是 MVCC 实现多版本的关键,它有两个核心作用:
- 事务回滚:当事务执行失败时,通过 undo log 恢复数据到修改前的状态
- 版本存储:保存数据的历史版本,供其他事务读取
当事务修改数据时,InnoDB 会先将数据的旧版本写入 undo log,再更新当前数据。例如:
- 事务 100 插入一条数据,
DB_TRX_ID=100,DB_ROLL_PTR=NULL(无历史版本) - 事务 200 更新该数据,InnoDB 会:
- 复制当前数据(
trx_id=100)到 undo log - 更新原数据的
name字段,将DB_TRX_ID改为 200 - 原数据的
DB_ROLL_PTR指向 undo log 中刚才保存的旧版本
- 复制当前数据(
这样,通过 DB_ROLL_PTR 就能串联起数据的所有历史版本,形成一条"版本链":
当前版本(trx_id=200, roll_ptr→旧版本1)
→ 旧版本1(trx_id=100, roll_ptr→NULL)
2.4 Read View
有了多版本数据后,事务如何判断哪个版本对自己可见?这就需要 Read View(读视图)—— 一个在事务读取数据时生成的"可见性规则集合"。
Read View 的实际参数(基于MySQL源码):
// MySQL源码:storage/innobase/include/read0types.h
class ReadView {
private:
trx_id_t m_low_limit_id; /* 大于等于此ID的事务均不可见 */
trx_id_t m_up_limit_id; /* 小于此ID的事务均可见 */
trx_id_t m_creator_trx_id; /* 创建该read view的事务ID */
ids_t m_ids; /* 创建read view时的活跃事务列表 */
trx_id_t m_low_limit_no; /* 用于purge的事务号 */
};
参数说明:
m_low_limit_id:高水位,下一个待分配的事务ID,大于等于此值的事务都不可见m_up_limit_id:低水位,活跃事务列表中的最小ID,小于此值的事务都可见m_ids:创建Read View时活跃(未提交)的事务ID列表m_creator_trx_id:创建此Read View的事务ID
可见性判断规则(假设数据版本的事务 ID 为 trx_id):
public:
// 判断事务是否可见的核心方法
bool changes_visible(trx_id_t id, const table_name_t& name) const {
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return(false);
} else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
return(!std::binary_search(p, p + m_ids.size(), id));
}
三、MVCC 工作流程:完整生命周期解析
结合上述概念,我们通过一个具体案例演示 MVCC 的完整工作流程。
场景设定
- 初始数据:
id=1, name='张三'(假设由事务 100 插入,DB_TRX_ID=100,DB_ROLL_PTR=NULL) - 事务 200(
trx_id=200):更新name为"李四"(未提交) - 事务 300(
trx_id=300):查询id=1的数据
步骤 1:事务 200 修改数据,生成版本链
- 事务 200 启动,获得
trx_id=200 - 修改数据前,InnoDB 将旧版本(
trx_id=100)写入 undo log - 更新原数据:
name='李四',DB_TRX_ID=200,DB_ROLL_PTR指向 undo log 中的旧版本 - 此时版本链为:
新版本(trx_id=200, roll_ptr→旧版本) → 旧版本(trx_id=100, roll_ptr→NULL) - 事务 200 未提交(仍处于活跃状态)
步骤 2:事务 300 读取数据,生成 Read View
- 事务 300 启动,获得
trx_id=300 - 执行查询时,生成 Read View:
m_ids = [200](当前只有事务 200 活跃)m_up_limit_id = 200(活跃事务中的最小ID)m_low_limit_id = 301(下一个事务ID)m_creator_trx_id = 300
步骤 3:遍历版本链,匹配可见版本
- 事务 300 先读取最新版本(
trx_id=200):- 检查
trx_id=200是否在m_ids中 → 是(事务 200 未提交)→ 不可见
- 检查
- 跟随
DB_ROLL_PTR读取上一版本(trx_id=100):- 检查
trx_id=100 < m_up_limit_id=200→ 可见(事务 100 已提交)
- 检查
- 返回该版本数据:
name='张三'
步骤 4:事务 200 提交后,版本可见性变化
- 事务 200 提交,从活跃事务列表
activeIDs中移除 - 此时若有新事务 400 启动并查询:
- 生成 Read View 时,
m_ids为空,m_up_limit_id等于m_low_limit_id=301 - 读取最新版本(
trx_id=200):200 < 301且不在m_ids中 → 可见 - 返回
name='李四'
- 生成 Read View 时,
四、MVCC 与锁机制的协同工作
MVCC 和锁在 InnoDB 中不是相互替代的关系,而是协同工作、各司其职的伙伴。理解它们的协作方式,是掌握 MySQL 并发控制的关键。
4.1 快照读与当前读:两种读取模式
快照读
定义:读取数据在某个时间点的历史版本,不读取最新数据,也不加锁。
触发方式:
-- 普通的 SELECT 查询(在 RR 和 RC 隔离级别下)
SELECT * FROM table_name WHERE condition;
特点:
- 基于 MVCC 机制
- 读取 Read View 确定的历史版本
- 无锁操作,读写不冲突
- 在 RR 级别下保证可重复读
当前读
定义:读取数据的最新版本,并对读取的记录加锁。
触发方式:
-- 加锁的 SELECT
SELECT * FROM table_name WHERE condition FOR UPDATE; -- 排他锁
SELECT * FROM table_name WHERE condition LOCK IN SHARE MODE; -- 共享锁
-- 数据修改操作
UPDATE table_name SET column = value WHERE condition; -- 排他锁
DELETE FROM table_name WHERE condition; -- 排他锁
INSERT INTO table_name ... ; -- 排他锁
特点:
- 绕过 MVCC,直接读取最新数据
- 需要加锁,可能阻塞其他操作
- 用于需要获取实时数据的场景
4.2 MVCC 与锁的职责划分
| 并发问题 | 解决机制 | 具体实现 |
|---|---|---|
| 读-写冲突 | MVCC 主要负责 | 通过版本链和 Read View,读操作访问历史版本,写操作创建新版本 |
| 写-写冲突 | 锁机制主要负责 | 通过行锁、间隙锁保证同一时间只有一个事务能修改数据 |
| 快照读的幻读 | MVCC 解决 | RR 级别下复用 Read View,保证同一事务看到相同的数据快照 |
| 当前读的幻读 | 间隙锁解决 | 锁定索引范围,阻止其他事务插入 |
实际工作流程
-- 事务A:混合使用快照读和当前读
START TRANSACTION;
-- 快照读:使用 MVCC,无锁
SELECT * FROM accounts WHERE user_id = 1;
-- 当前读:使用锁机制,加排他锁
SELECT * FROM accounts WHERE user_id = 1 FOR UPDATE;
-- 当前读:使用锁机制,加排他锁
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
COMMIT;
4.3 不同操作的锁使用策略
快照读的锁策略
-- 无锁操作(RR 和 RC 隔离级别)
SELECT * FROM table;
SELECT * FROM table WHERE id = 1;
SELECT * FROM table WHERE name LIKE 'A%';
当前读的锁策略
SELECT FOR UPDATE:
-- 对满足条件的记录加排他锁(X Lock)
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 其他事务无法修改这些记录,直到当前事务提交
SELECT LOCK IN SHARE MODE:
-- 对满足条件的记录加共享锁(S Lock)
SELECT * FROM products WHERE stock > 0 LOCK IN SHARE MODE;
-- 其他事务可以读,但不能修改这些记录
UPDATE/DELETE:
-- 更新前先执行当前读,对记录加排他锁
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 等效于:SELECT * FROM products WHERE id = 1 FOR UPDATE; + 更新操作
INSERT:
-- 对插入的新记录加排他锁
INSERT INTO orders (user_id, amount) VALUES (1, 100);
-- 同时可能对相关索引加间隙锁,防止幻读
4.4 幻读问题的分治解决方案
MVCC 解决快照读的幻读
场景:同一个事务中的多次普通查询
-- 事务A(RR隔离级别)
START TRANSACTION;
-- 第一次查询,生成Read View
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 返回5条记录
-- 事务B插入新订单并提交
-- INSERT INTO orders (user_id, amount) VALUES (1, 100); COMMIT;
-- 事务A第二次查询(快照读)
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 仍然返回5条记录 ✅
-- MVCC通过复用Read View,保证看到相同的数据快照
原理:
- 在RR级别下,事务第一次快照读时生成Read View
- 后续所有快照读都复用这个Read View
- 通过版本链找到对当前Read View可见的数据版本
- 其他事务的插入操作对当前事务不可见
间隙锁解决当前读的幻读
场景:加锁的查询和数据修改操作
-- 事务A(RR隔离级别)
START TRANSACTION;
-- 当前读,加间隙锁
SELECT * FROM orders WHERE user_id = 1 FOR UPDATE;
-- 不仅锁定现有记录,还锁定user_id=1的记录范围(间隙锁)
-- 事务B尝试插入(被阻塞)
-- INSERT INTO orders (user_id, amount) VALUES (1, 200); -- 被阻塞!
-- 事务A可以安全操作
UPDATE orders SET status = 'processed' WHERE user_id = 1;
COMMIT; -- 提交后,事务B的插入才执行
间隙锁的工作原理:
- 锁定索引记录之间的"间隙"
- 防止其他事务在锁定范围内插入新记录
- 确保当前读操作的一致性
间隙锁的触发条件:
-- 以下操作在RR级别可能触发间隙锁:
SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE;
DELETE FROM table WHERE age > 25;
UPDATE table SET status = 'X' WHERE category_id = 5;
五、MVCC 与事务隔离级别的关系
MVCC 是 InnoDB 实现事务隔离级别的基础,不同隔离级别通过"生成 Read View 的时机"来区分:
| 隔离级别 | 生成 Read View 的时机 | 效果说明 |
|---|---|---|
| 读未提交(RU) | 不使用 MVCC,直接读取最新版本 | 可能读取到未提交的数据(脏读) |
| 读已提交(RC) | 每次查询时生成新的 Read View | 只能看到已提交的版本,解决脏读,但可能不可重复读 |
| 可重复读(RR) | 事务启动时生成一次 Read View,之后复用 | 同一事务中多次查询结果一致,解决不可重复读 |
| 串行化(S) | 不依赖 MVCC,通过加锁强制事务串行执行 | 完全避免并发问题,但性能最差 |
关键点:InnoDB 的默认隔离级别是"可重复读(RR)",通过 MVCC 实现了"读不加锁"的可重复读,配合间隙锁解决了幻读问题。
六、MVCC 的优势与局限
优势
- 读写不冲突:读操作无需加锁,写操作只锁定必要的数据,大幅提升并发性能
- 多版本共存:不同事务可以同时操作不同版本的数据,满足多样化的隔离需求
- 事务回滚支持:通过 undo log 实现事务的原子性(ACID 中的 A)
局限
- 存储空间开销:undo log 会占用额外存储空间,需要定期清理(InnoDB 会自动 purge 过期版本)
- 版本链遍历成本:如果数据被频繁修改,版本链会很长,查询时遍历版本链可能影响性能
- 不适用于所有场景:写-写冲突仍需通过锁机制解决(如行锁、间隙锁)
七、总结
MVCC 是数据库领域解决并发问题的伟大发明,它通过"事务 ID + 隐藏字段 + undo log + Read View"的组合,构建了一个多版本的数据世界。核心逻辑可以概括为:
- 写操作生成新版本:每次修改数据时,保留旧版本到 undo log,形成版本链
- 读操作选择可见版本:通过 Read View 的规则,从版本链中找到当前事务可见的版本