一、💡 隔离级别到底在解决什么
一句话理解
多个事务同时操作数据库时,隔离级别规定事务之间可以怎样互相影响,以及一个事务能看到其他事务的哪些修改。
假设你在查询账户余额,另一个事务正在扣款。此时有三个问题:
- 对方还没提交,我能看到它修改后的余额吗?
- 对方提交之后,我在同一个事务里再次查询,余额会变化吗?
- 如果我查询的是一批订单,对方新插入的订单会出现在第二次结果里吗?
这就是隔离级别要回答的问题。事务能保证一组操作一起提交或回滚,但多个事务之间的干扰还需要并发控制。
本篇先看并发问题,再比较四种隔离级别,最后讨论业务选择和常见误区。以下以 MySQL InnoDB 为主。
前置笔记:05.MySQL 事务。实现原理接着阅读 07.MySQL MVCC 和 08.MySQL 锁。
阅读提示:涉及 MVCC 和锁的内容会单独标注
本篇会提前用到 MVCC 的快照、Read View 和版本链,以及锁定读、共享锁、范围锁和死锁。相关位置会标注“涉及 MVCC”或“涉及锁”。第一次阅读时,先理解查询结果及并发操作的表现;快照与可见性原理在 07.MySQL MVCC 中展开,具体锁语法、类型与加锁范围在 08.MySQL 锁 中展开。
二、⚠️ 先认识三种读取异常
下面的事务 A、事务 B 表示两个独立连接中的事务。每个例子相互独立,重点看操作顺序以及事务是否已经提交。
2.1 脏读:读到了别人未提交的修改
账户余额原来是 1000 元:
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 将余额改成 900,尚未提交 | |
| ② | 读到 900 | |
| ③ | 回滚,余额恢复为 1000 |
事务 B 读到的 900 最终没有生效。如果它据此拒绝付款或生成账单,就使用了错误的数据。
判断重点
看到了其他事务尚未提交的数据,就发生了脏读。对方即使后来提交,也不能改变读取发生时它尚未提交的事实。
2.2 不可重复读:同一事务里,同一行前后读到不同的值
账户余额仍然从 1000 元开始:
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,第一次读取余额,得到 1000 | |
| ② | 将余额改成 900,并提交 | |
| ③ | 在原事务内再次读取,得到 900 |
两次读到的都是已经提交的数据,因此没有脏读;但同一个事务内,同一账户的余额从 1000 变成了 900,出现了不可重复读。
判断重点
关注同一事务中重复读取同一行,内容是否保持一致。
2.3 幻读:同一事务里,相同条件查出的记录集合变了
订单表原来只有 1 条待支付订单:
| 顺序 | 事务 A:新增订单 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,查询待支付订单,得到 1 条 | |
| ② | 插入另一条待支付订单,并提交 | |
| ③ | 在原事务内按相同条件查询,得到 2 条 |
查询条件始终是 status = 'PENDING',第二次却多出一条符合条件的记录,这就是幻读。其他事务删除记录或修改条件字段,也可能改变符合条件的记录集合。
判断重点
关注相同条件对应的记录集合是否变化。行数变化是常见表现,但即使总行数相同,集合中的成员也可能发生变化。
2.4 三种问题怎么区分
| 异常 | 核心问题 | 记忆方式 |
|---|---|---|
| 脏读 | 读到了其他事务未提交的修改 | 看了别人的草稿 |
| 不可重复读 | 同一行前后读到的内容不同 | 同一个账户的余额变了 |
| 幻读 | 相同条件查出的记录集合不同 | 待支付订单里多出一条记录 |
不可重复读和幻读的例子都要求两次读取发生在同一个事务中。如果用户刷新两次页面,每次查询都属于不同事务,看到新数据通常就是正常行为。
三、🧱 四种隔离级别分别保证什么
InnoDB 支持四种隔离级别,默认是 REPEATABLE READ(可重复读),实际配置可以修改。
先看 SQL 标准中常见的读异常对照,再单独理解 InnoDB 的实现:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
READ UNCOMMITTED(RU,读未提交) | 可能 | 可能 | 可能 |
READ COMMITTED(RC,读已提交) | 不允许 | 可能 | 可能 |
REPEATABLE READ(RR,可重复读) | 不允许 | 不允许 | 标准允许,InnoDB 需区分读取方式 |
SERIALIZABLE(可串行化) | 不允许 | 不允许 | 不允许 |
这张表的边界
这张表用于理解三种经典读异常,不能据此判断所有业务并发问题是否安全。例如,先查余额、在程序里计算、再写回固定值,仍需要单独设计并发保护,见第五章。
3.1 READ UNCOMMITTED:允许读取未提交的数据
账户余额原来是 1000 元,事务 B 使用 RU:
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,将余额改成 900,尚未提交 | |
| ② | 开始事务,普通查询可能读到 900 | |
| ③ | 回滚,余额恢复为 1000 |
事务 B 读到的 900 是事务 A 尚未提交的修改。A 随后回滚,B 已经看过的数字便失去了依据。
它允许脏读,也无法保证重复读取同一行或同一范围时结果稳定。学习时可以用它演示脏读,普通业务通常不采用。
3.2 READ COMMITTED:每次查询都以本次读取时的已提交数据为准
RC 避免读取其他事务未提交的修改,但允许后续查询看到其他事务新提交的数据。
涉及 MVCC:为什么每次查询可以看到新提交的数据
MVCC 即“多版本并发控制”,可以先理解为:数据库保留数据的多个版本,普通一致性读根据可见性规则选择能读取的版本。Read View 是判断哪些版本可见的依据。在 RC 下,每次普通一致性读都会建立新的 Read View,因此后一次查询可能看到前一次查询之后其他事务提交的修改。版本如何保存、如何判断可见,详见 07.MySQL MVCC。
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,第一次普通查询余额,得到 1000 | |
| ② | 将余额改成 900,并提交 | |
| ③ | 在原事务内再次普通查询,得到 900 |
这个结果符合 RC 的承诺:两次都读已提交数据,但两次查询的观察时间点可以不同。因此 RC 能防止脏读,仍可能发生不可重复读和幻读。
上面的余额例子展示了不可重复读。如果查询的是一批符合条件的记录,RC 也可能发生幻读。假设订单表原来只有 1 条待支付订单:
| 顺序 | 事务 A:新增订单 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,普通查询待支付订单,得到 1 条 | |
| ② | 插入另一条待支付订单,并提交 | |
| ③ | 在原事务内按相同条件再次普通查询,得到 2 条 |
两次查询的条件都是 status = 'PENDING'。事务 A 提交后,事务 B 的第二次查询可以看到新订单,符合条件的记录集合从 1 条变成了 2 条,这就是幻读。两次都只读取已提交的数据,因此没有脏读。
3.3 REPEATABLE READ:同一事务中的普通查询复用稳定快照
涉及 MVCC:快照是什么
这里的“快照”指读取数据时使用的可见性视角,不是复制一份完整数据库。在 RR 下,同一事务的后续普通一致性读通常复用首次一致性读建立的 Read View,从数据版本中读取符合该视角的内容,所以其他事务提交后,本事务仍可能读到旧值。Read View、版本链以及本事务修改为何可见,详见 07.MySQL MVCC。
RR 下,第一次普通一致性读通常建立快照,后续普通一致性读复用它:
| 顺序 | 事务 A:扣款 | 事务 B:查询 |
|---|---|---|
| ① | 开始事务,第一次普通查询余额,得到 1000,并建立快照 | |
| ② | 将余额改成 900,并提交 | |
| ③ | 在原事务内再次普通查询,仍然得到 1000 | |
| ④ | 提交事务 | |
| ⑤ | 开启新事务,普通查询余额,可以看到 900 |
RR 并非永远看不到新数据,而是让当前事务的普通一致性读保持稳定视角。需要读取新提交的数据时,可以结束原事务,在新事务中查询。
理解这条规则,要注意三个条件:
- 同一个事务:不同事务不会因为都使用 RR 就自动共享同一快照。
- 普通一致性读:
SELECT ... FOR UPDATE等锁定读不遵循同一套快照读取规则。 - 事务自己的修改可见:如果本事务改了数据,后续查询可以看到自己的修改,不能把快照理解成完全冻结的数据库副本。
涉及锁:
SELECT ... FOR UPDATE是什么它是在普通查询末尾加上
FOR UPDATE,表示读取时申请排他锁,为后续修改提供保护;这条查询本身不会修改数据。通常在显式事务中使用,锁在提交或回滚时释放。其他事务的冲突修改或加锁操作可能需要等待,普通快照查询通常仍可读取。这里先记住它属于“加锁的查询”,详细用法见 08.MySQL 锁。
快照通常在第一次普通一致性读时建立,单独执行 START TRANSACTION 不代表此时就已经固定了快照。版本链和快照可见性详见 07.MySQL MVCC。上述读取边界见 官方一致性读说明。
3.4 SERIALIZABLE:保证并发结果可以解释为某种串行顺序
可串行化要求:多个事务并发执行的结果,应当等价于它们按照某种先后顺序执行的结果。
它不意味着整个数据库同一时刻只能运行一个事务。事务仍可以并发执行,但冲突访问受到更严格的控制。
在 InnoDB 中,关闭自动提交时,普通 SELECT 会隐式转换为共享锁定读。例如,一个事务读取某个范围后,另一个事务尝试进行冲突修改,就可能需要等待。
这种保证通常带来更多锁等待,也可能出现死锁,需要应用处理重试。应当根据业务一致性要求和实际负载评估使用。
涉及锁:共享锁、锁等待与死锁
共享锁定读会对读取的数据加共享锁,其他事务的冲突修改可能需要等待;多个事务互相等待对方持有的锁,可能形成死锁。详细机制与处理方式见 08.MySQL 锁。
3.5 用一句话串起四种级别
其中 RC 与 RR 的普通一致性读涉及 MVCC,读取视角的实现原理见 07.MySQL MVCC。
RU:其他事务没提交的修改,也可能看到。
RC:每次普通查询,都看本次读取时已提交的数据。
RR:同一事务的普通查询,通常保持同一个快照视角。
可串行化:并发结果应等价于某种串行执行顺序。四、🔍 InnoDB 的 RR 到底能不能防幻读
这是隔离级别最容易回答含糊的问题,必须说明使用了什么读取方式。
4.1 连续两次普通 SELECT:通过稳定快照保持结果集合
涉及 MVCC:新增订单为什么不可见
本节通过 MVCC 的快照可见性解释查询结果。即使其他事务已经插入并提交,新订单也不会自动进入当前事务原有的读取视角;后续普通查询仍按原 Read View 判断可见性。详细原理见 07.MySQL MVCC。
假设订单表原来只有 1 条待支付订单,事务 B 使用 RR,且没有修改相关数据。两次普通查询的条件都是 status = 'PENDING':
| 顺序 | 事务 A:新增订单 | 事务 B:普通查询 |
|---|---|---|
| ① | 开始事务,第一次普通查询待支付订单,得到 1 条,并建立快照 | |
| ② | 开始事务,插入另一条待支付订单,并提交成功 | |
| ③ | 在原事务内按相同条件再次普通查询,仍然得到原来的 1 条 | |
| ④ | 提交事务 | |
| ⑤ | 开启新事务,再次普通查询,可以看到 2 条 |
看表时抓住一点:A 已经成功插入并提交,B 在原事务里仍然沿用旧快照,所以看不到新增的订单。
新订单在快照建立后提交,对这个快照不可见。这里保证的是查询视角稳定,事务 A 的插入仍然可以成功。
4.2 范围锁定读:通过锁限制其他事务插入
涉及锁:范围锁定读与锁类型
本节提前涉及 08.MySQL 锁 中的锁定读、记录锁、间隙锁与 next-key lock。先理解“查询时加锁,可以让其他事务的冲突操作等待”即可;具体加锁范围在锁的笔记中展开。下面的查询应放在显式事务中理解,锁通常保持到事务提交或回滚。
如果事务 B 使用范围锁定读:
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;在 RR 下,InnoDB 通常会对实际扫描的索引范围使用记录锁与间隙锁(常见形式为 next-key lock)。其他事务向被锁定的间隙插入记录时,需要等待。
仍假设原来只有 1 条待支付订单,事务 B 使用 RR。以下以 status 上有索引、查询锁住相关索引范围,且 A 的新订单需要插入被锁定间隙为例;B 的两次查询都使用上面的 FOR UPDATE 写法:
| 顺序 | 事务 A:新增订单 | 事务 B:加锁查询 |
|---|---|---|
| ① | 开始事务,查询待支付订单并加锁,得到 1 条,锁住相关索引范围 | |
| ② | 开始事务,尝试插入另一条待支付订单;插入位置受到 B 的锁保护,进入等待,插入尚未完成 | |
| ③ | 仍在等待,无法完成此次插入并提交 | 在原事务内按相同条件再次加锁查询,仍然得到 1 条 |
| ④ | 提交事务,释放锁 | |
| ⑤ | 等待结束,插入继续执行,完成后提交 | |
| ⑥ | 开启新事务,再次查询,可以看到 2 条 |
看表时抓住一点:B 持锁期间,A 的这次插入被阻塞;B 第二次查到 1 条,是因为新增订单还没能插进去。
这里保证的是对相应索引范围的并发修改进行限制。具体锁住什么范围取决于索引、查询条件和执行计划,详见 08.MySQL 锁 及 官方隔离级别说明。
4.3 两种保证不能混为一谈
本节同时涉及两部分知识:普通一致性读的快照与可见性属于 07.MySQL MVCC;锁定读与范围锁属于 08.MySQL 锁。
| 对比问题 | 4.1:普通一致性读(涉及 MVCC) | 4.2:范围锁定读(涉及锁) |
|---|---|---|
| B 怎么查询? | 普通 SELECT | SELECT ... FOR UPDATE |
| 靠什么保持结果稳定? | 同一事务复用原快照 | 锁保护实际扫描的相关索引范围 |
| A 能插入这条新订单吗? | 可以插入并提交 | 插入被锁定间隙时,需要等待 |
| B 第二次为什么仍查到 1 条? | 新订单已提交,但对原快照不可见 | 新订单的插入被阻塞,尚未完成 |
| B 结束事务后呢? | 新事务可以建立新快照,看到已提交的新订单 | 释放锁后,A 才能继续插入;A 提交后,新事务可以看到新订单 |
回答边界
可以说“InnoDB 的 RR 通过快照读和范围锁机制处理相应场景下的幻读”,但不能扩大为“事务里任何读写组合,查询结果都绝不会变化”。混用普通查询、锁定读和增删改,或者本事务修改了相关数据时,需要分别分析。
五、🛠️ 业务里怎么选,哪些问题还要单独处理
5.1 先判断:需要新的观察时间点,还是统一的统计口径
涉及 MVCC:报表为什么需要同一事务
本节的“统一数据视角”依赖 RR 事务内普通一致性读复用 Read View。不同事务不会自动共享同一 Read View,因此多条查询各自提交,不能保证使用同一快照。原理见 07.MySQL MVCC。
| 业务要求 | 可以考虑 | 使用条件 |
|---|---|---|
| 同一事务里的后续查询需要看到其他事务新提交的数据 | RC | 接受前后读取结果变化 |
| 对账、报表需要多次查询使用同一数据视角 | RR | 查询在同一事务中,主要使用普通一致性读 |
| 业务要求并发执行满足可串行化 | SERIALIZABLE | 评估锁等待、死锁重试和吞吐量 |
| 演示读取未提交数据 | RU | 只作为学习实验,普通业务通常不用 |
例如,一份报表依次统计订单数、支付金额和退款金额。如果要求这些查询基于同一个快照,需要把它们放在同一 RR 事务中执行;仅把数据库默认级别设为 RR,再执行三个各自自动提交的查询,不能保证它们共享快照。
用户刷新页面一般会发起新请求、开启新事务。即使使用 RR,也可以在下一次刷新时看到新提交的数据。因此,不能仅凭“页面要展示最新状态”,就断定必须选择 RC。
选择时先遵循业务一致性要求,再结合现有配置、查询方式和并发负载评估。RC 可能减少部分范围锁冲突,但不代表所有业务改成 RC 都更快。(涉及锁:范围锁、锁等待和死锁重试,详见 08.MySQL 锁。)
5.2 余额与库存:隔离级别不能代替业务并发控制
账户余额原来是 1000 元,事务 A 要扣 100 元,事务 B 要扣 200 元。两个事务都先普通查询余额,在程序里计算,再将算好的固定值写回:
| 顺序 | 事务 A:扣款 100 元 | 事务 B:扣款 200 元 |
|---|---|---|
| ① | 开始事务,普通查询余额,得到 1000 | |
| ② | 开始事务,普通查询同一账户余额,也得到 1000 | |
| ③ | 在程序里计算:1000 - 100 = 900 | |
| ④ | 在程序里计算:1000 - 200 = 800 | |
| ⑤ | 将余额写成固定值 900,并提交 | |
| ⑥ | 将余额写成先前算好的固定值 800,并提交 |
| 结果对比 | 余额 | 原因 |
|---|---|---|
| 实际结果 | 800 元 | B 将余额直接写成 800,覆盖了 A 的扣款效果 |
| 正确结果 | 700 元 | 两次扣款都应生效:1000 - 100 - 200 = 700 |
看表时抓住一点:B 写入的 800 是根据旧余额 1000 算出来的固定值,不是在数据库当前余额上再扣 200。
B 写回的固定值覆盖了 A 的扣款效果,这叫丢失更新。RC 或 RR 都不能自动识别应用写回的固定值已经过时。
常见处理方式:
| 方式 | 怎么做 | 如何避免上面的覆盖问题 |
|---|---|---|
| 条件原子更新 | 执行 UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100;,根据影响行数判断是否扣款成功 | 让数据库在当前余额上直接扣款,避免写回程序根据旧余额算出的固定值 |
| 先锁再改(涉及锁,详见 08.MySQL 锁) | 在同一事务中使用 SELECT ... FOR UPDATE,读取并获得锁后再计算和修改 | 其他事务的冲突修改需要等待,避免读取后、写入前被别人修改 |
| 版本号检查 | 更新时同时检查旧版本号;不匹配则按业务要求重新读取并重试,或提示冲突 | 发现读取后数据已被别人修改,就拒绝直接写回过时的计算结果 |
这些措施分别解决相应的并发修改问题;完整支付流程还需要事务、幂等与业务约束配合。
5.3 稳定快照也要控制事务时长
涉及 MVCC:旧版本为什么不能立即清理
旧数据版本可能仍被活跃事务的 Read View 使用,因此需要保留。长时间持有读取视角,可能延迟相关旧版本的清理。版本链、undo log 与旧版本清理的关系见 07.MySQL MVCC。
报表或导出事务持续太久,会长时间占用连接,并可能让旧版本无法及时清理。应控制事务时长,根据数据量选择合适的导出方案。
如果改为分批、分事务处理,就需要重新评估跨批次的一致性,不能同时假定每批独立提交又天然共享同一个 RR 快照。
六、🎤 高频面试题
6.1 MySQL 默认隔离级别是什么
InnoDB 默认是可重复读,也就是
REPEATABLE READ。实际环境可以修改,回答默认值时要明确存储引擎。
6.2 RC 和 RR 有什么区别
本题涉及 MVCC:两种级别建立、复用 Read View 的规则见 07.MySQL MVCC。
对普通一致性读,RC 的每次查询使用新的数据视角,能看到本次读取前其他事务已提交的修改;RR 的后续查询通常复用首次一致性读建立的快照。因此,两者都能避免脏读,RC 仍可能发生不可重复读,而 RR 能保持普通重复读取的稳定性。
6.3 不可重复读和幻读怎么区分
不可重复读关注同一行的内容是否变化;幻读关注相同条件对应的记录集合是否变化。两者都强调同一个事务内的重复读取。
6.4 InnoDB 的 RR 能防幻读吗
本题涉及 MVCC 和锁:稳定快照的可见性原理见 07.MySQL MVCC,范围锁定读与 next-key lock 的原理见 08.MySQL 锁。
要区分读取方式。连续普通一致性读通过同一个快照保持结果集合稳定;范围锁定读通过 next-key lock 等机制限制被锁定范围内的插入。混合读写和事务自身修改的情况需要单独分析,不能笼统说所有场景都不会变化。
6.5 隔离级别是不是越高越好
本题涉及锁:锁等待、死锁与并发保护方式见 08.MySQL 锁。
应当选择满足业务一致性要求的级别。更强的隔离可能增加等待、冲突与重试成本;余额、库存等业务还需要条件更新、锁或版本检查,不能只靠调整隔离级别。
七、📌 快速回顾与后续阅读
先识别问题:未提交数据?同一行变了?结果集合变了?
再理解规则:RU、RC、RR、SERIALIZABLE 分别提供什么保证?
最后落到业务:需要同一快照,还是后续查询看到新提交数据?
涉及写入:额外检查条件更新、锁、版本号和业务约束。接下来阅读 07.MySQL MVCC,理解版本链与 Read View 如何实现可见性;再阅读 08.MySQL 锁,理解锁类型、范围及死锁。