一、💡 从隔离级别接着往下想:数据库怎么兑现读取规则
先抓住两篇文章的关系
06.MySQL 隔离级别 讲的是“一个事务应该看到什么”;本篇讲的是“InnoDB 怎样找出允许它看到的数据版本”。MVCC(Multi-Version Concurrency Control,多版本并发控制)是实现 RC、RR 普通一致性读的重要机制。
1.1 先接住上一篇的结论:RC 和 RR 读出来有什么不同
继续使用上一篇的账户例子:余额最初是 1000,事务 A 负责扣款,事务 B 负责查询。B 的两次查询在同一个事务内,都是普通 SELECT,B 自己不修改数据。
| 顺序 | 事务 A:扣款 | 事务 B 使用 RC(读已提交) | 事务 B 使用 RR(可重复读) |
|---|---|---|---|
| ① | 尚未扣款 | 第一次查到 1000 | 第一次查到 1000,建立快照 |
| ② | 将余额改成 900,尚未提交 | 此时再查,仍是 1000 | 此时再查,仍是 1000 |
| ③ | 提交扣款 | ||
| ④ | 再查得到 900 | 再查仍是 1000 |
RC、RR 两列表示两种独立情形。先不背实现,回忆它们对应的知识点:
- 防脏读:第②步都不读取 A 尚未提交的
900。 - 不可重复读:第④步 RC 可以看到新提交的
900,所以同一事务前后结果可能变化。 - 可重复读:第④步 RR 继续看到
1000,保持原来的读取视角。
上一篇告诉我们这些结果符合各自的隔离规则。接下来才是本篇的问题:当前余额已经被改成了 900,数据库到底从哪里拿到 1000?又怎么知道这次该返回哪一个?
1.2 为什么需要“多个版本”,而不是只保存最新值
先看 A 尚未提交时。B 不能读取未提交的 900,否则可能脏读。让 B 等待 A 结束是一种并发控制思路,但会增加读写之间的等待。
InnoDB 的普通一致性读提供了另一条路:A 继续修改,B 读取对自己可见的旧版本 1000。 这里的“一致性读”也常叫“快照读”,就是按照某个读取视角选择版本,后文会解释这个视角如何实现。
再看 A 提交以后。RC 的下一次普通查询可以读取 900;RR 的原事务仍可能需要读取 1000。所以,“已经提交”还不足以决定返回哪个版本,还要看当前查询沿用哪个读取视角。
这里的“读取视角”就是后文要讲的 Read View:用于判断哪些数据版本对当前查询可见的依据。先沿着时间顺序理解:
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 尚未扣款 | 第一次普通查询,看到 1000 |
| ② | 将余额改成 900,并提交 | |
| ③ | 在同一个事务内再次普通查询 |
第③步,A 明明已经提交,为什么 B 还可能看到 1000?关键在于 B 使用哪一个视图:
- 事务 B 使用 RC:第③步建立新的 Read View。A 在这个新视图建立之前已经提交,因此
900对新视图可见,查询返回900。 - 事务 B 使用 RR:第③步继续沿用第①步建立的 Read View。A 的修改是在旧视图建立之后发生并提交的,因此
900对旧视图不可见,查询仍返回1000。
这里仍假设 B 自己没有修改数据,两次读取都是同一事务中的普通一致性读。
“已经提交”和“对我可见”是两回事
“已经提交”描述的是写事务现在的状态;“对我可见”还取决于当前查询使用的 Read View。已经提交,不等于对所有查询立即可见;RR 的旧快照仍可能看不到后来提交的修改。
由此自然得到两个实现需求:
| 从隔离规则推导出的需求 | InnoDB 的对应机制 | 接下来要回答的问题 |
|---|---|---|
| 当前值变了,仍要能找到旧值 | 当前记录与 undo log 形成的逻辑版本链 | 1000 怎样还原出来? |
| 不同查询可能有不同的可见范围 | Read View(读取视图) | 哪些事务写出的版本允许我看? |
| RC 后续读取可以看新提交,RR 通常保持原视角 | Read View 的建立与复用时机 | 这次该换视图,还是沿用原视图? |
先记住:版本链提供候选版本,Read View 筛选可见版本,RC/RR 决定何时建立或复用视图。 后面的隐藏字段、事务 ID 都是在为这三个职责服务。
1.3 隔离级别、MVCC、锁分别处在哪一层
隔离级别是事务对外提供的行为规则;MVCC 和锁是实现并发控制的机制,不能把四种隔离级别理解为四套 MVCC。
| 隔离级别 | 普通查询的关键行为 | 与本篇的关系 |
|---|---|---|
| RU:读未提交 | 可能读到其他事务未提交的修改 | 不套用本篇 RC/RR 的快照可见性规则 |
| RC:读已提交 | 每次普通一致性读使用新快照 | 用 MVCC 解释为何不脏读、却可能前后不同 |
| RR:可重复读 | 同一事务的普通一致性读通常复用首次快照 | 用 MVCC 解释为何别人提交了,我仍看到旧值 |
| SERIALIZABLE:可串行化 | 施加更严格的并发约束;关闭自动提交时,普通 SELECT 隐式成为共享锁定读 | 需要结合锁理解,见 08.MySQL 锁 |
即使在 RC、RR 下,SELECT ... FOR UPDATE、UPDATE、DELETE 也不能照搬普通快照读的分析方式。先学会本篇的普通读取,再到第六章区分当前读与快照读。
本篇以 MySQL 8.4 InnoDB 的 RC、RR 普通一致性读为主。读取规则可对照 官方一致性读说明;前置笔记是 05.MySQL 事务、06.MySQL 隔离级别。
现在按下面的顺序读,就能从现象走到原理:
隔离级别要求我看到什么?
→ 需要保留哪些旧版本?(第二章:版本链)
→ 怎样判定哪个版本可见?(第三章:Read View)
→ 实际查询怎样挑出结果?(第四章:逐版判断)
→ RC/RR 为什么挑出不同结果?(第五章:视图生命周期)二、🔗 版本链:数据库怎样找回旧余额
2.1 当前记录上的两个关键字段
回到开头的疑问:余额已改成 900,查询还需要拿到 1000。数据库既要知道“这一版是谁修改的”,也要知道“沿哪里找修改前的内容”,于是需要下面两个字段。
InnoDB 的聚簇索引记录中包含事务信息。理解版本链,重点看这两个隐藏字段:
| 字段 | 作用 | 白话理解 |
|---|---|---|
DB_TRX_ID | 最后插入或修改该记录的事务 ID | 这一版是谁写出来的 |
DB_ROLL_PTR | 指向相关 undo log 记录 | 从哪里还原修改前的内容 |
DB_ROW_ID 主要与没有合适主键或唯一非空索引时的聚簇索引生成有关,不是判断版本可见性的核心。
2.2 undo log 保存的是还原旧版本所需的信息
120、105、90 是示例中的事务 ID。DB_TRX_ID 表示这一版数据是哪个事务写出来的。
这三个事务先后操作同一个账户(账户 ID 假设为 1):
| 事务编号 | 对账户做了什么 | 余额变化 | 是否提交 |
|---|---|---|---|
| 90 | 新增一行账户记录:账户 ID 为 1,初始余额为 1200 | 原来没有这行 → 1200 | 已执行 COMMIT,确认保存这次新增 |
| 105 | 修改账户 1 的余额,扣掉 200 | 1200 → 1000 | 已执行 COMMIT,确认保存这次扣款 |
| 120 | 修改账户 1 的余额,再扣掉 100 | 1000 → 900 | 还没执行 COMMIT,这次扣款尚未最终确认 |
所以,“事务 90 插入,已提交”就是:编号为 90 的事务新增了这行账户数据,并执行提交,确认保存。 90 是事务的编号,账户的编号是 1。
当前记录:余额 900,DB_TRX_ID = 120(事务 120 修改,尚未提交)
│ 通过 DB_ROLL_PTR 和 undo 信息还原旧版本
▼
旧版本:余额 1000,DB_TRX_ID = 105(事务 105 修改,已提交)
│ 继续向前还原
▼
更旧版本:余额 1200,DB_TRX_ID = 90(事务 90 插入,已提交)这就是版本链。图中的旧版本是根据 undo 信息还原出来的,不意味着每次更新都把完整数据行或整张表复制一份。
undo log 既用于事务回滚,也为一致性读提供重建旧版本的信息;两种用途的保留期限并不相同。用于一致性读的更新 undo,可能在写事务提交后仍需保留。官方多版本说明
先分清两个职责
版本链负责“旧数据怎样找回来”,它本身不能决定当前查询应该返回哪一版。
三、👀 Read View:查询有资格看哪一版
3.1 生活例子:看一张已经确认的账本照片
假设你和室友共用一本生活费账本:
| 时间 | 室友在做什么 | 你看到什么 |
|---|---|---|
| ① | 账本余额是 1000 元,已经确认 | 你拍下照片,照片上是 1000 |
| ② | 室友把余额改成 900,还没确认 | 你继续看照片,仍是 1000 |
| ③ | 室友确认了 900 | 原来的照片不会跟着变,仍是 1000 |
关键就在第③步:账本已经变了,但你手上的旧照片没变。
接回隔离级别就容易了:
- RR:同一事务的普通查询,像一直看原来的照片,所以仍看到
1000。 - RC:每次普通查询都重新拍照;室友确认后再拍,就能看到
900。
3.2 回到数据库:Read View 就是读取时的判断依据
数据库并不真的拍照。它记下建立视图时,哪些事务还没提交,再据此判断哪些版本可以看。 这个判断依据就叫 Read View。
| 生活例子 | 数据库里对应什么 |
|---|---|
| 室友修改账本 | 其他事务修改数据 |
| 确认修改 | 提交事务 |
| 继续看原照片 | 继续使用原来的 Read View |
| 找到照片对应的旧内容 | 沿版本链寻找可见的旧版本 |
先记三句话就够了:
- 别人建视图前已经提交的修改,可以看。
- 别人建视图时还没提交,或后来才做的修改,这个旧视图看不到。
- 自己在当前事务里做的修改,自己可以看。
所以,上一章的版本链像链表,提供 900 → 1000 → 1200 这些候选版本;Read View 则负责判断:我该停在哪一版。
照片只是帮助理解
Read View 不保存整张表或查询结果,只保存判断版本可见性需要的事务信息。而且事务能看到自己的修改,所以它也不是一张完全不能变化的照片。
3.3 不只管一行:多行查询也用同一份视图
每行有自己的版本链,但查询多行时,用的是同一份 Read View。
假设账户表原来有两行:账户 1 余额 1000,账户 2 余额 2000。事务 B 使用 RR,只做普通查询、不修改数据:
| 写入事务 A | 查询事务 B |
|---|---|
① 查询全部账户,建立视图,看到:账户 1 = 1000、账户 2 = 2000 | |
② 将账户 1 改为 900、账户 2 改为 1800,再新增账户 3,余额 500;一起提交 | |
| ③ 在原事务内再次查询全部账户,仍使用①的视图 |
第③步不是只判断某一行,而是对各行应用同一套可见性规则:
| 账户 | 当前最新数据 | B 用旧视图看到什么 |
|---|---|---|
| 账户 1 | 900 | 沿这一行的版本链找到旧值 1000 |
| 账户 2 | 1800 | 沿这一行的版本链找到旧值 2000 |
| 账户 3 | 新增,余额 500 | 没有旧视图可见的版本,不返回这行 |
所以 B 仍查到 2 行,余额分别为 1000、2000。A 的修改和新增已经成功,只是 B 的旧视图看不到。
这份视图也不限于一张表:如果 A 在②还新增了订单,B 在③之后第一次普通查询 InnoDB 订单表,仍沿用①的视图,看不到那条新订单。
记法:版本链属于某一行;RR 事务里的普通查询共用读取视角,逐行判断能看哪一版。
3.4 结合事务 90、105、120 看字段
继续用第四章的例子:B 建立视图时,90、105 已提交,120 还没提交。 假设此时只有 120 是活跃的读写事务,事务 ID 的分配上界是 121。
Read View 就把这个时间点的情况记下来:
| 字段 | 这个例子的值 | 白话解释 |
|---|---|---|
m_ids | [120] | 拍照时谁还没提交?事务 120 |
min_trx_id | 120 | 这份未提交名单里,最小的事务 ID 是多少?120 |
max_trx_id | 121 | ID 大于等于 121 的事务属于这个视图之后的范围,不能看 |
creator_trx_id | 标识查询事务 B | 用来识别“是不是我自己改的”;B 只查询,本例用不到这条判断 |
121 是本例额外假设的分配边界,不是余额,也不是固定等于 120 加 1。纯只读事务 B 可能没有普通读写事务 ID,不必为了演算再给它编一个编号。
现在拿着这张视图,沿版本链检查:
| 找到的版本 | 用哪个字段判断 | 结果 |
|---|---|---|
900,事务 120 写的 | 120 在 m_ids = [120] 里,说明拍照时没提交 | 不能看,找上一版 |
1000,事务 105 写的 | 105 < min_trx_id(120),比当时最早的活跃读写事务还早 | 可以看,返回 1000 |
1200,事务 90 写的 | 已经找到可见版本 | 不用继续找 |
如果 120 后来提交了呢? 旧视图里的 m_ids 仍是 [120],不会自动划掉 120。所以 RR 继续用旧视图时,仍跳过 900。
RC 下一次查询会建立新视图,120 已经提交,就不再因为“还没提交”被排除。
补充:为什么既要有边界,还要有名单?
先排除自己的修改(自己改的自己能看),再看其他事务的版本:
- 小于
min_trx_id:足够早,可以看。 - 大于等于
max_trx_id:太晚,不能看。 - 落在两个边界之间:查
m_ids名单;在名单里不能看,不在则可以看。
例如另一个视图的名单是 [100, 120],上界为 121:事务 105 虽然落在 [100, 121) 中,但不在未提交名单里,因此可以看。事务编号小,不代表先提交,所以中间这一段还需要查名单。
这里的 min_trx_id、max_trx_id 是教学名称,对应源码的 m_up_limit_id、m_low_limit_id。名单为空时,最小边界取分配上界。
源码参考:ReadView 定义与判断规则。
四、🔄 串起来:一次查询怎样沿版本链找到结果
4.1 继续看刚才的账户
还是同一个账户。左边负责写入,右边的事务 B 只查询、不修改,按从上到下的顺序看:
| 写入事务(90、105、120) | 查询事务 B |
|---|---|
① 事务 90:新增账户,余额 1200,提交 | |
② 事务 105:将余额改成 1000,提交 | |
③ 事务 120:将余额改成 900,暂时不提交 | |
| ④ 第一次普通查询,建立 Read View |
在第④步建立视图时:105 已提交,120 还没提交。 先记住这一点即可。
4.2 查询像沿链表找:这一版不能看,就找上一版
事务 B 从最新版本开始检查:
| 检查哪个版本 | 能看吗 | 为什么 |
|---|---|---|
900,事务 120 写的 | 不能 | 建立视图时,120 还没提交 |
1000,事务 105 写的 | 能 | 建立视图前,105 已经提交 |
1200,事务 90 写的 | 不用再找 | 已经找到了可见的 1000 |
900(不能看)→ 1000(能看,返回它)→ 1200(不用再找)版本链负责找上一版,Read View 负责判断这一版能不能看。
4.3 如果事务 120 随后提交呢
事务 B 不结束事务,再普通查询一次:
| 事务 B 的隔离级别 | 返回什么 | 原因 |
|---|---|---|
| RC | 900 | 建立新视图时,120 已经提交 |
| RR | 1000 | 沿用旧视图;旧视图记住的仍是“120 当时没提交” |
120 后来提交,不会改写 B 已经建立的旧视图。 这就是同样一条版本链,RC 和 RR 后续可能读出不同结果的原因。
如果某行在旧视图建立之后才插入,又没有更早的可见版本,那么旧视图就看不到这行。
五、🧭 RC 和 RR:核心差别在于什么时候建立读取视角
前面已经知道 Read View 怎样选版本。这一章只往前走一步:下一次查询,是换新视图,还是继续用旧视图?
5.1 先记住一个区别:RC 换视图,RR 用原视图
| 对比项 | RC | RR |
|---|---|---|
| 第一次普通查询 | 建立视图 | 建立视图 |
| 同一事务里再次普通查询 | 建立新视图 | 复用原视图 |
| 别人后来提交的修改 | 新视图可以看到 | 原视图看不到 |
| 自己之前的修改 | 能看到 | 能看到 |
下面先看同一行余额,再看多行订单,最后归纳读取问题和业务场景。余额、订单例子都假设 B 的查询在同一事务中,且 B 自己不修改数据;涉及自己修改和加锁查询的情况会单独说明。
5.2 看余额:RC 每次读取建立新视图
事务 B 的两次查询在同一个事务中,按从上到下的顺序看:
| 写入事务 A(事务 120) | 查询事务 B(RC) |
|---|---|
① 将余额从 1000 改成 900,暂时不提交 | |
② 第一次普通查询,建立视图 V1,读到 1000 | |
| ③ 提交扣款 | |
④ 第二次普通查询,建立新视图 V2,读到 900 |
A 在 V1 建立时尚未提交,对 V1 不可见;在 V2 建立前已经提交,对 V2 可见。变化来自读取视角更新,不是版本链的判断规则发生了变化。
5.3 看余额:RR 复用首次读取建立的视图
事务 B 的两次查询在同一个事务中,按从上到下的顺序看:
| 写入事务 A(事务 120) | 查询事务 B(RR) |
|---|---|
① 将余额从 1000 改成 900,暂时不提交 | |
② 第一次普通查询,建立视图 V1,读到 1000 | |
| ③ 提交扣款 | |
④ 第二次普通查询,复用原视图 V1,读到 1000 |
普通 START TRANSACTION 通常只表示事务开始,快照在第一次普通一致性读时建立。RR 下如需在开始时建立一致性快照,可使用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
别人提交了,不会改变原来的视图,所以第二次仍读到 1000。
5.4 多行查询:分清四种情况
下面每种情况单独看,B 始终在同一个事务内操作。先看普通查询和自己的修改,再看涉及锁的部分。
5.4.1 别人新增,自己普通查询:RC 看到新订单,RR 仍看旧视图
先看普通查询:订单原来只有一条 O1,状态是“待支付”。 B 的两次查询在同一个事务中,B 自己不修改数据。
| 新增订单的事务 A | 查询事务 B |
|---|---|
① 普通查询待支付订单,看到 O1,共 1 条 | |
| ② 新增待支付订单 O2,并提交成功 | |
③ 再次普通查询:RC 看到 O1、O2,共 2 条;RR 仍只看到 O1,共 1 条 |
| 第③步的问题 | RC | RR |
|---|---|---|
| O2 有没有保存成功? | 有 | 有 |
| B 为什么看到这个结果? | 新视图能看到 O2 | 旧视图看不到后来插入的 O2 |
| B 结束事务,开启新事务再普通查询呢? | 可以看到 O1、O2 | 也可以看到 O1、O2 |
RR 这里做的是“我暂时看不到新订单”,不是“别人不能新增订单”。 RC 在同一事务里两次查出的订单集合变了,就是这里的幻读。
5.4.2 自己增删改:自己的修改自己能看到
如果是 B 自己增删改呢?自己改的,后续普通查询能看到,不用等提交。
假设 B 使用 RR,第一次普通查询待支付订单,看到 O1、O2。下面三行是独立例子,每次都从这个初始状态开始:
| B 自己做了什么(尚未提交) | B 在同一事务内再普通查询待支付订单 | 为什么 |
|---|---|---|
| 新增待支付订单 O3 | O1、O2、O3,共 3 条 | 自己新增的能看到 |
| 将 O1 从“待支付”改为“已支付” | 只有 O2,共 1 条 | 能看到自己修改后的状态,O1 不再符合“待支付”条件 |
| 删除 O1 | 只有 O2,共 1 条 | 自己删除的,不再查出来 |
例如自己新增时,操作顺序是:
| 其他事务 A | 查询事务 B(RR,同一个事务) |
|---|---|
| ① 普通查询待支付订单,看到 O1、O2 | |
| ② 新增待支付订单 O4,并提交 | |
| ③ 自己新增待支付订单 O3,暂不提交 | |
| ④ 再普通查询,看到 O1、O2、O3,看不到 O4 |
自己的 O3 能看到,别人后来提交的 O4 仍看不到。 所以旧视图不是把查询结果完全冻住,而是限制其他事务哪些修改可见,同时允许看到自己的修改。
后面两种情况涉及锁
建议看完 08.MySQL 锁 中的锁定读、当前读和范围锁,再回来看 5.4.3、5.4.4。第一次阅读可以先跳到 5.5;想先感受现象,也可以只看下面的操作表。
5.4.3 改用加锁查询:能看到新订单,但不刷新旧视图
先感受一下:同一个 RR 事务 B,换一种查询方式,结果就可能不同。
| 新增订单的事务 A | 查询事务 B(RR,同一个事务) |
|---|---|
| ① 普通查询待支付订单,看到 O1,建立旧视图 | |
| ② 新增待支付订单 O2,并提交成功 | |
| ③ 再普通查询,仍只看到 O1,因为沿用旧视图 | |
④ 改用 SELECT ... FOR UPDATE 查询,看到 O1、O2,并加锁 | |
| ⑤ 再普通查询,仍只看到 O1:第④步没有刷新旧视图 | |
| ⑥ 提交事务,释放锁 |
先记住这个现象:普通查询按旧视图看;FOR UPDATE 不按这个旧视图看,也不会把旧视图更新掉。 上表假设没有其他冲突操作;锁具体保护什么、别人何时需要等待,学 08.MySQL 锁 时再展开。
5.4.4 先锁住范围:让别人的新增暂时等待
如果希望别人暂时不能往相关范围新增订单,需要范围锁保护。这时不只是“我看不到新订单”,而是“别人暂时插不进来”。
例如,B 要处理金额在 100~200 元的订单。假设使用 InnoDB RR,金额字段有索引,A 的插入位置落在 B 锁住的范围内:
| 新增订单的事务 A | 处理订单的事务 B(RR) |
|---|---|
① 在事务内用 FOR UPDATE 查询金额 100~200 元的订单,锁住相关索引范围 | |
② 尝试新增金额为 150 元的订单,进入等待,插入尚未完成 | |
| 仍在等待 | ③ 处理查到的订单 |
| ④ 提交事务,释放锁 | |
| ⑤ 等待结束,继续插入 |
| 方式 | 别人新增订单时会怎样 |
|---|---|
| 普通查询的旧快照 | 别人可以插入并提交,只是旧快照看不到 |
| 范围锁保护 | 插入位置被锁住时,需要等待锁释放 |
具体锁住哪些位置与索引、查询条件和执行计划有关,学 08.MySQL 锁 时再展开。
四种情况记住:普通查询看视图,自己的修改能看到,加锁查询不沿用旧视图,范围锁可以让冲突插入等待。
5.5 RC、RR 能避免哪些读取问题
| 问题 | RC | RR(InnoDB) |
|---|---|---|
| 脏读:看到别人没提交的数据 | 能避免 | 能避免 |
| 不可重复读:同一行前后值不同 | 不能保证避免 | 普通快照查询能避免 |
| 幻读:相同条件查出的记录集合变化 | 不能保证避免 | 连续普通快照查询通过同一视图避免 |
这张表沿用前面的条件:同一个事务、连续普通查询、自己没有修改相关数据。
5.6 最后落到业务:什么时候考虑 RC,什么时候考虑 RR
关键看:同一个事务里,后一次查询是要看到新提交的数据,还是保持原来的统计口径。
例如,后台批量处理订单:同一个事务先查询订单状态,做一些处理,执行订单操作前再查询一次,希望发现其他事务刚提交的取消状态。下面假设两次都是普通查询:
| 取消订单的事务 A | 批处理事务 B(同一事务内) |
|---|---|
| ① 第一次查询:订单是“待处理” | |
| ② 将订单改为“已取消”,提交 | |
| ③ 再次查询:希望看到“已取消”,跳过这笔订单 |
- RC:第③步使用新视图,可以看到“已取消”,适合这个读取要求。
- RR:第③步沿用旧视图,仍可能看到“待处理”,不符合这里重新确认状态的要求。
但 RC 也不能保证查完以后状态不再变化。实际执行订单操作时,仍要用条件更新或锁保护;RC 提供较新的读取视角,不负责保证整个业务操作安全。
| 场景 | 希望得到什么 | 可以考虑 |
|---|---|---|
| 同一事务内,处理前再次检查订单状态 | 看见其他事务新提交的取消状态 | RC |
| 同一事务内,分几次查询生成财务报表 | 订单数、支付金额、退款金额使用同一个快照 | RR |
| 客服刷新页面,每次查询属于新事务 | 新事务读取新提交的数据 | RC、RR 都可以,不能仅凭刷新需求决定 |
记法:同一事务里想“再看一眼最新情况”,考虑 RC;想“始终按同一时点统计”,考虑 RR。 RC 不是每个系统必须使用的级别,最终还要结合业务要求选择。详见 06.MySQL 隔离级别。
上述视图规则参见 MySQL 官方一致性读说明。
六、🛠️ MVCC 的使用边界与代价
6.1 普通查询和修改数据,用的不是同一套读取规则
| 操作 | 典型语句 | 先这样理解 |
|---|---|---|
| 展示余额 | 普通 SELECT(RC/RR) | 按 Read View 找到能看的版本,可能是旧余额 |
| 先加锁查询,再修改 | SELECT ... FOR UPDATE | 不按普通查询的旧快照读;别人持有冲突的锁时,可能需要等 |
| 扣款、删除记录 | UPDATE、DELETE | 基于当前数据修改;别人持有冲突的锁时,可能需要等 |
后两类常叫“当前读”。不能因为快照里余额是 1000,就认为实际扣款时一定也按 1000 处理。
锁的细节去下一篇看
这里先知道“遇到冲突可能等待”就够了。为什么要等、等谁、什么时候继续,以及
FOR UPDATE怎么用,建议看完 08.MySQL 锁 再回来理解。当前读也不表示可以直接使用别人尚未提交的数据。
6.2 两个人都看见最后一件库存,不代表都能购买
MVCC 可以让两个事务都读到对自己可见的库存 1,但它不会自动替业务完成抢购判定。
常见做法是把库存检查与扣减放进同一条条件更新:
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;在商品 ID 唯一且记录存在的前提下,影响行数为 1 表示本次扣减成功;为 0 表示当前没有可扣减的匹配记录。订单与扣库存还要放在合适的事务中提交,不能只凭一次快照查询决定购买成功。
6.3 长时间持有旧视图,会拖住历史版本清理
旧版本不能无限保留,也不能在写事务刚提交时立即全部删除。InnoDB 的 purge 会在相关历史信息不再被活跃视图等机制需要时,逐步清理它们。
如果一个 RR 报表事务长期持有旧 Read View,期间其他事务持续修改数据,某些更新 undo 就必须继续保留,可能增加存储占用和旧版本还原成本。
因此,即使只执行查询,也应及时结束事务。分批、分事务导出可以缩短单次事务,但跨批次是否还需要同一快照,要另行设计。
6.4 MVCC 减少等待,但不保证查询永远不用等
普通快照查询通常不用等别人改完同一行,可以先读取可见的旧版本。但如果遇到修改表结构等操作,查询仍可能等待。
先记住:MVCC 让读写更少互相阻塞,不能消除所有等待,也不能代替修改数据时需要的锁。 相关锁的机制见 08.MySQL 锁,本篇先不展开。
七、🎤 高频面试题
7.1 MVCC 是怎样实现的
InnoDB 利用记录中的事务 ID、回滚指针以及 undo 信息形成逻辑版本链,再用 Read View 判断版本是否可见。普通一致性读从当前版本开始检查,不可见就还原更旧的版本,直到找到合适版本或确定该行对快照不存在。
7.2 为什么事务已经提交,RR 还是读不到它的修改
可见性取决于快照建立时的状态。如果该事务在 Read View 建立时尚未提交,它的 ID 会保留在旧视图的活跃集合中;随后提交不会改变旧视图的判断结果。
7.3 RC 和 RR 的 Read View 有什么区别
RC 每次普通一致性读获取新的视图,RR 通常复用同一事务首次一致性读建立的视图。两者使用类似的版本判断机制,区别主要在视图的建立与复用时机。
7.4 Read View 是数据库的一份完整副本吗
不是。它主要保存用于判断版本可见性的事务信息;需要读取旧内容时,InnoDB 再通过当前记录与 undo 信息还原对应版本。
7.5 MVCC 能完全代替锁吗
不能。MVCC 主要优化普通读取与写入的并发,写写冲突和锁定操作仍然需要锁;业务上还要通过条件更新或版本检查等方式保护并发修改。
7.6 旧版本为什么不能在写事务提交后马上清理
写事务提交后,其他事务持有的旧 Read View 仍可能需要修改前的版本。只要相关视图还需要这些历史信息,就不能立即清理对应 undo;长事务因此可能增加历史版本保留成本。
八、📌 快速回顾与后续阅读
版本链:旧版本从哪里来? → 当前记录 + undo 信息
Read View:我能看到哪一版? → 事务 ID 边界 + 活跃集合 + 自己的修改
查询:怎么找出合适版本? → 从新到旧,逐个判断
RC/RR:为什么读取结果不同? → 新建视图与复用视图的时机不同
使用边界:能否直接据此安全修改? → 还要条件更新、锁及事务配合
历史清理:什么时候能删旧信息? → 不再被相关视图等机制需要之后继续阅读 08.MySQL 锁,理解锁定读取、写写冲突、范围锁与死锁。