读完这篇,你能做什么
能用两个会话区分快照读与锁定读,并据此解释库存或余额更新。
学习理由、准备要求与知识点
为什么学它
并发请求读到什么由隔离级别和语句类型共同决定,事务注解本身无法解释全部行为。
先读这些内容
知识点清单
- ACID 边界
- RC 与 RR
- 一致性读
- 快照建立时机
- 自身写入可见
- 锁定读
- MVCC 版本
- 并发实验时间线
运行前先确认MySQL8.4、InnoDB与Java17;Java客户端源码固定Connector/J9.0.0。ReadView可见性另核对MySQL8.4.0的C++源码,Java算法是原创简化模型,不是MySQL内核移植。
01先弄懂它是什么
两个事务同时读写订单时,一个事务能看到另一个事务的哪些结果,由隔离级别规定。在 READ COMMITTED 和 REPEATABLE READ 下,普通一致性读可以利用历史版本,减少读写互相等待。这种机制叫 MVCC;它不是给每个事务复制一整套数据库,也不表示所有查询都不加锁,而是用版本记录和读取规则挑选可见数据。
在 InnoDB 的常见普通一致性读中,RR 通常复用本事务第一次一致性读建立的快照;RC 通常每次一致性读建立新快照。所以实验不能只记录何时 START TRANSACTION,还要记录第一次普通 SELECT 在什么时候。WITH CONSISTENT SNAPSHOT 等选项另有适用条件,不能一概认为开始事务就立刻冻结了所有查询能看到的数据。
事务能读到自己刚做的修改,所以它看到的内容不一定完全等于数据库过去某一时刻的整体样子。普通 SELECT 通常按一致性读规则挑选版本;SELECT FOR UPDATE、UPDATE 等则走锁定或当前访问路径,规则不同。在一个事务里混用它们,可能看到不同的新旧状态。判断某条语句会读到什么,要看它实际走哪种路径,不能只凭“这个事务是 RR”就认定每次结果相同。
文中这几个词,先认一认
- 一致性读
- 按当前读取规则从数据的新旧版本中选出可见行;快照何时建立与隔离级别有关。
- 版本链
- 同一行修改前后的历史记录链,查询可沿它寻找允许看到的版本。
- 当前或锁定访问
- 按当前访问规则读取并保护记录,可能要等其他事务释放锁,不能直接套用普通快照查询的结论。
02看看它怎么工作
准备一个隔离InnoDB练习表含id=1、value=10。连接A在RR开始事务并普通SELECT读到10;连接B更新为20后COMMIT;A再次普通SELECT通常仍为10。A提交并开始新事务再读,可见20。把A改为RC并重复流程,第二次普通读可以观察B在此期间提交的20。两连接与明确提交点是实验不可省略的前提。
判断某个版本能不能读,不能只比较事务 ID 大小。一个较早开始、ID 较小的事务可能还没提交;另一个 ID 更大的事务却可能在快照建立前已经提交。教学模型因此要记下快照建立时仍活跃的事务、未来事务的 ID 范围,以及读者自己的 ID,才能分别判断这些情况。
查一行时,从最新版本往旧版本找,找到第一个允许读取的版本就停止。如果这个版本标记为已删除,应返回不存在,不能继续找更老的记录把它“复活”。版本的先后由实际修改历史决定,不能简单按事务 ID 排序。真实 InnoDB 还需要 undo 保存旧数据,并借助索引和 purge 等机制维护、清理这些历史。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["普通一致性读取得视图"] --> B["从最新行版本开始"]
B --> C{"自身修改或视图可见"}
C -->|"否"| D["沿历史链找旧版本"]
D --> C
C -->|"是"| E{"删除标记"}
E -->|"是"| F["返回不存在"]
E -->|"否"| G["返回该版本"]
H["RC或RR"] --> I["决定何时创建或复用视图"]
I --> A按顺序看,每一步做了什么
- 确认表引擎、会话隔离级别和自动提交状态。
- 用两个连接记录开始、首次读、更新和提交时间线。
- 分别比较RC与RR普通SELECT。
- 另做锁定读实验并观察等待,避免混淆访问路径。
- 检查自身写入、删除版本和长事务的影响。
03对照实现代码
MySQL Connector/J9.0.0 · ConnectionImpl#setTransactionIsolation
原创Java命令映射,非源码节选。它只解释客户端如何请求会话隔离级别,真实ReadView与undo由MySQL服务端实现。
import java.sql.Connection;
class SourceModel {
static String isolationCommand(int level) {
// JDBC常量与服务器语句之间需要明确转换。
String name=switch(level) {
case Connection.TRANSACTION_READ_UNCOMMITTED -> "READ UNCOMMITTED";
case Connection.TRANSACTION_READ_COMMITTED -> "READ COMMITTED";
case Connection.TRANSACTION_REPEATABLE_READ -> "REPEATABLE READ";
case Connection.TRANSACTION_SERIALIZABLE -> "SERIALIZABLE";
// 不支持的级别应拒绝,不能静默当成默认级别。
default -> throw new IllegalArgumentException("isolation");
};
return "SET SESSION TRANSACTION ISOLATION LEVEL "+name;
// 生成语句不代表它已发送,也不创建本地MVCC。
}
}真实方法检查连接与当前缓存的隔离级别,根据配置决定是否需要发送设置命令。
switch 把 JDBC 的隔离级别常量转换成对应 MySQL 会话命令,不支持的级别会被拒绝。所以设置隔离级别最终要落实为服务端接受的具体命令。
服务器调用成功后才更新客户端记录的级别;本地字段不是数据库历史版本的存储位置。
连接归还池后,还可能被下一个请求借走,所以隔离级别这类会话设置也要有人管理。需要修改时,应按框架和数据库规则,在业务事务开始前安排好;不能让某个请求改过的设置,不知不觉成为后来请求的环境。
代码先调用谁,再调用谁
- 事务管理器设置隔离
ConnectionImpl.setTransactionIsolation- 检查是否需要发送
- SET SESSION TRANSACTION ISOLATION LEVEL
- 服务器管理实际视图和锁行为
04动手写一个例子
按读取视图选择首个可见行版本
输入最新到最旧的行版本链,以及读取者id、快照未来边界和快照时活跃事务集合。自己的版本可见;未来事务和当时活跃事务的版本不可见。首个可见删除标记返回不存在。只模拟普通一致性读。
- 输入 1
活跃{20,55}、未来边界60,版本55:new→40:old- 输出 1
old
- 输入 2
读取者50,最新版本50:mine- 输出 2
mine
快照里的活跃集合,可以理解为“拍下快照时,哪些事务还没提交”的名单。名单记录的是当时情况,外面的事务后来提交,也不能把它从这份旧名单中删掉。继续复用旧视图时,这些事务的修改仍按旧视图判断为不可见;要看新的提交情况,应建立相应的新视图。
先判断自身版本,再判断未来边界和活跃集合。较小事务id不一定已提交,因此不能仅以id小于读取者就接受。模型使用集合查找表达规则,真实源码用有序id结构及边界优化。
沿输入链查找首个可见版本,保留删除标记的终止语义。RC建立新视图可以改变结果,RR复用同一视图则维持对应快照观察,自身写入仍单独可见。
为什么这样写是对的返回版本是实际链上最靠前的可见版本;若它为删除标记,不继续回溯并复活旧值。
V为访问版本数,A为活跃事务数;每个版本常数次集合判断,视图复制活跃集合。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Version(long transaction,String value,boolean deleted) {}
record View(long reader,long next,Set<Long> active) {
View {
if (reader<=0||next<=0) throw new IllegalArgumentException("view");
active=Set.copyOf(active);
for (long id:active)
if (id<=0||id>=next) throw new IllegalArgumentException("active id");
}
boolean visible(long transaction) {
if (transaction<=0) throw new IllegalArgumentException("transaction");
// 自身修改单独可见,不被普通快照历史限制掩盖。
if (transaction==reader) return true;
// 未来事务与快照时仍活跃的事务均不可见。
return transaction<next&&!active.contains(transaction);
}
}
static Optional<String> read(List<Version> chain,View view) {
for (Version version:chain) {
if (!view.visible(version.transaction())) continue;
// 首个可见删除即不存在,不继续寻找旧值。
return version.deleted()?Optional.empty():Optional.of(version.value());
}
return Optional.empty();
}
static Version value(long tx,String text) { return new Version(tx,text,false); }
static void eq(Object expected,Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
View rr=new View(50,60,Set.of(20L,55L));
eq(Optional.of("old"),read(List.of(value(55,"new"),value(40,"old")),rr));
eq(Optional.of("old"),read(List.of(value(65,"future"),value(40,"old")),rr));
eq(Optional.of("mine"),read(List.of(value(50,"mine"),value(40,"old")),rr));
eq(Optional.empty(),read(List.of(new Version(40,null,true),value(10,"past")),rr));
eq(Optional.of("early"),read(List.of(value(20,"active"),value(10,"early")),rr));
View fresh=new View(50,70,Set.of());
eq(Optional.of("future"),read(List.of(value(65,"future"),value(40,"old")),fresh));
eq(Optional.empty(),read(List.of(),rr));
System.out.println("PASS 7 MVCC visibility tests");
}
}这些特殊情况也要试一试
- 没有可见版本返回不存在;可见删除版本同样不存在但不能继续回溯。
- 自身写入优先可见;快照活跃集合不可随外部提交原地变化。
- 不按事务id重排版本链,不模拟锁定读等待。
进阶选读 · 长事务与历史版本保留
场景报表持有旧快照很久,其他事务持续修改,历史版本与清理压力增加。
做法测量事务存活时间、历史版本和查询需求,考虑缩短事务、分批带明确快照边界读取,或使用专门报表副本;不能直接删除仍被视图需要的历史。
复杂度长链读取成本随需要回溯版本数增长,真实purge与索引成本需观察引擎指标。
好处与代价缩短事务降低历史负担,却可能失去跨批同一快照,需要明确报表一致性契约。
05项目里怎么用
选隔离级别要先看要保护什么规则。余额和库存仍需锁或条件更新,普通快照 SELECT 后再无条件 UPDATE 可能继续出错。RR 不等于可串行化;幻读也不能只靠一个简单演示概括,要结合使用的语句、索引和实际锁范围分析。
连接池中会话隔离与事务生命周期应由统一框架管理。不要在业务方法中随意修改隔离却不恢复,也不要把自动提交关闭后持有连接到远程调用完成。记录实际会话变量和事务边界,避免只看配置文件声明。
同一事务里读到新旧混合数据时,先把语句分开:普通一致性读、锁定读、UPDATE,以及读取自己的写入。再核对第一次快照建立和其他连接提交的时间。长事务、DDL 和特殊语句可能超出教学模型,应对照对应版本文档与实际执行结果。
容易踩的坑
- 认为START TRANSACTION一律立即冻结所有查询快照。
- 以事务id较小推断其版本一定可见。
- 把普通SELECT和FOR UPDATE当成相同读取路径。
- 可见删除后继续找旧值,错误复活记录。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| READ COMMITTED | 普通读取更容易看见新提交。 | 同事务多次读取可变化。 | 接受逐语句新快照的用例。 |
| REPEATABLE READ | 普通一致性读可复用快照。 | 长事务保留历史且仍需写入并发控制。 | 需要事务内稳定快照的读取。 |
| 锁定读 | 可保护后续修改所需记录。 | 增加等待和死锁风险。 | 确需先读后改并保护状态的短事务。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01MVCC意味着所有读都不加锁吗?
展开参考答案与推导
不是,普通一致性读与锁定读等路径不同。
02RR第二次读一定和第一次完全相同吗?
展开参考答案与推导
需限定普通一致性读与相关数据;自身写入和其他访问路径可能改变观察。
03快照活跃事务后来提交能修改旧视图吗?
展开参考答案与推导
不应改变旧视图的活跃集合,新视图可以得到不同结果。
换个情况,还会处理吗
01RR事务中先SELECT旧值再UPDATE看到不同状态怎么办?
展开参考答案与推导
区分一致性读与写入访问路径,结合自身修改和锁等待解释。
02报表事务很久导致历史版本增长怎么办?
展开参考答案与推导
衡量一致性需求,缩短或分批事务,并观察purge与历史压力。
03同应用请求隔离行为不一致怎么查?
展开参考答案与推导
检查真实连接会话状态、池复用和事务开始前的设置路径。
写代码试一试
01如何测试旧视图不接受未来版本?
- 输入 / 条件
next60,版本65- 输出 / 预期
跳过65
提示 边界排他。
展开参考答案与推导
用旧视图读到旧版本,再新建next70视图验证65可见。
02怎样防止删除记录复活?
- 输入 / 条件
可见删除40后有旧值10- 输出 / 预期
不存在
提示 首个可见版本就终止。
展开参考答案与推导
删除标记返回empty而非继续循环。
03如何表示自身写入?
- 输入 / 条件
reader50,版本50- 输出 / 预期
优先可见
提示 自身例外。
展开参考答案与推导
先判断创建者相等,再执行普通未来与活跃集合判断。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能用两个会话区分快照读与锁定读,并据此解释库存或余额更新。
本篇重点加练
快照建立时点与锁定读的可见范围不同,混合使用时需检验事务实际维护了哪些业务条件。
- 快照建立时机
- 锁定读
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
库存与订单保存在同一个
MySQL库,外部通知由 HTTP 发送。画出业务操作的 ACID 边界,并比较 RC、RR 对查询观察的约束。- 提交内容
- 资源边界图与隔离比较
- 怎样算完成
- 不把外部副作用纳入本地提交
- 区分隔离和持久性
练到的知识点ACID 边界 · RC 与 RR
- 作业 02代码推演
事务 A 开始后等待,事务 B 插入并提交,A 才第一次查询,随后 B 再次修改。为 RC 和 RR 分别记录查询时点与需要确认的快照信息。
- 提交内容
- 双事务时间线
- 怎样算完成
- 显式标明首次一致性读
- 记录自动提交和会话隔离设置
练到的知识点一致性读 · 快照建立时机
- 作业 03代码推演
连接池交出的会话可能继承旧配置,应用先执行准备查询再进入报表逻辑。列出判断真正快照建立时刻必须记录的语句及连接状态。
- 提交内容
- 会话时间线与取证表
- 怎样算完成
- 包含自动提交与事务开始方式
- 不只依据应用方法进入时刻
练到的知识点快照建立时机
- 作业 04原理问答
业务先查询“剩余预约数”,再根据该结果锁定其中一行并写入。评审锁定一行与保护查询所得整体条件之间的关系。
- 提交内容
- 条件与锁保护范围图
- 怎样算完成
- 区分单行事实和集合条件
- 注明索引与隔离前提
练到的知识点锁定读
综合应用
- 作业 05面试场景
事务 A 先读取统计列表,再修改其中一条,之后分别普通查询和锁定查询。设计数据样本,审查结果为何可能不是单一历史时刻的整体视图。
- 提交内容
- 查询轨迹与解释
- 怎样算完成
- 区分自身写入和他人提交
- 每次读取类型清楚
练到的知识点自身写入可见 · 锁定读
- 作业 06原理问答
画出一条记录的四个版本和三个事务的开始、提交关系。为两次观察建立版本选择过程,不用实际墙钟先后替代事务可见性规则。
- 提交内容
- 版本链图与判定表
- 怎样算完成
- 覆盖未提交和已删除版本
- 所有判定能回指时间线
练到的知识点MVCC 版本 · 并发实验时间线
- 作业 07排错实验
在隔离本地
MySQL用两个会话复现自定交错,分别运行 RC 与 RR;脚本在每一步暂停等待确认,完整记录查询和提交。- 提交内容
- 建表脚本、双会话记录和结论
- 怎样算完成
- 运行前核对连接配置
- 不使用 sleep 作为唯一交错保证
练到的知识点ACID 边界 · RC 与 RR · 一致性读 · 快照建立时机 · 自身写入可见 · 锁定读 · MVCC 版本 · 并发实验时间线
- 作业 08设计评审
批处理先获得一致性视图,再逐条锁定并修正数据。为报表展示值、校验值和最终写入分别定义时间语义。
- 提交内容
- 报表展示、校验和最终写入各自采用哪个时点数据的说明
- 怎样算完成
- 允许三者不同但要解释
- 包含过程中他人修改
练到的知识点快照建立时机 · 锁定读
- 作业 09排错实验
在本地
MySQL两会话设置有界预约集合,改变首次普通读取与首次锁定读取的顺序,比较业务判断所用版本和最终提交状态。- 提交内容
- SQL、屏障步骤与观察表
- 怎样算完成
- 运行前核对隔离级别
- 避免仅靠固定 sleep 排序
练到的知识点快照建立时机 · 锁定读
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
导出任务持续二十分钟,期间用户频繁更新资料。制定报表一致性目标,并评估长事务、分批查询与锁定读取的代价。
- 提交内容
- 方案对比与观测计划
- 怎样算完成
- 说明每种方案的数据时间口径
- 包含历史版本保留和在线写入影响
练到的知识点一致性读 · 快照建立时机 · 锁定读 · MVCC 版本 · 并发实验时间线
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造所有事务都遵守各自 SQL 规则且均成功提交,最终却违反一个跨行业务约束的场景,明确所依赖的隔离与访问条件。
- 提交内容
- 最小业务与并发历史
- 怎样算完成
- 约束事先定义
- 不假定所有数据库隔离实现相同
练到的知识点快照建立时机 · 锁定读
- 作业 12思维拓展 · 跨域迁移
将数据分析迁移到长时间运行的 Agent 调研任务:先读资料后执行动作,中间资料会变化。对照快照与锁定读思想,定义行动前应重新核查的事实。
- 提交内容
- 跨域读取与行动契约
- 怎样算完成
- 区分可接受陈旧资料和关键条件
- 不声称外部系统拥有数据库锁语义
练到的知识点快照建立时机 · 锁定读
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MySQL 8.4:事务隔离级别 (新标签页)↗
比较 RC、RR 与锁定读行为,明确并发实验采用的事务前提。
- 实践参考文档英文
MySQL 8.4:一致性非锁定读 (新标签页)↗
阅读快照建立时机和可见性,区分快照读、锁定读与自身写入。
- 深入参考文档英文
MySQL 8.4:InnoDB 锁 (新标签页)↗
阅读记录锁、间隙锁与 next-key lock,解释索引范围如何影响阻塞。
- 深入参考文档英文
MySQL锁定读 (新标签页)↗
区分普通读与需要保护后续修改的读取。
- 深入参考文档英文
Connector/J会话隔离设置 (新标签页)↗
查看Java客户端如何请求隔离级别,明确服务端边界。
- 深入参考文档英文
MySQL8.4.0 ReadView C++源码 (新标签页)↗
核对
changes_visible的创建者、未来边界与活跃事务规则;此来源不是Java实现。