MySQL MVCC机制深度解析
MySQL MVCC 机制深度解析
你有没有想过,MySQL 在 REPEATABLE READ 隔离级别下,同一个事务内多次查询,结果为什么能保持一致?
答案就是 MVCC(Multi-Version Concurrency Control,多版本并发控制)。
今天咱们就来扒一扒 MVCC 的实现原理,看完这篇,你就能理解 MySQL 是怎么做到"快照读"的。
MVCC 解决什么问题?
先说说没有 MVCC 会怎样:
- 读事务和写事务之间会互相阻塞(只能用读写锁)
- 并发性能极差,基本只能排队执行
MVCC 的思路是:读不加锁,读写不冲突。怎么做到?保存数据的多个版本,读操作只看某个时间点的快照,不管后面的修改。
这就是为什么叫"多版本"——同一行数据,可能有多个版本同时存在。
三个隐藏字段
InnoDB 的每一行数据,除了你定义的列,还有三个隐藏字段:
| 字段名 | 大小 | 作用 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最后修改这行数据的事务 ID |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 里的上一个版本 |
| DB_ROW_ID | 6 字节 | 隐藏主键(如果表没有主键,InnoDB 会自动生成) |
重点:DB_TRX_ID 和 DB_ROLL_PTR 是实现 MVCC 的关键。
举个栗子
假设我们有张用户表:
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
);
INSERT INTO users VALUES (1, 'Alice', 25);
插入后,这行数据在磁盘上是这样的:
[id=1, name='Alice', age=25, DB_TRX_ID=100, DB_ROLL_PTR=null, DB_ROW_ID=1]
假设事务 ID 是 100。
现在事务 200 执行 UPDATE:
-- 事务200
START TRANSACTION;
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;
InnoDB 会怎么做?
- 把原来的行数据拷贝到 undo log(回滚日志)
- 更新当前行的
age=30,并设置DB_TRX_ID=200 - 把
DB_ROLL_PTR指向 undo log 里那个旧版本
所以现在磁盘上有个"版本链":
当前行: [id=1, name='Alice', age=30, DB_TRX_ID=200, DB_ROLL_PTR=指向undo_log]
↓
undo log: [id=1, name='Alice', age=25, DB_TRX_ID=100, DB_ROLL_PTR=null]
如果再来个事务 300 修改:
-- 事务300
START TRANSACTION;
UPDATE users SET name = 'Bob' WHERE id = 1;
COMMIT;
版本链会变成:
当前行: [id=1, name='Bob', age=30, DB_TRX_ID=300, DB_ROLL_PTR=指向undo_log_2]
↓
undo log 2: [id=1, name='Alice', age=30, DB_TRX_ID=200, DB_ROLL_PTR=指向undo_log_1]
↓
undo log 1: [id=1, name='Alice', age=25, DB_TRX_ID=100, DB_ROLL_PTR=null]
这就是"多版本"的由来——通过版本链,能追溯到这行数据的所有历史版本。
undo log 是啥?
undo log(回滚日志) 存的是行数据的旧版本,主要有两个作用:
- 事务回滚:如果事务执行失败,需要根据 undo log 把数据恢复到事务开始前的状态
- MVCC 快照读:其他事务需要根据 undo log 读取旧版本的数据
undo log 的类型
- INSERT undo log:插入操作产生的 undo log,事务提交后可以直接删除(因为只有当前事务能看见这行数据)
- UPDATE undo log:修改/删除操作产生的 undo log,不能被立即删除,因为可能还有其他事务在读旧版本(purge 线程会在合适的时候清理)
Read View(快照)是啥?
前面说了,MVCC 的核心是"快照读"——每个事务开始时,生成一个快照,后面的查询都基于这个快照。
这个快照在 InnoDB 里叫 Read View(快照视图),它记录了:
m_ids:当前活跃的(未提交的)事务 ID 列表min_trx_id:m_ids里的最小值max_trx_id:下一个要分配的事务 ID(当前最大事务 ID + 1)creator_trx_id:创建这个 Read View 的事务 ID
Read View 的判断规则
当一行数据的 DB_TRX_ID 与 Read View 对比时,有这么几条规则:
- 如果
DB_TRX_ID < min_trx_id:可见(这个版本在快照生成前就已经提交了) - 如果
DB_TRX_ID >= max_trx_id:不可见(这个版本是快照生成后才创建的) - 如果
min_trx_id <= DB_TRX_ID < max_trx_id:- 如果
DB_TRX_ID在m_ids里:不可见(这个事务还没提交) - 如果
DB_TRX_ID不在m_ids里:可见(这个事务已经提交了)
- 如果
如果当前版本不可见,就顺着 版本链(通过 DB_ROLL_PTR)找到上一个版本,再用同样的规则判断,直到找到可见的版本。
实战:MVCC 的工作流程
说了这么多理论,咱们来个实际的例子。
假设初始数据:
-- 事务100
INSERT INTO users VALUES (1, 'Alice', 25);
COMMIT;
现在有两个事务并发执行:
时间轴:
T1: 事务200开始(Read View生成)
T2: 事务300开始(Read View生成)
T3: 事务300执行:UPDATE users SET age = 30 WHERE id = 1; COMMIT;
T4: 事务200执行:SELECT * FROM users WHERE id = 1;
T1:事务200生成 Read View
假设这个时候,只有事务200和事务300是活跃的,所以:
事务200的Read View:
m_ids = [200, 300]
min_trx_id = 200
max_trx_id = 301
creator_trx_id = 200
T3:事务300提交修改
事务300把 age 改成 30,并提交。现在这行数据的版本链是:
当前行: [id=1, name='Alice', age=30, DB_TRX_ID=300, DB_ROLL_PTR=→]
↓
undo log: [id=1, name='Alice', age=25, DB_TRX_ID=100, DB_ROLL_PTR=null]
T4:事务200查询
事务200执行 SELECT,InnoDB 会:
-
找到这行数据的当前版本:
DB_TRX_ID=300 -
用事务200的 Read View 判断:
min_trx_id=200 <= DB_TRX_ID=300 < max_trx_id=301✓DB_TRX_ID=300在m_ids=[200, 300]里 ✓- 结论:不可见!(事务300在快照生成时还没提交)
-
顺着版本链找到上一个版本:
DB_TRX_ID=100 -
继续判断:
DB_TRX_ID=100 < min_trx_id=200✓- 结论:可见!
所以事务200读到的 age 还是 25,尽管事务300已经改成 30 了。
这就是 REPEATABLE READ 的奥秘:Read View 在事务开始时生成,后面所有的快照读都基于同一个 Read View,所以多次查询结果一致。
READ COMMITTED 和 REPEATABLE READ 的区别
前面说的都是 REPEATABLE READ(MySQL 默认),那 READ COMMITTED 呢?
核心区别:Read View 的生成时机不同!
- REPEATABLE READ:Read View 在第一次快照读时生成,后面都用这个 Read View
- READ COMMITTED:每次快照读都会生成一个新的 Read View
所以 READ COMMITTED 能读到其他事务已提交的最新数据,而 REPEATABLE READ 只能读到事务开始时的快照。
验证一下
-- 会话A(REPEATABLE READ)
START TRANSACTION;
SELECT * FROM users WHERE id = 1; -- age=25,同时生成Read View
-- 会话B
START TRANSACTION;
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;
-- 会话A
SELECT * FROM users WHERE id = 1; -- 还是age=25!因为Read View没变
-- 会话A(READ COMMITTED)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM users WHERE id = 1; -- age=25,生成Read View1
-- 会话B
START TRANSACTION;
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;
-- 会话A
SELECT * FROM users WHERE id = 1; -- age=30!因为重新生成了Read View2
当前读 vs 快照读
前面说的都是"快照读"(Snapshot Read),也就是普通的 SELECT。
但有些操作是"当前读"(Current Read),会读取最新版本的数据:
SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEINSERTUPDATEDELETE
为啥要当前读?
因为写操作必须基于最新数据,否则会覆盖其他事务的修改。
-- 事务200
START TRANSACTION;
SELECT * FROM users WHERE id = 1; -- 快照读,age=25
-- 事务300
START TRANSACTION;
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;
-- 事务200
UPDATE users SET age = age + 1 WHERE id = 1; -- 当前读,读到age=30,结果是31
COMMIT;
注意:UPDATE 是先当前读(拿到最新版本),再修改,最后写回。
purge 线程:清理旧版本
undo log 不能无限增长,否则版本链会越来越长,查询性能下降。
InnoDB 有个 purge 线程,负责清理那些"不再被任何事务需要的"旧版本。
判断标准:如果某个旧版本的 DB_TRX_ID 小于所有活跃事务的 min_trx_id,那这个旧版本就可以被清理了(因为不可能再有事务访问它)。
实战建议
1. 避免长事务
长事务会阻止 purge 线程清理旧版本,导致 undo log 膨胀,占用大量磁盘空间。
-- 查看长事务
SELECT * FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;
建议:事务要尽量短小,用完马上提交。
2. 慎用 SELECT … FOR UPDATE
FOR UPDATE 是当前读,会加排他锁,容易引发死锁。
建议:只在必要时用,并且尽量按固定顺序加锁。
3. 别在事务里做耗时操作
如果你在事务里调用了外部 API、发邮件、或者睡了一觉,那这个事务会长时间占用锁和 undo log,严重影响性能。
建议:事务里只做数据库操作,别掺和其他逻辑。
总结
- MVCC 的核心是"多版本"——通过版本链保存数据的多个历史版本
- 每一行数据有隐藏字段
DB_TRX_ID(最后修改的事务 ID)和DB_ROLL_PTR(指向 undo log) - Read View(快照)记录了事务开始时,哪些事务是活跃的
- 快照读时,会根据 Read View 判断哪个版本可见
- REPEATABLE READ 的 Read View 在第一次快照读时生成,READ COMMITTED 每次快照读都生成新的 Read View
- 当前读(
UPDATE/DELETE/INSERT/SELECT ... FOR UPDATE)会读取最新版本,并且加锁
如果你能把版本链、Read View、undo log 的关系讲清楚,面试官绝对服气。
实战代码都在我本地跑过,你可以放心复制。 如果有问题,欢迎评论区交流!
更多推荐



所有评论(0)