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_IDDB_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 会怎么做?

  1. 把原来的行数据拷贝到 undo log(回滚日志)
  2. 更新当前行的 age=30,并设置 DB_TRX_ID=200
  3. 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(回滚日志) 存的是行数据的旧版本,主要有两个作用:

  1. 事务回滚:如果事务执行失败,需要根据 undo log 把数据恢复到事务开始前的状态
  2. 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_idm_ids 里的最小值
  • max_trx_id:下一个要分配的事务 ID(当前最大事务 ID + 1)
  • creator_trx_id:创建这个 Read View 的事务 ID

Read View 的判断规则

当一行数据的 DB_TRX_ID 与 Read View 对比时,有这么几条规则:

  1. 如果 DB_TRX_ID < min_trx_id可见(这个版本在快照生成前就已经提交了)
  2. 如果 DB_TRX_ID >= max_trx_id不可见(这个版本是快照生成后才创建的)
  3. 如果 min_trx_id <= DB_TRX_ID < max_trx_id
    • 如果 DB_TRX_IDm_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 会:

  1. 找到这行数据的当前版本:DB_TRX_ID=300

  2. 用事务200的 Read View 判断:

    • min_trx_id=200 <= DB_TRX_ID=300 < max_trx_id=301
    • DB_TRX_ID=300m_ids=[200, 300] 里 ✓
    • 结论:不可见!(事务300在快照生成时还没提交)
  3. 顺着版本链找到上一个版本:DB_TRX_ID=100

  4. 继续判断:

    • 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 UPDATE
  • SELECT ... LOCK IN SHARE MODE
  • INSERT
  • UPDATE
  • DELETE

为啥要当前读?

因为写操作必须基于最新数据,否则会覆盖其他事务的修改。

-- 事务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 的关系讲清楚,面试官绝对服气。


实战代码都在我本地跑过,你可以放心复制。 如果有问题,欢迎评论区交流!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐