并发读写是数据库永恒的难题:一个事务正在修改某行,另一个事务此刻来读,它该看到旧值还是新值?最朴素的做法是加读锁——读之前先排队等写完。但这会让读写互相阻塞,吞吐量直线下降。InnoDB 给出的答案是 MVCC(Multi-Version Concurrency Control,多版本并发控制):让读不加锁、写也不阻塞读,二者各看各的"版本",井水不犯河水。

场景:为什么需要多版本

想象一个银行系统,后台有个统计任务要遍历几百万行账户算总额,跑十几秒。与此同时无数转账事务在并发修改余额。如果统计任务用读锁锁住每一行,转账全得排队;如果不锁,统计读到的总额可能是一半旧值一半新值,对不上账。

MVCC 的思路类似 Git:每次修改不是覆盖原值,而是生成一个新"版本",旧版本依然保留。读操作根据自己启动的时间点,去找"对我可见的那个版本"。统计任务在启动瞬间确定了一个快照,之后无论别人怎么改,它看到的永远是那一刻的一致数据。这就是快照读(Snapshot Read)

机制:隐藏列、undo log 与版本链

要理解 MVCC,先看 InnoDB 给每行偷偷加的隐藏列:

  • DB_TRX_ID:最近修改这行的事务 ID。
  • DB_ROLL_PTR:回滚指针,指向 undo log 中该行的上一个版本。
  • DB_ROW_ID:隐藏主键(无显式主键时才用)。

每当一个事务更新某行,InnoDB 会把旧值写进 undo log,新行的 DB_ROLL_PTR 指向那条 undo 记录。这样旧版本被串成一条版本链

1
2
当前行(trx_id=105) → undo(trx_id=102) → undo(trx_id=98) → ...
↑ DB_ROLL_PTR 一路往回指

读的时候沿着这条链往回走,直到找到对当前事务可见的版本。所以 undo log 不只是用来回滚,它同时支撑了 MVCC 的"历史版本"。

Read View:可见性的裁判

光有版本链还不够,得有规则判断"哪个版本对我可见"。这个规则的载体叫 Read View,本质是一个事务在某个时刻拍下的"活跃事务快照",包含四个关键字段:

  • m_ids:生成 Read View 时所有**活跃(未提交)**事务的 ID 列表。
  • min_trx_idm_ids 中的最小值。
  • max_trx_id:下一个将要分配的事务 ID(即当前最大 ID + 1)。
  • creator_trx_id:创建该 Read View 的事务自己的 ID。

判断某个版本的 trx_id 是否可见,逻辑是这样的:

1
2
3
4
5
6
若 trx_id == creator_trx_id        → 可见(自己改的)
若 trx_id < min_trx_id → 可见(生成快照前已提交)
若 trx_id >= max_trx_id → 不可见(生成快照后才开启的事务)
若 min_trx_id <= trx_id < max_trx_id:
trx_id 在 m_ids 中 → 不可见(当时还活跃/未提交)
trx_id 不在 m_ids 中 → 可见(当时已提交)

如果当前版本不可见,就顺着 DB_ROLL_PTR 取上一个版本,重复判断,直到找到可见版本或链尾。

隔离级别的本质:Read View 的生成时机

SQL 标准定义了四种隔离级别,InnoDB 默认是 RR(可重复读)。MVCC 主要服务于 RC 和 RR,二者的差异精妙地只在一个点上——Read View 什么时候生成

  • RC(读已提交):每执行一条 SELECT 都重新生成一个 Read View。所以别人一提交,下次查询就能看到,这就是为什么 RC 会出现"不可重复读"。
  • RR(可重复读):事务中第一次快照读时生成 Read View,之后整个事务复用它。于是无论别人提交多少次,本事务看到的快照始终如一。
1
2
3
4
5
6
7
8
9
10
11
-- 会话 A(RR 隔离级别)
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 此刻生成 Read View,读到 100

-- 会话 B
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;

-- 会话 A 再次查询
SELECT balance FROM account WHERE id = 1; -- 仍然是 100!复用旧 Read View
COMMIT;

同样的脚本在 RC 下,A 的第二次查询会读到 200。理解了"Read View 生成时机",两个隔离级别的所有行为差异都能推导出来,不用死记。

工程权衡与边界

快照读 vs 当前读。 MVCC 只作用于普通 SELECT(快照读)。一旦你用了 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE,或者 UPDATE / DELETE,读到的是最新已提交版本并加锁,这叫当前读。当前读不走 Read View,它必须看到最新数据,否则更新就丢失了。这也是为什么 RR 下普通查询读到旧值、但 UPDATE 却作用在新值上——很多人在这里栽跟头。

RR 能解决幻读吗? 部分能。快照读层面,RR 靠 Read View 天然避免幻读(多读几次行数不变)。但当前读层面会出现幻读,InnoDB 额外用**间隙锁(Next-Key Lock)**来堵住,这属于锁机制的范畴,MVCC 本身管不到。

长事务的代价。 这是 MVCC 最大的线上隐患。只要一个事务的 Read View 还活着,它可能引用的旧版本就不能被 purge(清理)。一个忘了提交的长事务,会让 undo log 不断堆积,撑大 ibdata/undo 表空间,且查询要沿着越来越长的版本链回溯,性能逐步劣化。排查时盯住 information_schema.innodb_trxtrx_started 很早的事务:

1
2
3
4
SELECT trx_id, trx_started, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;

只读事务的优化。 显式声明 START TRANSACTION READ ONLY,InnoDB 不会为其分配事务 ID,能减少 m_ids 的负担,对纯查询场景是免费的性能优化。

小结

MVCC 用"空间换并发":通过 undo log 保留多版本、用 Read View 做可见性裁决,实现了读不阻塞写、写不阻塞读。RC 与 RR 的区别浓缩成一句话——Read View 是每次查询生成还是事务级复用。理解快照读与当前读的分野,是用对隔离级别、避开长事务与版本链陷阱的关键。下次再遇到"为什么我读到的是旧数据",先问自己:这是快照读还是当前读,我的 Read View 是什么时候拍的。