MVCC
导学
MySQL 的 REPEATABLE READ 隔离级别下,普通 SELECT 几乎不加锁,却能保证可重复读——这是怎么做到的?答案是MVCC(多版本并发控制)。它是 InnoDB 实现高并发的核心技术之一。
定义
MVCC(Multi-Version Concurrency Control):通过保存数据在某一时刻的快照(Snapshot),让读操作不需要加锁就能获取一致的数据视图,从而实现读写互不阻塞的并发控制机制。
MVCC 的实现:隐藏列和 Undo Log
InnoDB 的每行记录都有两个隐藏列:
| 隐藏列 | 含义 |
|---|---|
DB_TRX_ID | 最后修改该行的事务 ID(6 字节) |
DB_ROLL_PTR | 回滚指针,指向 Undo Log 中的上一个版本(7 字节) |
当某行被修改时,InnoDB 不会直接覆盖原数据,而是:
- 将原数据复制到 Undo Log
- 修改当前行的数据
- 将当前行的
DB_ROLL_PTR指向 Undo Log 中的旧版本
这就形成了一条版本链。
ReadView:决定你能看到哪个版本
快照读 vs 当前读
| 读取类型 | SQL 形式 | 是否加锁 | 读取数据来源 |
|---|---|---|---|
| 快照读(Snapshot Read) | 普通 SELECT | 不加锁 | 历史版本(Undo Log) |
| 当前读(Current Read) | SELECT ... FOR UPDATE / IN SHARE MODE | 加锁 | 最新版本 |
ReadView 的生成时机
-- READ COMMITTED:每次查询生成新的 ReadView
-- REPEATABLE READ:事务开始时生成 ReadView,整个事务复用
ReadView 包含以下信息:
creator_trx_id:创建该 ReadView 的事务 IDm_ids:生成 ReadView 时,所有**活跃(未提交)**事务的 ID 列表min_trx_id:m_ids中的最小值max_trx_id:生成 ReadView 时,下一个将被分配的事务 ID
可见性判断规则
对于某行的 DB_TRX_ID:
DB_TRX_ID == creator_trx_id:自己修改的,可见DB_TRX_ID < min_trx_id:在 ReadView 生成前已提交,可见DB_TRX_ID >= max_trx_id:在 ReadView 生成后启动,不可见min_trx_id <= DB_TRX_ID < max_trx_id:- 如果
DB_TRX_ID在m_ids中(活跃事务):不可见,沿 Undo Log 找上一个版本 - 如果不在
m_ids中(已提交):可见
- 如果
SQL 示例:MVCC 实现可重复读
准备测试数据
CREATE TABLE employees (
emp_id INT PRIMARY KEY AUTO_INCREMENT,
emp_name VARCHAR(20),
dept VARCHAR(20),
score DECIMAL(5,2)
);
INSERT INTO employees (emp_name, dept, score) VALUES
('大翔', '技术部', 100),
('白歌', '技术部', NULL);
当前数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
假设初始状态的隐藏列:
DB_TRX_ID = 50(某个已提交的事务修改了这行)DB_ROLL_PTR指向 Undo Log 中的历史版本
场景一:MVCC 实现可重复读
操作过程:两个会话并发执行
| 时间顺序 | 会话 A(事务 ID = 100) | 会话 B(事务 ID = 101) |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:100 | |
| T3 | START TRANSACTION; | |
| T4 | UPDATE employees SET score = 80 WHERE emp_id = 1; | |
| T5 | COMMIT; | |
| T6 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:100(可重复读!) | |
| T7 | COMMIT; | |
| T8 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:80 |
完整 SQL 演示:
-- ========== 会话 A(事务 ID = 100) ==========
START TRANSACTION;
-- 第一次查询:ReadView 生成
-- m_ids = {100}, min_trx_id = 100, max_trx_id = 101, creator_trx_id = 100
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T2):
| score |
|---|
| 100 |
结果解读:
- 会话 A 的 ReadView 在第一次查询时生成
- 当前行的
DB_TRX_ID = 50(假设初始值) 50 < min_trx_id = 100,说明在 ReadView 生成前已提交,可见- 所以读到
score = 100
-- ========== 会话 B(事务 ID = 101) ==========
START TRANSACTION;
-- 修改数据
UPDATE employees SET score = 80 WHERE emp_id = 1;
-- 此时 InnoDB 的操作:
-- 1. 将原数据(score=100, DB_TRX_ID=50)复制到 Undo Log
-- 2. 修改当前行 score = 80
-- 3. 当前行的 DB_TRX_ID 变为 101
-- 4. DB_ROLL_PTR 指向 Undo Log 中的旧版本
COMMIT;
会话 B 提交后的实际数据状态(最新版本):
| emp_id | score | DB_TRX_ID | DB_ROLL_PTR |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 80 |
-- ========== 会话 A 继续 ==========
-- 第二次查询:仍然使用事务开始时生成的 ReadView
-- m_ids = {100}, min_trx_id = 100, max_trx_id = 101
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T6,会话 A 的视角):
| score |
|---|
| 100 |
结果解读:
- 当前行的
DB_TRX_ID = 101(会话 B 的修改) 101 >= max_trx_id = 101,说明在 ReadView 生成后启动,不可见- 沿 Undo Log 找到上一个版本:
score = 100, DB_TRX_ID = 50 50 < min_trx_id = 100,可见- 所以仍然读到
score = 100,实现了可重复读!
-- 会话 A 提交
COMMIT;
-- 新事务(事务 ID = 102),生成新的 ReadView
START TRANSACTION;
SELECT score FROM employees WHERE emp_id = 1;
操作后的数据状态(新事务的视角):
| score |
|---|
| 80 |
结果解读:
- 新事务的 ReadView:
m_ids = {102}, min_trx_id = 102, max_trx_id = 103 - 当前行的
DB_TRX_ID = 101 101 < min_trx_id = 102,说明在 ReadView 生成前已提交,可见- 所以读到
score = 80(最新已提交数据)
COMMIT;
场景二:READ COMMITTED 下的不可重复读
操作过程
-- ========== 会话 A ==========
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 第一次查询:生成 ReadView
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态:
| score |
|---|
| 80 |
-- ========== 会话 B ==========
START TRANSACTION;
UPDATE employees SET score = 60 WHERE emp_id = 1;
COMMIT;
-- ========== 会话 A 继续 ==========
-- 第二次查询:在 RC 下,每次查询生成新的 ReadView
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(RC 级别下):
| score |
|---|
| 60 |
结果解读:
- RC 级别下,每次查询都生成新的 ReadView
- 第二次查询时,新的 ReadView 看到
DB_TRX_ID = 101(或新的事务 ID)已经提交 - 所以读到了最新的
score = 60 - 同一事务内两次查询结果不同,这就是不可重复读
COMMIT;
场景三:自己修改的数据立即可见
-- ========== 会话 A ==========
START TRANSACTION;
-- 查询原始数据
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态:
| score |
|---|
| 60 |
-- 自己修改数据
UPDATE employees SET score = 50 WHERE emp_id = 1;
-- 再次查询
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(同一事务内):
| score |
|---|
| 50 |
结果解读:
- 当前行的
DB_TRX_ID变为当前事务的 ID - 根据可见性规则 1:
DB_TRX_ID == creator_trx_id,自己修改的立即可见 - 所以同一事务内能立即看到自己修改的数据
COMMIT;
MVCC 与 Purge
Undo Log 中的旧版本不能无限增长。InnoDB 的Purge 线程会定期清理不再被任何 ReadView 需要的旧版本。
⚠️ 关键风险:长事务(长时间不提交)会阻止 Purge 清理,导致 Undo Log 膨胀,严重影响性能。生产环境应监控长事务并设置超时。
示例:长事务导致 Undo Log 膨胀
-- 会话 A 开启一个长时间不提交的事务
START TRANSACTION;
-- 执行查询,生成 ReadView
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态:
| score |
|---|
| 50 |
-- ========== 大量其他事务在此期间修改数据 ==========
-- 会话 B、C、D ... 不断修改 employees 表的数据
-- 每次修改都会产生新的 Undo Log 记录
-- 但由于会话 A 的事务未提交,其 ReadView 仍然有效
-- Purge 线程无法清理 Undo Log 中比 ReadView 更早的版本
-- 假设 10 分钟后,会话 A 才提交
COMMIT;
-- 现在 Purge 线程才能清理旧的 Undo Log
结果解读:
- 会话 A 的 ReadView 持有时间长达 10 分钟
- 在这期间,所有被修改的数据的旧版本都必须保留在 Undo Log 中
- Undo Log 文件会不断膨胀,占用大量磁盘空间
- 其他查询如果需要遍历版本链,性能也会下降
- 生产环境建议:设置
innodb_lock_wait_timeout和监控长事务
-- 查看当前运行中的长事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
示例输出:
| trx_id | trx_state | trx_started | trx_seconds | trx_mysql_thread_id | trx_query |
|---|---|---|---|---|---|
| 12345 | RUNNING | 2024-01-15 10:00:00 | 600 | 100 | SELECT score FROM employees WHERE emp_id = 1 |
结果解读:
trx_seconds = 600:该事务已经运行了 600 秒(10 分钟)- 应设置告警,当事务运行时间超过阈值(如 60 秒)时通知 DBA
常见误区
| 误区 | 正解 |
|---|---|
| MVCC 完全不加锁 | 快照读不加锁,但当前读(FOR UPDATE)仍然加锁。 |
| MVCC 没有空间开销 | Undo Log 需要磁盘空间,长事务会导致 Undo Log 膨胀。 |
| RR 级别完全无幻读 | MVCC 解决了快照读的幻读,但当前读(FOR UPDATE)的幻读需要 Gap Lock 解决。 |
示例:快照读与当前读的区别
-- 准备数据(使用 employees 表)
SELECT * FROM employees WHERE emp_id = 1;
当前数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
-- ========== 会话 A ==========
START TRANSACTION;
-- 快照读(普通 SELECT):读取历史版本,不加锁
SELECT * FROM employees WHERE emp_id = 1;
快照读结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
-- 当前读(FOR UPDATE):读取最新版本,加 X 锁
SELECT * FROM employees WHERE emp_id = 1 FOR UPDATE;
当前读结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
COMMIT;
结果解读:
- 快照读和当前读在当前场景下结果相同
- 但如果有其他事务在同时修改数据:
- 快照读读到的是历史版本(不受其他事务影响)
- 当前读读到的是最新版本,且会阻塞其他事务的修改
面试考点
Q:什么是 MVCC?
多版本并发控制。通过保存数据的多个历史版本(Undo Log),让读操作读取历史快照而不加锁,实现读写并发互不阻塞。每行记录有
DB_TRX_ID和DB_ROLL_PTR两个隐藏列。
Q:READ COMMITTED 和 REPEATABLE READ 在 MVCC 下的区别?
RC 每次查询生成新的 ReadView,能看到其他事务已提交的修改;RR 在事务开始时生成 ReadView,事务内始终看到同一快照,实现可重复读。
Q:为什么长事务会导致性能下降?
长事务持有旧的 ReadView,阻止 Purge 线程清理 Undo Log,导致 Undo Log 不断膨胀,占用大量磁盘空间,同时增加查询时遍历版本链的开销。
Q:MVCC 能解决幻读吗?
MVCC 解决了快照读(普通
SELECT)的幻读。当前读(SELECT ... FOR UPDATE)的幻读需要通过 Gap Lock / Next-Key Lock 解决。
小结
- MVCC 通过保存数据的多版本实现读写并发不阻塞
- 每行有
DB_TRX_ID(事务 ID)和DB_ROLL_PTR(回滚指针) - ReadView 决定事务能看到哪个版本的数据
- RC 每次查询新 ReadView,RR 事务开始时生成 ReadView
- 长事务会阻止 Purge,导致 Undo Log 膨胀,应尽量避免
本章结束:到这里,你已经系统掌握了 MySQL 5.7 的核心知识——从基础查询到高级优化,从表结构设计到事务并发控制。数据库是一门实践科学,建议你在真实环境中多加练习,用 EXPLAIN 分析每一条慢 SQL,用慢查询日志发现瓶颈,用事务和锁保证数据一致性。