MySQL 事务隔离:从脏读、幻读到 MVCC 与 Next-Key Lock
2026/7/31 16:48:26 网站建设 项目流程

我是安徽最忧郁程序员 无隅

事务隔离最容易学成一张需要死记的表:四种级别、三种异常,再加上 MVCC 和各种锁。表格背下来以后,一旦问题变成“InnoDB 的 RR 到底会不会幻读”,结论就开始互相冲突。

问题的根源是,那张表只描述了隔离级别的目标,没有解释 InnoDB 如何实现这些目标。

理解 InnoDB 事务隔离的关键,是先判断一条语句读取哪个数据版本,再判断它是否加锁、锁住哪段索引范围。沿着这条主线,四种隔离级别、MVCC 和 Next-Key Lock 才能真正连在一起。


1. 为什么事务并发会产生读取异常

假设事务 A 正在查询数据,事务 B 同时修改或插入数据。如果数据库不约束两个事务之间的数据可见性,事务 A 的读取结果就可能受到事务 B 执行进度的影响。

常见的并发读取异常有三种。

脏读(Dirty Read),是事务 A 读到了事务 B 尚未提交的数据。事务 B 一旦回滚,事务 A 读到的值就从来没有真正生效过。

不可重复读(Non-Repeatable Read),是事务 A 在同一事务中两次读取同一行,事务 B 在两次读取之间修改该行并提交,导致事务 A 得到了两个不同的值。它关注的是同一行的值发生变化

幻读(Phantom Read),是事务 A 两次执行相同的范围查询,事务 B 在中间插入或删除了满足条件的行,导致第二次查询的结果集发生增减。它关注的是满足条件的行集合发生变化

可以用一句话区分后两者:

  • 不可重复读:原来的行还在,但行里的值变了。
  • 幻读:相同条件下,结果集中多行或少行了。

这些异常不是三个孤立概念,而是数据库设置不同隔离级别时,需要逐步限制的并发行为。


2. 四种隔离级别,本质上是在限制事务能看见什么

事务隔离级别控制的核心,是一个事务能够看见其他事务执行到哪个阶段的数据。隔离级别越高,事务之间的可见性约束越强,但锁等待和并发成本通常也会增加。

隔离级别读取语义脏读不可重复读幻读
READ UNCOMMITTED可能读取其他事务尚未提交的数据可能可能可能
READ COMMITTED每次语句读取当时已经提交的数据避免可能可能
REPEATABLE READ同一事务中的一致性读通常复用同一快照避免避免标准层面仍可能
SERIALIZABLE使用更严格的锁定规则获得可串行化效果避免避免避免

READ UNCOMMITTED 的隔离性最低。它允许普通读取接触尚未提交的版本,因此可能出现脏读,查询结果也不保证可以重复。

READ COMMITTED,简称 RC。每次一致性读都会建立新的快照,所以只能读取已经提交的数据,但同一事务的下一次查询可能看见其他事务刚提交的修改。

REPEATABLE READ,简称 RR。同一事务中的一致性读通常基于第一次一致性读建立的快照,后续普通SELECT因而能够看到一致的数据状态。

SERIALIZABLE 的隔离约束最强。以 InnoDB 为例,在关闭自动提交时,普通SELECT会被隐式转换为SELECT ... FOR SHARE。它的目标是让并发执行的结果等价于某种串行顺序,不等于数据库简单地让所有事务全局一个接一个运行

这里必须分清两个层次:

  1. SQL 标准说明每种隔离级别需要避免哪些并发现象。
  2. InnoDB 再通过 MVCC、记录锁、间隙锁等机制实现这些隔离语义。

因此,通用表格可以帮助建立第一层认识,却不能直接回答 InnoDB 在某条具体 SQL 上会不会出现幻读。


3. 为什么 InnoDB 默认选择 REPEATABLE READ

MySQL 8.4 官方文档明确说明,InnoDB 支持四种事务隔离级别,默认使用 REPEATABLE READ。

可以通过下面的语句查看当前会话的隔离级别:

SELECT@@transaction_isolation;

也可以只修改当前会话,避免影响其他连接:

SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;

RR 能成为 InnoDB 的默认级别,关键不只是它在隔离级别表中处于一个较高位置,而是 InnoDB 为两种读取方式分别准备了机制:

  • 普通SELECT属于一致性非锁定读,通常使用 MVCC 读取快照。
  • SELECT ... FOR UPDATESELECT ... FOR SHARE等锁定读,以及UPDATEDELETE,需要读取并锁定较新的数据库状态。

对于前者,旧版本让读取不必阻塞写入;对于后者,记录锁、Gap Lock 和 Next-Key Lock 又能保护将要修改的数据与索引范围。

RR 的实际能力来自“MVCC + 锁”的组合,而不是某一个机制单独完成全部工作。


4. MVCC:不加读锁,也能读到一致的数据版本

MVCC 的全称是 Multi-Version Concurrency Control,即多版本并发控制。

它解决的核心问题是:当一行数据正在被修改时,普通查询怎样在不等待写事务结束的情况下,仍然读到一个一致的结果?

答案不是复制多张完整的数据表,而是利用记录中的事务信息和 undo log,按需重建当前查询能够看见的历史版本。

4.1 MVCC 的本质

假设某行数据先后从 100 修改为 200,又从 200 修改为 300。聚簇索引记录中保存的是较新的状态,而构造旧版本所需的信息保存在 undo log 中。

当一个较早建立的快照需要读取这行数据时,InnoDB 会从当前记录出发,沿回滚指针向历史版本追溯,直到找到对当前 Read View 可见的版本。

所以,MVCC 中的“多版本”更准确地说,是当前记录加上 undo log 中可用于重建历史状态的信息

普通一致性读不必给读取到的行加共享锁。其他事务仍然可以更新这些行,而当前查询可以通过历史版本继续完成读取,这就是常说的“读写不冲突”。

4.2 MVCC 的三个关键组成

第一部分是 InnoDB 记录中的内部事务信息。

  • DB_TRX_ID:记录最后一次插入或更新该行的事务标识。
  • DB_ROLL_PTR:指向 undo log 中相关记录的回滚指针。
  • DB_ROW_ID:当表中没有主键,也没有合适的非空唯一索引作为聚簇索引时,InnoDB 才会生成并使用隐藏行标识。

因此,把DB_ROW_ID说成每一行都必然使用的 MVCC 字段并不严谨。真正参与版本追溯的核心是事务标识、回滚指针和 undo log。

第二部分是undo log。事务修改聚簇索引记录时,会写入撤销信息。它既服务于事务回滚,也能在一致性读需要旧数据时重建历史版本。MySQL 关于 undo log 的说明也明确指出,一致性读需要原始数据时,可以从 undo log 记录中获取。

第三部分是Read View。它可以理解为一次一致性读对事务可见性的判断依据,记录了创建视图时活跃事务的大致边界。InnoDB 检查版本上的事务标识,判断该版本是在快照之前已经提交、由当前事务产生,还是当时仍不可见。

Read View 自身不会复制数据,也不会修改版本链。它只负责回答一个问题:这个版本对当前快照是否可见?

4.3 RC 与 RR 的关键差异

RC 和 RR 都会使用一致性读与历史版本,但二者创建快照的时机不同。

在 READ COMMITTED 下,每次一致性读都会建立新的快照。事务 A 第一次查询后,如果事务 B 修改并提交,事务 A 的第二次普通查询就可能看见新值。

在 REPEATABLE READ 下,同一事务中的一致性读通常复用第一次一致性读建立的快照。需要注意,快照默认不是执行START TRANSACTION的瞬间就一定创建,而是在第一次一致性读时建立;START TRANSACTION WITH CONSISTENT SNAPSHOT是一个需要单独讨论的显式方式。

这也说明,“MVCC 只在 RC 和 RR 下工作”是一种便于入门但过于绝对的说法。更严谨的表述是:RC 与 RR 是讨论 InnoDB 一致性快照语义时最核心的两个隔离级别,它们最主要的差异是快照更新频率。


5. 幻读为什么不能只用 MVCC 解释

幻读涉及结果集中的行是否会增加或减少。分析它之前,必须先确定 SQL 属于快照读还是当前读。

5.1 快照读:普通 SELECT

在 RC 和 RR 下,不带锁定子句的普通SELECT通常属于一致性非锁定读,也就是常说的快照读。

在 RR 中,连续的普通SELECT通常复用同一个 Read View。即使其他事务在中间插入并提交了满足条件的新行,这个新版本对旧快照仍不可见,事务 A 的第二次快照读也就不会突然多出一行。

这种场景依靠的是 MVCC。它没有阻止事务 B 插入,而是让事务 A 继续读取原来的数据快照。

5.2 当前读:读取并锁定最新状态

如果查询结果接下来要参与修改,旧快照通常不够用。例如,系统查询一批待处理任务后准备更新它们,就必须避免其他事务同时抢占相同任务。

常见的锁定读包括:

SELECT...FORUPDATE;SELECT...FORSHARE;

UPDATEDELETE在定位并修改记录时同样需要读取并锁定较新的状态。这类语句在中文资料中经常被统称为当前读。

当前读不能只依靠 MVCC,因为它不仅要“看见什么”,还要保护接下来将要读取或修改的对象。对于范围条件,只锁住现有记录并不够:其他事务仍可能从记录之间的空隙插入新行。

5.3 Next-Key Lock 如何阻止幻影行插入

InnoDB 的索引锁可以从三个概念理解:

  • Record Lock:锁住一条已有的索引记录。
  • Gap Lock:锁住两条索引记录之间的间隙,主要用于阻止插入。
  • Next-Key Lock:前开后闭的索引区间,可以理解为“记录前的间隙 + 该索引记录”。

图中的(10, 20]表示,10 和 20 之间的间隙被保护,同时索引记录 20 也被锁定。事务 B 尝试插入索引值 15 时,会因为插入意向与间隙上的锁冲突而等待,直到事务 A 提交或回滚。

但不能把它简化成“只要是当前读,就一定把条件范围全部加上 Next-Key Lock”。MySQL 官方隔离级别文档给出了更具体的边界:

  • 使用唯一索引和唯一等值条件找到一条记录时,InnoDB 通常只锁索引记录,不锁前面的间隙。
  • 使用范围条件或非唯一条件时,InnoDB 通常对扫描到的索引范围使用 Gap Lock 或 Next-Key Lock。
  • 在 RC 下,间隙锁大部分被禁用,主要保留给外键约束检查和重复键检查。

锁的对象不是 SQL 文本里的抽象WHERE条件,而是执行过程中实际扫描到的索引记录和索引间隙。索引设计和执行计划会直接影响加锁范围;缺少合适索引时,扫描与锁定范围都可能显著扩大。


6. RR 到底有没有完全解决幻读

这个问题不能脱离数据库实现和读取方式,只回答“解决了”或“没有解决”。

6.1 先给结论

从 SQL 标准的一般定义看,REPEATABLE READ 不承诺避免所有幻读,SERIALIZABLE 才提供最强的可串行化保证。

但在 InnoDB 的常见使用场景中:

  • 连续快照读依靠固定 Read View,通常不会看见其他事务后来提交的幻影行。
  • 正确的范围锁定读依靠 Gap Lock 或 Next-Key Lock,通常能够阻止其他事务向受保护范围插入新行。

因此,更准确的结论是:

InnoDB 的 RR 通过 MVCC 和索引范围锁避免了大量典型幻读,但它不是一句脱离 SQL 类型、索引和操作顺序的无条件保证。

6.2 为什么“先快照读,再 UPDATE”会产生理解冲突

考虑下面的执行顺序:

  1. 事务 A 使用普通SELECT,读取旧快照。
  2. 事务 B 插入一条满足条件的新行并提交。
  3. 事务 A 执行SELECT ... FOR UPDATEUPDATE

第三步不能继续只看旧快照,因为它需要基于较新的数据库状态完成锁定或修改。于是,同一个事务中可能同时出现两幅不同的数据图景:普通SELECT看到旧快照,锁定语句看到较新的状态。

很多面试资料把这种现象称为 RR 下的“特殊幻读”。如果严格按照“同一事务用相同查询执行两次”的定义,它又不是两个完全相同的读取操作,因为第二次已经从快照读切换成了当前读。

MySQL 8.4 官方文档直接提醒,不建议在同一个 RR 事务中混用非锁定SELECT与锁定语句。前者展示 Read View 中的旧状态,后者使用较新的状态进行锁定,两种表状态放在一起往往难以理解。

所以,这个案例真正要记住的不是“RR 突然失效”,而是:

快照读与当前读服务于不同目标,混用它们时,不能假设所有语句都观察同一份数据库状态。

6.3 工程上的正确选择

如果业务只需要在一个事务中完成一致性查询,可以使用 RR 下的普通快照读,让 MVCC 提供稳定视图。

如果先查询、后更新,而且查询结果决定后续写操作,应当从一开始就评估SELECT ... FOR UPDATESELECT ... FOR SHARE,并为查询条件建立合理索引。这样锁定范围和业务意图才能保持一致。

如果业务必须获得严格的可串行化语义,可以评估 SERIALIZABLE,但要接受更高的锁等待、冲突和重试成本。隔离级别不是越高越好,而是要与一致性要求和并发压力匹配。


7. 用两个会话验证 RC、RR 与当前读的差异

下面使用一张简单的account表和一张带范围索引的orders表完成三个实验。建议准备两个 MySQL 客户端连接,分别作为会话 A 和会话 B。

先执行一次初始化:

DROPTABLEIFEXISTSaccount;CREATETABLEaccount(idINTPRIMARYKEY,balanceINTNOTNULL)ENGINE=InnoDB;INSERTINTOaccountVALUES(1,100);DROPTABLEIFEXISTSorders;CREATETABLEorders(idINTPRIMARYKEY,amountINTNOTNULL,INDEXidx_amount(amount))ENGINE=InnoDB;INSERTINTOordersVALUES(1,100),(2,200);

实验一:RC 与 RR 的快照差异

会话 A 先使用 RC:

SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;STARTTRANSACTION;SELECTbalanceFROMaccountWHEREid=1;-- 100

会话 B 修改并提交:

UPDATEaccountSETbalance=200WHEREid=1;COMMIT;

会话 A 再次执行相同查询:

SELECTbalanceFROMaccountWHEREid=1;-- 200COMMIT;

RC 的第二次一致性读建立了新快照,因此能够看到事务 B 已经提交的 200。

把测试数据恢复为 100,再将会话 A 的隔离级别换成 RR,重复相同顺序:

SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;STARTTRANSACTION;SELECTbalanceFROMaccountWHEREid=1;-- 100-- 会话 B 将 balance 更新为 200 并提交后SELECTbalanceFROMaccountWHEREid=1;-- 仍然是 100COMMIT;

RR 的第二次普通查询复用了第一次一致性读建立的快照,所以仍然得到 100。

实验二:RR 下的范围锁定读

会话 A 对金额范围执行锁定读:

SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;STARTTRANSACTION;SELECT*FROMordersWHEREamountBETWEEN100AND200FORUPDATE;

会话 B 尝试插入落在该索引范围内的新值:

INSERTINTOorders(id,amount)VALUES(3,150);

在典型的 RR 范围锁定场景中,会话 B 会等待,因为会话 A 已经对扫描到的索引范围及相关间隙加锁。会话 A 执行COMMIT后,会话 B 才能继续。

如果实际结果与预期不同,应先检查当前隔离级别、表引擎、查询使用的索引和执行计划,而不是只看WHERE条件的字面范围。

实验三:混用快照读与当前读

先确保orders表中不存在amount = 150的记录。会话 A 建立 RR 快照:

SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;STARTTRANSACTION;SELECT*FROMordersWHEREamount=150;-- 空结果

会话 B 插入并提交:

INSERTINTOorders(id,amount)VALUES(3,150);COMMIT;

会话 A 再执行一次普通SELECT,仍然读取旧快照:

SELECT*FROMordersWHEREamount=150;-- 仍为空

随后改用锁定读:

SELECT*FROMordersWHEREamount=150FORUPDATE;-- 可以读取并锁定新行COMMIT;

两次查询结果不同,不是因为 Read View 在事务中途被偷偷替换,而是因为后一次语句已经切换到锁定读语义,需要读取并锁定较新的状态。


8. 总结:用“读类型”串起隔离级别、MVCC 与锁

事务隔离级别规定的是并发事务之间的数据可见性。脏读、不可重复读和幻读,则分别描述读取未提交值、同一行值变化和结果集增减三类异常。

InnoDB 默认使用 RR,但 RR 的能力并不只来自一张快照:

  • 快照读依靠 MVCC、undo log 和 Read View,选择对当前事务可见的数据版本。
  • 当前读依靠记录锁、Gap Lock 和 Next-Key Lock,保护正在读取或修改的索引记录与间隙。

以后再遇到“RR 会不会幻读”这类问题,可以先问三个问题:

  1. 当前语句是快照读,还是锁定读、更新或删除?
  2. SQL 实际使用了哪个索引,扫描并锁定了哪些记录和间隙?
  3. 同一事务是否混用了旧快照与较新的当前状态?

理解 InnoDB 事务隔离的关键,不是死记哪一格打勾,而是判断一条语句读取哪个版本、是否加锁,以及锁住了哪段索引范围。

本文涉及的实现边界可以继续参考 MySQL 8.4 官方文档:

  • Transaction Isolation Levels
  • Consistent Nonlocking Reads
  • Locking Reads
  • Phantom Rows
  • InnoDB Multi-Versioning

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询