Skip to content

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:

  1. 早期 MySQL 的 binlog 只有 statement 格式,RC 级别下主从可能不一致(同一 SQL 在主从执行顺序不同,结果不同)。RR 能避免。
  2. 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 版本链 ​

← 左右滑动看完整图 →balance=300trx_id=50balance=200trx_id=40balance=100trx_id=30NULL当前行(链头)DB_ROLL_PTRundo log 旧版本不可见:50 在 m_ids 里不可见:40 在 m_ids 里可见:30 < min_trx_idReadView 快照:m_ids = [40, 45, 50] · min_trx_id = 40 · max_trx_id = 51
ReadView 就是一张「我出生那一刻谁还活着」的名单。顺着 DB_ROLL_PTR 从最新版本往回走,第一个通过判断的版本就是你能看到的——所以 RC 和 RR 的差别只在 ReadView 什么时候生成。

每次修改都会产生一个新版本,用 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 生成时机)
        > 那幻读解决了吗?(快照读解决了,当前读要靠间隙锁)
          > 间隙锁有什么代价?(锁范围大,容易死锁)

和其他知识的连接 ​

自测 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?

点开看答案

三个原因:

  1. RC 没有间隙锁,锁范围小,并发度高,死锁概率低。RR 的间隙锁是死锁的重灾区。
  2. RC 的主从一致性没问题了。早期 MySQL 的 binlog 只有 statement 格式,RC 下主从可能不一致;现在默认 row 格式,记录的是行的实际变更,不存在这个问题。
  3. 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 分钟,中途不卡壳