大家好,我是十三!欢迎来到十三Tech。
前几年一次性能压测——一个高频读接口(每秒几千 QPS)跑下来,发现 SHOW PROCESSLIST 里大量 Sending data 状态的连接,业务侧反馈"偶发慢查询"。排查了一周,最后发现根因——开发在事务里用了 SELECT ... FOR UPDATE,把本来应该是快照读的查询变成了当前读,跟其他事务的写互相阻塞。改成普通 SELECT 后,QPS 直接翻倍。
这件事让我开始研究 MVCC——很多人讲它只讲"多版本并发控制",太抽象。MVCC 真正的核心是一个叫 ReadView 的小结构——它决定了事务能看见哪些数据版本。理解 ReadView 就理解了 MySQL 事务隔离的全部秘密。
这一篇就聊聊快照读和当前读的差别、ReadView 怎么判断可见性,以及 RC(读已提交)和 RR(可重复读)的真正分野。
一、快照读 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 UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT。读的是最新版本,并加锁(行锁或间隙锁)。
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——本来快照读能解决的事,被改成当前读,把读变成了"串行"。
当前读为什么必须读最新版本:因为 UPDATE、DELETE 是要修改的——必须基于最新数据才能正确改。如果基于历史版本改,会覆盖其他事务的更新——丢失更新(Lost Update)。当前读 + 行锁是 InnoDB 防止丢失更新的核心机制。
二、ReadView——一行可见性的判官
MVCC 的全部秘密浓缩在一个叫 ReadView 的小结构里。
ReadView 包含四个核心字段:
m_ids:生成 ReadView 时,当前活跃(未提交)的事务 ID 集合min_trx_id:m_ids 里最小的 IDmax_trx_id:生成 ReadView 时,系统应该分配给下一个事务的 ID(不是 m_ids 里的最大值,是系统层面的 next)creator_trx_id:生成这个 ReadView 的事务自己的 ID
可见性判断规则——对于某个数据版本的 trx_id(最近修改它的事务 ID):
trx_id == creator_trx_id→ 可见(自己改的)trx_id < min_trx_id→ 可见(这个修改在 ReadView 生成之前就提交了)trx_id >= max_trx_id→ 不可见(这个修改是 ReadView 生成之后才开启的事务做的)min_trx_id <= trx_id < max_trx_id:- 如果
trx_id ∈ m_ids→ 不可见(修改这事当时还没提交) - 如果
trx_id ∉ m_ids→ 可见(修改这事已经提交了)
- 如果
不可见时,顺着 roll_ptr 找 undo log 的下一个版本,重新套规则——直到找到可见的或链到头。
这套规则覆盖所有情况——四个字段的组合保证了"事务看到的状态"严格符合隔离级别定义。ReadView 的精巧之处在于——它用 O(1) 的字段(活跃事务列表)实现了"任意版本可见性判断"。
三、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 显式加锁,强制当前读。
回头看那个压测事故——开发误用 FOR UPDATE 把快照读变当前读,根因是没理解"快照读 vs 当前读"的差别。修复方法:那个查询不需要"基于最新值修改",纯展示用——普通 SELECT 即可,MVCC 让它读历史版本、完全无锁、跟写互不阻塞。
MVCC 真正教会我的,不是"多版本"这个名词,而是用"空间换并发"的设计权衡——保留多份历史版本会消耗内存和 undo 空间,但换来了"读不阻塞写、写不阻塞读"的高并发能力。这个权衡几乎在所有高并发系统里都能看到副本——Copy-On-Write、Immutable Data Structure、CRDT 全是同一种思想的不同实现。
下一篇讲事务隔离级别——把快照读、当前读、MVCC、锁全串起来,看四种隔离级别各自防什么、不防什么。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
