引言
MVCC(多版本并发控制)是 InnoDB 实现高并发读写的核心机制,它让读写操作互不阻塞,大幅提升了数据库的并发性能。理解 MVCC 原理,对于掌握事务隔离级别的实现至关重要。
本章将深入讲解:
- 四种隔离级别的详细对比
- MVCC 的实现原理
- Read View 与版本链
- 不同隔离级别下 MVCC 的行为
事务隔离级别详解
四种隔离级别对比
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
完整对比表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 锁机制 | 并发性能 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | ⚠️ | ⚠️ | ⚠️ | 无锁 | ⭐⭐⭐⭐⭐ |
| READ COMMITTED | ✅ | ⚠️ | ⚠️ | 行锁 | ⭐⭐⭐⭐ |
| REPEATABLE READ | ✅ | ✅ | ⚠️(部分) | 行锁 + 间隙锁 | ⭐⭐⭐ |
| SERIALIZABLE | ✅ | ✅ | ✅ | 表锁 | ⭐⭐ |
READ UNCOMMITTED(读未提交)
特点:
- 事务可以读取未提交的数据
- 可能读到脏数据
- 并发性能最高
示例:
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 未提交
-- 会话 1
SELECT balance FROM accounts WHERE id = 1; -- 读到 900(脏读!)
-- 会话 2
ROLLBACK; -- 回滚
-- 会话 1
SELECT balance FROM accounts WHERE id = 1; -- 变回 1000
-- 会话 1 发现之前读到的是脏数据!
适用场景:
- 统计报表(允许数据不精确)
- 日志分析(实时性要求不高)
READ COMMITTED(读已提交)
特点:
- 只能读取已提交的数据
- 解决脏读问题
- 可能出现不可重复读
- Oracle、SQL Server 默认级别
示例:
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT; -- 提交
-- 会话 1
SELECT balance FROM accounts WHERE id = 1; -- 读到 900
-- 同一事务内两次读取结果不同(不可重复读)!
MySQL 实现:
- 每次 SELECT 都生成新的 Read View
- 只能看到已提交事务的修改
REPEATABLE READ(可重复读)
特点:
- 同一事务内多次读取结果一致
- 解决脏读和不可重复读
- 部分解决幻读(通过 MVCC + 间隙锁)
- MySQL 默认隔离级别
示例:
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT; -- 提交
-- 会话 1
SELECT balance FROM accounts WHERE id = 1; -- 仍然读到 1000
-- 即使会话 2 已提交,会话 1 仍读到事务开始时的数据!
MySQL 实现:
- 事务开始时生成 Read View
- 整个事务使用同一个 Read View
- 通过 MVCC 看到数据的历史版本
SERIALIZABLE(串行化)
特点:
- 事务串行执行
- 完全避免并发问题
- 性能最差
示例:
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 500;
-- 会话 2
START TRANSACTION;
INSERT INTO accounts (id, balance) VALUES (101, 600);
-- 阻塞!等待会话 1 提交
-- 会话 1
COMMIT;
-- 会话 2
-- 现在可以执行了
实现方式:
- 对读取的数据加共享锁(S 锁)
- 对修改的数据加排他锁(X 锁)
- 读写互斥,写写互斥
MVCC 核心原理
什么是 MVCC
MVCC(Multi-Version Concurrency Control) 多版本并发控制,是一种提高并发性能的机制。
核心思想:
- 数据有多个版本
- 读操作访问历史版本
- 写操作创建新版本
- 读写互不阻塞
graph LR
subgraph 传统锁机制
A1[读操作] -->|加共享锁 | B1[数据]
A2[写操作] -->|加排他锁 | B1
A1 -.阻塞.-> A2
end
subgraph MVCC 机制
A3[读操作] -->|读历史版本 | B2[数据版本 1]
A4[写操作] -->|创建新版本 | B3[数据版本 2]
A3 -.不阻塞.-> A4
end
style A1 fill:#ffcccc
style A2 fill:#ffcccc
style A3 fill:#ccffcc
style A4 fill:#ccffcc
MVCC 实现三要素
InnoDB 通过以下三个要素实现 MVCC:
- 隐藏列:每行数据自带的隐藏字段
- Undo Log:存储数据的历史版本
- Read View:事务读取数据时的快照
-- InnoDB 每行数据的隐藏列
-- 1. DB_TRX_ID:最近修改该行的事务 ID
-- 2. DB_ROLL_PTR:回滚指针,指向 Undo Log
-- 3. DB_ROW_ID:行 ID(无主键时自动生成)
-- 用户不可见,但 InnoDB 内部使用
隐藏列详解
表结构:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance DECIMAL(10,2)
) ENGINE=InnoDB;
实际存储(InnoDB 内部):
+----+---------+-------------+---------------+-------------+
| id | balance | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID |
+----+---------+-------------+---------------+-------------+
| 1 | 1000 | 101 | NULL | 1 |
+----+---------+-------------+---------------+-------------+
隐藏列说明:
| 列名 | 大小 | 说明 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近修改该行的事务 ID |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 Undo Log |
| DB_ROW_ID | 6 字节 | 行 ID(无主键时自动生成) |
Undo Log 版本链
Undo Log 记录数据的历史版本:
初始状态:
+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------------+---------------+
| 1 | 1000 | 100 | NULL |
+----+---------+-------------+---------------+
事务 101 修改:
UPDATE accounts SET balance = 900 WHERE id = 1;
+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------------+---------------+
| 1 | 900 | 101 | → Undo Log |
+----+---------+-------------+---------------+
↓
Undo Log: {balance: 1000, trx_id: 100}
事务 102 修改:
UPDATE accounts SET balance = 800 WHERE id = 1;
+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------------+---------------+
| 1 | 800 | 102 | → Undo Log |
+----+---------+-------------+---------------+
↓
Undo Log: {balance: 900, trx_id: 101}
↓
Undo Log: {balance: 1000, trx_id: 100}
-- 形成版本链!
版本链的特点:
- 每次修改都创建新版本
- 旧版本保存在 Undo Log
- 通过回滚指针串联
- 形成链表结构
Read View 读视图
Read View 是事务读取数据时的快照,包含以下信息:
// Read View 结构
struct ReadView {
trx_id_t creator_trx_id; // 创建 Read View 的事务 ID
trx_id_t min_trx_id; // 活跃事务中最小的事务 ID
trx_id_t max_trx_id; // 下一个要分配的事务 ID
vector<trx_id_t> m_ids; // 活跃事务 ID 列表
};
字段说明:
| 字段 | 说明 | 示例 |
|---|---|---|
| creator_trx_id | 当前事务 ID | 105 |
| min_trx_id | 活跃事务最小 ID | 101 |
| max_trx_id | 下一个事务 ID | 110 |
| m_ids | 活跃事务 ID 列表 | [101, 103, 105] |
活跃事务: 已启动但未提交的事务
可见性判断规则
当事务读取数据时,通过以下规则判断版本可见性:
graph TD
A[读取数据行] --> B{获取 DB_TRX_ID}
B --> C{DB_TRX_ID = 当前事务 ID?}
C -->|是 | D[可见 - 自己修改的]
C -->|否 | E{DB_TRX_ID < min_trx_id?}
E -->|是 | F[可见 - 已提交事务]
E -->|否 | G{DB_TRX_ID >= max_trx_id?}
G -->|是 | H[不可见 - 未来事务]
G -->|否 | I{DB_TRX_ID 在 m_ids 中?}
I -->|是 | J[不可见 - 未提交事务]
I -->|否 | K[可见 - 已提交事务]
style D fill:#ccffcc
style F fill:#ccffcc
style H fill:#ffcccc
style J fill:#ffcccc
style K fill:#ccffcc
判断规则详解:
-- 规则 1:自己修改的数据可见
DB_TRX_ID == creator_trx_id → 可见
-- 规则 2:已提交事务的修改可见
DB_TRX_ID < min_trx_id → 可见
-- 规则 3:未来事务的修改不可见
DB_TRX_ID >= max_trx_id → 不可见
-- 规则 4:活跃事务的修改不可见
DB_TRX_ID in m_ids → 不可见
DB_TRX_ID not in m_ids → 可见(已提交)
示例:
Read View:
- creator_trx_id: 105
- min_trx_id: 101
- max_trx_id: 110
- m_ids: [101, 103, 105]
数据版本判断:
- DB_TRX_ID = 105 → 可见(自己修改的)
- DB_TRX_ID = 100 → 可见(100 < 101,已提交)
- DB_TRX_ID = 101 → 不可见(在 m_ids 中,未提交)
- DB_TRX_ID = 102 → 可见(不在 m_ids 中,已提交)
- DB_TRX_ID = 110 → 不可见(110 >= 110,未来事务)
不同隔离级别的 MVCC 行为
READ COMMITTED 的 MVCC
特点:每次 SELECT 都生成新的 Read View
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Read View 1: min_trx_id=100, max_trx_id=105, m_ids=[]
-- 读到 balance=1000
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- 会话 1
SELECT balance FROM accounts WHERE id = 1;
-- 生成新的 Read View 2: min_trx_id=101, max_trx_id=106, m_ids=[]
-- 读到 balance=900(会话 2 已提交,可见)
-- 不可重复读!
时序图:
会话 1 会话 2
------ ------
BEGIN
SELECT (Read View 1)
balance=1000
BEGIN
UPDATE balance=900
COMMIT
SELECT (Read View 2)
balance=900 ← 读到新数据
REPEATABLE READ 的 MVCC
特点:事务开始生成一次 Read View,全程使用
-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Read View: min_trx_id=100, max_trx_id=105, m_ids=[]
-- 读到 balance=1000
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- 会话 1
SELECT balance FROM accounts WHERE id = 1;
-- 使用同一个 Read View
-- 读到 balance=1000(版本 100 < min_trx_id,可见)
-- 可重复读!
时序图:
会话 1 会话 2
------ ------
BEGIN
SELECT (Read View)
balance=1000
BEGIN
UPDATE balance=900
COMMIT
SELECT (同一 Read View)
balance=1000 ← 仍读旧数据
幻读的 MVCC 解决方案
MVCC 解决快照读(普通 SELECT)的幻读:
-- 会话 1(REPEATABLE READ)
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 500;
-- 返回 10 条记录
-- Read View: max_trx_id=105
-- 会话 2
INSERT INTO accounts (id, balance) VALUES (101, 600);
COMMIT;
-- 会话 1
SELECT * FROM accounts WHERE balance > 500;
-- 仍然返回 10 条记录!
-- 新插入的事务 ID=106 >= max_trx_id=105,不可见
当前读(加锁读)的幻读通过间隙锁解决:
-- 会话 1
SELECT * FROM accounts WHERE balance > 500 FOR UPDATE;
-- 加间隙锁,阻止其他事务插入
-- 会话 2
INSERT INTO accounts (id, balance) VALUES (101, 600);
-- 阻塞!等待会话 1 提交
MVCC 实战分析
案例 1:读写不冲突
-- 会话 1(读)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 会话 2(写)
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 不阻塞!
-- 会话 1
COMMIT;
-- 会话 2
COMMIT;
-- 读写并发,性能高
案例 2:写写冲突
-- 会话 1
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 800 WHERE id = 1;
-- 阻塞!等待会话 1 释放锁
-- 会话 1
COMMIT;
-- 会话 2
-- 获得锁,继续执行
COMMIT;
案例 3:长事务问题
-- 长事务
START TRANSACTION;
SELECT * FROM large_table;
-- 长时间未提交
-- 问题:
-- 1. Read View 长期持有,无法清理旧版本
-- 2. Undo Log 无法回收,占用空间
-- 3. 其他事务看不到最新数据
-- 解决:
-- 1. 避免长事务
-- 2. 定期监控 INNODB_TRX
-- 3. 设置 innodb_max_undo_logs
注意事项
1. MVCC 的代价
空间开销:
- 每行数据增加隐藏列(19 字节)
- Undo Log 占用存储空间
- 需要定期清理旧版本
性能开销:
- 生成 Read View 的成本
- 版本链遍历的成本
- Purge 线程清理旧版本
2. Undo Log 清理
-- Purge 线程负责清理不再需要的 Undo Log
-- 当没有事务需要某个版本时,Purge 会删除它
-- 查看 Purge 状态
SHOW ENGINE INNODB STATUS;
-- 配置 Purge 线程数
innodb_purge_threads = 4 -- 默认 4 个线程
3. 版本链过长
-- 问题:版本链过长,查询性能下降
-- 原因:长事务未提交,旧版本无法清理
-- 监控:
SELECT
trx_id,
trx_state,
trx_started,
TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) as duration_seconds
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
-- 解决:
-- 1. 避免长事务
-- 2. 杀掉长事务 KILL
4. 隔离级别选择
-- 推荐配置:
-- 大多数场景(默认)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 需要与 Oracle 兼容
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 统计报表(允许脏读)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 特殊场景(完全串行)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
总结
MVCC 核心要点
- 三要素:隐藏列、Undo Log、Read View
- 版本链:通过回滚指针串联历史版本
- 可见性:通过 Read View 判断版本是否可见
- 读写不阻塞:读历史版本,写新版本
隔离级别与 MVCC
| 隔离级别 | Read View 生成时机 | 并发问题 |
|---|---|---|
| READ UNCOMMITTED | 不生成 Read View | 脏读、不可重复读、幻读 |
| READ COMMITTED | 每次 SELECT | 不可重复读、幻读 |
| REPEATABLE READ | 事务开始 | 部分幻读 |
| SERIALIZABLE | 加锁 | 无 |
最佳实践
- 使用默认隔离级别:REPEATABLE READ
- 避免长事务:及时提交或回滚
- 监控 Undo Log:防止版本链过长
- 理解快照读 vs 当前读:选择正确的查询方式
下一步
掌握 MVCC 后,下一章我们将学习:
- 锁机制详解(行锁、表锁、间隙锁)
- 死锁分析与排查
- 锁优化与并发控制
参考资料
- MySQL 官方文档 - MVCC
- 《高性能 MySQL》第 6 章:MySQL 架构与历史
- 《MySQL 技术内幕:InnoDB 存储引擎》第 7 章:事务
- Innodb 锁与 MVCC