大家好,我是十三!欢迎来到十三Tech。

前几年一次性能压测——一个高频读接口(每秒几千 QPS)跑下来,发现 SHOW PROCESSLIST 里大量 Sending data 状态的连接,业务侧反馈"偶发慢查询"。排查了一周,最后发现根因——开发在事务里用了 SELECT ... FOR UPDATE,把本来应该是快照读的查询变成了当前读,跟其他事务的写互相阻塞。改成普通 SELECT 后,QPS 直接翻倍。

这件事让我开始研究 MVCC——很多人讲它只讲"多版本并发控制",太抽象。MVCC 真正的核心是一个叫 ReadView 的小结构——它决定了事务能看见哪些数据版本。理解 ReadView 就理解了 MySQL 事务隔离的全部秘密。

这一篇就聊聊快照读和当前读的差别、ReadView 怎么判断可见性,以及 RC(读已提交)和 RR(可重复读)的真正分野。

MVCC:快照读不加锁的秘密

一、快照读 vs 当前读——一字之差天壤之别

MVCC 把"读"分成两类——很多人没意识到这个区分,结果在错误的地方加锁。

快照读(Snapshot Read):普通的 SELECT(不带 FOR UPDATE / LOCK IN SHARE MODE)。读的是历史版本(基于 undo log 的版本链),不加锁、不阻塞写、不被写阻塞。MVCC 的核心受益场景。

SELECT * FROM users WHERE id = 1;           -- 快照读
SELECT * FROM users WHERE age > 18;         -- 快照读

当前读(Current Read)SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT。读的是最新版本,并加锁(行锁或间隙锁)。

SELECT * FROM users WHERE id = 1 FOR UPDATE;        -- 当前读 + 行锁
UPDATE users SET age = age + 1 WHERE id = 1;        -- 当前读 + 行锁
DELETE FROM users WHERE id = 1;                      -- 当前读 + 行锁

这个区分的解释力:开头那个性能压测的事故,根因就是开发误用 FOR UPDATE——本来快照读能解决的事,被改成当前读,把读变成了"串行"。

当前读为什么必须读最新版本:因为 UPDATEDELETE 是要修改的——必须基于最新数据才能正确改。如果基于历史版本改,会覆盖其他事务的更新——丢失更新(Lost Update)。当前读 + 行锁是 InnoDB 防止丢失更新的核心机制。

二、ReadView——一行可见性的判官

MVCC 的全部秘密浓缩在一个叫 ReadView 的小结构里。

ReadView 包含四个核心字段

  • m_ids:生成 ReadView 时,当前活跃(未提交)的事务 ID 集合
  • min_trx_id:m_ids 里最小的 ID
  • max_trx_id:生成 ReadView 时,系统应该分配给下一个事务的 ID(不是 m_ids 里的最大值,是系统层面的 next)
  • creator_trx_id:生成这个 ReadView 的事务自己的 ID

可见性判断规则——对于某个数据版本的 trx_id(最近修改它的事务 ID):

  1. trx_id == creator_trx_id可见(自己改的)
  2. trx_id < min_trx_id可见(这个修改在 ReadView 生成之前就提交了)
  3. trx_id >= max_trx_id不可见(这个修改是 ReadView 生成之后才开启的事务做的)
  4. min_trx_id <= trx_id < max_trx_id
    • 如果 trx_id ∈ m_ids不可见(修改这事当时还没提交)
    • 如果 trx_id ∉ m_ids可见(修改这事已经提交了)

不可见时,顺着 roll_ptr 找 undo log 的下一个版本,重新套规则——直到找到可见的或链到头。

这套规则覆盖所有情况——四个字段的组合保证了"事务看到的状态"严格符合隔离级别定义。ReadView 的精巧之处在于——它用 O(1) 的字段(活跃事务列表)实现了"任意版本可见性判断"。

ReadView 的结构和可见性判断

三、RC vs RR——ReadView 生成时机决定一切

讲完 ReadView,RC(Read Committed,读已提交)和 RR(Repeatable Read,可重复读)的差别变得极其简单——就是 ReadView 生成的时机不同

RC(读已提交):每次 SELECT 都生成新的 ReadView。所以能看到"截至此刻"所有已提交的事务——别的事务提交后,下一次 SELECT 就能看到。这就是"读已提交"的字面含义。

RR(可重复读):事务里第一次 SELECT 时生成 ReadView,整个事务期间复用这个 ReadView。所以无论别的事务怎么提交,本事务看到的都是"事务开始那一刻"的快照——这就是"可重复读"的字面含义。

-- RR 隔离级别下
START TRANSACTION;
SELECT * FROM users WHERE id = 1;  -- ReadView 在这里生成
-- 此时其他事务把 age 改成 30 并提交
SELECT * FROM users WHERE id = 1;  -- 仍然看到 age=25(旧 ReadView)
COMMIT;

这个时机的差别解释了所有 RC vs RR 的现象——RC 下两次 SELECT 可能不一样(不可重复读现象)、RR 下永远一样(可重复读)。所有 SQL 标准定义的隔离级别现象,本质都是 ReadView 时机的不同

RR 真的解决了所有异常吗?没有。RR 在 InnoDB 下额外用"间隙锁(Next-Key Lock)"防止幻读,但并不是完美——某些场景下(如自身事务里先快照读再当前读)仍可能出现"幻读"。所以严格场景下用 SELECT ... FOR UPDATE 显式加锁,强制当前读。

RC 和 RR 的 ReadView 时机差别

回头看那个压测事故——开发误用 FOR UPDATE 把快照读变当前读,根因是没理解"快照读 vs 当前读"的差别。修复方法:那个查询不需要"基于最新值修改",纯展示用——普通 SELECT 即可,MVCC 让它读历史版本、完全无锁、跟写互不阻塞。

MVCC 真正教会我的,不是"多版本"这个名词,而是用"空间换并发"的设计权衡——保留多份历史版本会消耗内存和 undo 空间,但换来了"读不阻塞写、写不阻塞读"的高并发能力。这个权衡几乎在所有高并发系统里都能看到副本——Copy-On-Write、Immutable Data Structure、CRDT 全是同一种思想的不同实现。

下一篇讲事务隔离级别——把快照读、当前读、MVCC、锁全串起来,看四种隔离级别各自防什么、不防什么。


关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。

联系方式:569893882@qq.com GitHub:@TriTechAI