臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 从隔离级别接着往下想:数据库怎么兑现读取规则

先抓住两篇文章的关系

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 的余额,扣掉 2001200 → 1000已执行 COMMIT,确认保存这次扣款
120修改账户 1 的余额,再扣掉 1001000 → 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
找到照片对应的旧内容沿版本链寻找可见的旧版本

先记三句话就够了:

  1. 别人建视图前已经提交的修改,可以看。
  2. 别人建视图时还没提交,或后来才做的修改,这个旧视图看不到。
  3. 自己在当前事务里做的修改,自己可以看。

所以,上一章的版本链像链表,提供 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 用旧视图看到什么
账户 1900沿这一行的版本链找到旧值 1000
账户 21800沿这一行的版本链找到旧值 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_id120这份未提交名单里,最小的事务 ID 是多少?120
max_trx_id121ID 大于等于 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 的隔离级别返回什么原因
RC900建立新视图时,120 已经提交
RR1000沿用旧视图;旧视图记住的仍是“120 当时没提交”

120 后来提交,不会改写 B 已经建立的旧视图。 这就是同样一条版本链,RC 和 RR 后续可能读出不同结果的原因。

如果某行在旧视图建立之后才插入,又没有更早的可见版本,那么旧视图就看不到这行。

五、🧭 RC 和 RR:核心差别在于什么时候建立读取视角

前面已经知道 Read View 怎样选版本。这一章只往前走一步:下一次查询,是换新视图,还是继续用旧视图?

5.1 先记住一个区别:RC 换视图,RR 用原视图

对比项RCRR
第一次普通查询建立视图建立视图
同一事务里再次普通查询建立新视图复用原视图
别人后来提交的修改新视图可以看到原视图看不到
自己之前的修改能看到能看到

下面先看同一行余额,再看多行订单,最后归纳读取问题和业务场景。余额、订单例子都假设 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 条
第③步的问题RCRR
O2 有没有保存成功?有有
B 为什么看到这个结果?新视图能看到 O2旧视图看不到后来插入的 O2
B 结束事务,开启新事务再普通查询呢?可以看到 O1、O2也可以看到 O1、O2

RR 这里做的是“我暂时看不到新订单”,不是“别人不能新增订单”。 RC 在同一事务里两次查出的订单集合变了,就是这里的幻读。

5.4.2 自己增删改:自己的修改自己能看到

如果是 B 自己增删改呢?自己改的,后续普通查询能看到,不用等提交。

假设 B 使用 RR,第一次普通查询待支付订单,看到 O1、O2。下面三行是独立例子,每次都从这个初始状态开始:

B 自己做了什么(尚未提交)B 在同一事务内再普通查询待支付订单为什么
新增待支付订单 O3O1、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 能避免哪些读取问题

问题RCRR(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 锁,理解锁定读取、写写冲突、范围锁与死锁。

九、🔗 官方资料与源码