深色模式
03 · 隔离级别与 MVCC
数据库最抽象的一篇。必须画图,画一次就通了。
一句话本质
并发事务会互相干扰。隔离级别决定"允许干扰到什么程度",MVCC 用"多版本"实现"读不阻塞写、写不阻塞读"。
生活类比:同一份文件的多个版本
你在写一份文档(事务 A),同时同事在看(事务 B)。
加锁方案(悲观):
A 开始写 > 把文档锁住 > B 想看得排队
> 安全,但 B 干等着,效率低
MVCC 方案(乐观):
A 修改时,生成"版本 2",原来的"版本 1"保留
B 来读 > 读"版本 1"(他开始时看到的那个版本)
> B 不用等,A 也不用等
> 等 A 提交后,B 下次读才能看到版本 2核心:每个人看到的是"自己开始时的那份快照",而不是"最新的"。
核心机制
一、三种异常(必须能举例)
表:account(id, balance) 初始 id=1, balance=100
【脏读】读到了别人未提交的修改
事务 A:UPDATE balance = 200 (未提交)
事务 B:SELECT balance > 读到 200 < 脏读
事务 A:ROLLBACK > 实际还是 100
> B 读到的 200 是"从未存在过"的数据
【不可重复读】同一事务内两次读同一行,值不同
事务 B:SELECT balance > 100
事务 A:UPDATE balance = 200,COMMIT
事务 B:SELECT balance > 200 < 不可重复读
> 同一事务内,读到了不同的值
【幻读】同一事务内两次范围查询,行数不同
事务 B:SELECT * FROM account WHERE id > 0 > 1 行
事务 A:INSERT INTO account VALUES(2, 50),COMMIT
事务 B:SELECT * FROM account WHERE id > 0 > 2 行 < 幻读
> 多出了"幻影行"不可重复读 vs 幻读的关键区别:
- 不可重复读:同一行的内容变了(UPDATE/DELETE)
- 幻读:结果集的行数变了(INSERT)
为什么这个区分重要:因为解法不同——不可重复读用行锁/MVCC 快照就够,幻读需要间隙锁(锁住"可能插入的位置")。
二、四个隔离级别
| 级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| 读未提交(RU) | ✅ | ✅ | ✅ | 什么都不做 |
| 读已提交(RC) | ❌ | ✅ | ✅ | 每次 SELECT 生成新 ReadView |
| 可重复读(RR,MySQL 默认) | ❌ | ❌ | ⚠️ 基本解决 | 事务开始时生成 ReadView + 间隙锁 |
| 串行化 | ❌ | ❌ | ❌ | 所有读加共享锁,写加排他锁 |
为什么 MySQL 默认是 RR 而不是 RC:
- 早期 MySQL 的 binlog 只有 statement 格式,RC 级别下主从可能不一致(同一 SQL 在主从执行顺序不同,结果不同)。RR 能避免。
- RR 在同一事务内读到一致快照,对业务更友好。
但现在很多公司改成 RC(比如阿里):RC 的锁范围更小、并发更高,且现在 binlog 默认是 row 格式,不存在一致性问题。
三、MVCC 的三个组成部分
组成 1:隐藏字段
InnoDB 给每行加了三个隐藏字段:
+-- id 主键
+-- balance 业务字段
+-- DB_TRX_ID 最后修改这一行的事务 ID
+-- DB_ROLL_PTR 指向 undo log 的指针(版本链的链条)
+-- DB_ROW_ID 没有主键时用的隐藏行 ID组成 2:undo log 版本链
每次修改都会产生一个新版本,用 DB_ROLL_PTR 串成链,链头是最新版本。链上每个版本都带着"是谁改的"(trx_id),所以能不能看见它,取决于读它的事务手里的那份 ReadView——就是下一节。
组成 3:ReadView(读视图)
ReadView 是"我在这个时刻能看到哪些事务的结果"的一份快照。四个关键字段:
m_ids :生成 ReadView 时,所有"活跃(未提交)"的事务 ID 列表
min_trx_id :m_ids 里最小的
max_trx_id :生成 ReadView 时,下一个将要分配的事务 ID
creator_trx_id:创建这个 ReadView 的事务自己的 ID可见性判断规则(对版本链上每个版本,从链头开始判断):
设版本的 trx_id = T
① T == creator_trx_id
> 是我自己改的,可见 ✅
② T < min_trx_id
> 这个事务在我开始前就提交了,可见 ✅
③ T >= max_trx_id
> 这个事务在我生成 ReadView 之后才开启,不可见 ❌
④ min_trx_id <= T < max_trx_id
> 看 T 是否在 m_ids 里
在(还活跃)> 不可见 ❌
不在(已提交)> 可见 ✅
如果不可见,就顺着版本链往下找,直到找到可见的版本四、RC 和 RR 的唯一区别:ReadView 的生成时机
RC(读已提交):
每次 SELECT 都生成一个新的 ReadView
> 所以能读到别人刚提交的数据(不可重复读)
RR(可重复读):
只在事务的第一次 SELECT 时生成 ReadView,之后复用
> 所以整个事务看到的是同一份快照(可重复读)这是整篇最核心的一句话。MVCC 的实现两者完全一样,差别只在这一个时间点。
五、快照读 vs 当前读
快照读(普通 SELECT):
SELECT * FROM user WHERE id = 1;
> 走 MVCC,读版本链里"我能看到的那个版本"
> 不加锁,不阻塞
当前读(读最新版本,并加锁):
SELECT * FROM user WHERE id = 1 FOR UPDATE; > 加排他锁
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE; > 加共享锁
UPDATE / DELETE / INSERT > 隐式当前读
> 读最新的已提交版本,并加锁为什么 UPDATE 必须当前读:如果 UPDATE 读的是快照,那它可能基于一个旧版本修改,覆盖掉别人刚提交的数据(丢失更新)。
六、幻读怎么解决
RR 级别下,MVCC 只能解决"快照读"的幻读:
事务 B 的快照读:两次 SELECT 都看到 1 行 < 因为快照没变,看不到新插入的行
但事务 B 如果执行 UPDATE(当前读):
UPDATE account SET balance = balance + 1 WHERE id > 0;
> 会"看到"新插入的那一行,并修改它 < 这就是幻读所以 InnoDB 用间隙锁(Gap Lock)解决当前读的幻读:
间隙锁锁住"记录之间的空隙",让别人无法在这个空隙里插入
索引记录:id = 1, 5, 10
间隙: (-∞,1) (1,5) (5,10) (10,+∞)
SELECT * FROM t WHERE id BETWEEN 3 AND 7 FOR UPDATE;
> 锁住 (1,5) 和 (5,10) 两个间隙 + id=5 这条记录
> 别人无法插入 id=4 或 id=6
> 幻读被阻止Next-Key Lock = 记录锁 + 间隙锁,锁住"左开右闭"的区间,比如 (1, 5]。这是 InnoDB 在 RR 下的默认加锁方式。
RC 级别没有间隙锁(这也是它并发更高的原因,但会有幻读)。
为什么这么设计
反事实 1:如果只用锁实现隔离
所有读都要加共享锁,所有写都要加排他锁。读写互相阻塞,并发度极低。
MVCC 让"读"完全不加锁(快照读),"写"只锁必要的行——读不阻塞写,写不阻塞读。这是现代数据库并发能力的来源。
反事实 2:如果没有 undo log 保存旧版本
MVCC 无从实现——你没法"读一个已经不存在的旧值"。所以 undo log 既是回滚工具,也是 MVCC 的数据源。
反事实 3:如果 ReadView 每次读都生成(也就是 RC)
那就是 RC 级别,会出现不可重复读。对于"同一事务内要多次读同一数据"的业务(比如先查余额再扣款),RC 会导致逻辑错误。
RR 的价值就在这:给事务一个稳定的世界观。
面试怎么问
Q:四个隔离级别?分别解决什么问题?
按表格答,重点说"每个级别解决了上一级的哪个异常"。再补一句"MySQL 默认 RR,但很多公司改 RC,因为锁范围小、并发高"。
Q:MVCC 怎么实现的?
三件套:隐藏字段(trx_id + roll_ptr)、undo log 版本链、ReadView。 然后讲可见性判断规则(四个分支)。
Q:RC 和 RR 的实现区别?
只有一处:ReadView 的生成时机。RC 每次 SELECT 都生成新的,RR 只在第一次 SELECT 时生成并复用。
Q:MVCC 能解决幻读吗?
只能解决快照读的幻读。当前读(UPDATE/DELETE/FOR UPDATE)仍然会看到新插入的行,需要靠间隙锁(Next-Key Lock)解决。
Q:什么是快照读和当前读?
快照读是普通 SELECT,走 MVCC 读历史版本,不加锁。当前读是
FOR UPDATE、UPDATE、DELETE,读最新版本并加锁。
追问链:
怎么解决不可重复读?(MVCC / RR 级别)
> MVCC 靠什么?(隐藏字段 + undo 版本链 + ReadView)
> ReadView 怎么判断可见?(四个分支)
> RC 和 RR 差在哪?(ReadView 生成时机)
> 那幻读解决了吗?(快照读解决了,当前读要靠间隙锁)
> 间隙锁有什么代价?(锁范围大,容易死锁)和其他知识的连接
- < 来自 事务与 ACID:undo log 是版本链的载体;隔离性是 ACID 的 I
- < 来自 调度与并发同步:CPU 分给谁,共享数据谁先动:MVCC 是"乐观并发控制",和 CAS 的思路一致(不锁,靠版本判断)
- > 影响 锁与死锁:间隙锁是死锁的主要来源之一
- > 影响 SQL 执行与优化:长事务导致 undo log 无法 purge,版本链变长,查询变慢
- > 影响 工程实践:
SELECT ... FOR UPDATE用错会锁全表;RC 下没有间隙锁但要处理幻读
自测 3 问
Q1:为什么 RR 级别下,同一个事务两次 SELECT 结果一样,但 UPDATE 却能看到新插入的行?
点开看答案
因为两种读走的是不同的路径:
- 普通 SELECT(快照读):走 MVCC,用事务开始时生成的 ReadView 判断可见性。新插入的行 trx_id 在 ReadView 的
max_trx_id之后(或者还在 m_ids 里),所以不可见。 - UPDATE(当前读):必须读最新数据,否则会基于旧版本修改、覆盖别人的提交(丢失更新)。所以它绕过 MVCC,直接读最新版本并加锁。
这就产生了"看起来矛盾"的现象:SELECT 看不到,UPDATE 却改到了。
InnoDB 的补丁是间隙锁:在 RR 下,UPDATE ... WHERE id > 0 会给范围加 Next-Key Lock,阻止别人在范围内插入。这样"看不到也改不到",逻辑才自洽。
Q2:ReadView 里 m_ids 存的是"活跃事务",为什么要这个列表?
点开看答案
因为光靠"事务 ID 大小"判断不了可见性。
假设事务 ID 是递增分配的:
- trx_id = 30(已提交)
- trx_id = 40(还在跑,未提交)
- trx_id = 50(已提交)
- 我是 trx_id = 60,现在生成 ReadView
如果只看"T < 60 就可见",那 40 也会被判为可见——但 40 还没提交,读到它就是脏读。
所以需要 m_ids = [40] 这个列表,明确排除"ID 比我小但还没提交"的事务。
本质上:事务 ID 小 ≠ 已提交。提交顺序和分配顺序不一致,所以必须显式记录"谁还活着"。
Q3:为什么很多公司把隔离级别从 RR 改成 RC?
点开看答案
三个原因:
- RC 没有间隙锁,锁范围小,并发度高,死锁概率低。RR 的间隙锁是死锁的重灾区。
- RC 的主从一致性没问题了。早期 MySQL 的 binlog 只有 statement 格式,RC 下主从可能不一致;现在默认 row 格式,记录的是行的实际变更,不存在这个问题。
- RC 更符合直觉。大多数业务期望"读到最后提交的数据",RR 的"整个事务看到同一快照"反而让一些场景困惑(比如查到了旧数据)。
代价:RC 有不可重复读和幻读,业务层要自己处理(比如用乐观锁版本号、或者关键操作加 FOR UPDATE)。
结论:RR 是"数据库帮你兜底",RC 是"把控制权交给业务"。规模大了通常选后者。
类比失效处 / 易错点
- "多版本副本"类比会让人以为真的复制了整行数据。实际 undo log 只记录被修改的字段的旧值,不是整行拷贝。
- MVCC 不是"完全无锁"。快照读无锁,但当前读(UPDATE/FOR UPDATE)要加锁。
- RR 不能完全解决幻读。只能解决快照读的,当前读要靠间隙锁。这是最常被答错的一点。
m_ids不是"所有未提交事务",是"生成 ReadView 那一刻还活跃的事务"。之后新开的、之后提交的,都不在里面。- 间隙锁只在 RR 及以上存在,RC 没有。所以"RC 下为什么没有幻读"这个问法本身就是错的——RC 有幻读。
✅ 验收清单
- 能各举一个例子说明脏读、不可重复读、幻读(要能说清后两者的区别:值变 vs 行数变)
- 能默写四个隔离级别的表格(级别 × 三种异常)
- 能说出 MVCC 的三件套(隐藏字段 / undo 版本链 / ReadView),并画出结构图
- 能背出 ReadView 的四个可见性判断分支
- 能一句话说清"RC 和 RR 的唯一区别"(ReadView 生成时机)
- 能解释"为什么 UPDATE 能看到 SELECT 看不到的行"
- 能说清间隙锁解决什么问题,以及它的代价
- 合上书,用自己的话讲 3 分钟,中途不卡壳