news 2026/10/1 2:13:34

MySQL 事务隔离级别是怎么实现的?图解 Read View 与 MVCC 多版本并发控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL 事务隔离级别是怎么实现的?图解 Read View 与 MVCC 多版本并发控制
  • 文档
  • 教程
  • 知识库

【免费下载链接】CS-Base

图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com

项目地址:https://gitcode.com/GitHub_Trending/cs/CS-Base
点击查看免费下载

本篇是 CS-Base 开源仓库《图解 MySQL》事务篇的核心文章(原文位于 mysql/transaction/mvcc.md),围绕"事务隔离级别是怎么实现的"这一经典面试主题,系统讲解事务四大特性、脏读/不可重复读/幻读三种并发问题、四种隔离级别,以及 InnoDB 引擎如何用 Read View + undo log 版本链实现 MVCC(多版本并发控制),并对比「读提交」与「可重复读」两种隔离级别在 Read View 创建时机上的本质区别。读完本篇,你将掌握隔离级别与 MVCC 的完整原理链路,能讲清楚"为什么可重复读下快照读看不到别人提交的新数据",也能结合仓库配套文章进一步理解幻读的边界场景与 next-key lock 的补充手段。

事务的四大特性与 InnoDB 的实现技术

先从一个场景说起:你的钱包里有 100 万元,现在要给对方转账 100 万元。转账这个动作在程序里会涉及一系列数据库操作,至少包括两步修改:扣减自己的余额、增加对方的余额。

假设执行完第一步"我的账户扣了 100 万"之后,服务器忽然掉电,就会发生一个严重问题:我的账户扣了 100 万,但钱并没有到对方账户上——这 100 万凭空消失了。

要解决这个问题,就必须保证转账业务里的所有数据库操作是不可分割的:要么全部执行成功,要么全部失败,不允许出现中间状态。数据库中的「事务(Transaction)」就能达到这样的效果。在转账操作前先开启事务,等所有数据库操作执行完成后才提交事务;对于已提交的事务,它所做的修改永久生效;如果中途发生中断或错误,则该事务期间所做的修改会被回滚到执行该事务之前的状态。

事务是由存储引擎来实现的,常见的InnoDB 引擎支持事务,但并非所有引擎都支持,例如 MySQL 原生的 MyISAM 引擎就不支持事务——这也是大多数生产环境选择 InnoDB 的重要原因。要实现事务,必须遵守 4 个特性:

  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节;事务执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。好比买一件商品:购买成功,则付钱且商品到手;购买失败,则商品还在商家手中,消费者的钱也没花出去。
  • 一致性(Consistency):事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。例如用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元;A 给 B 转账 200 元分为两个步骤:从 A 账户扣 200 元、给 B 账户加 200 元。一致性要求操作后结果是 A 剩 600 元、B 有 800 元、总额仍为 1400 元,而不会出现"A 扣了 200 元但 B 没增加"(那将变成 A 和 B 各 600 元,总额 1200 元)的中间状态。
  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改;隔离性可以防止多个事务并发执行时因交叉执行而导致数据不一致,每个事务都有完整的数据空间,对其他并发事务是隔离的。消费者购买商品这个事务,不应影响其他消费者购买。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

InnoDB 引擎分别通过不同的技术来保证这四个特性(详见 undo log、redo log、binlog 有什么用?):

事务特性保证技术
持久性redo log(重做日志)
原子性undo log(回滚日志)
隔离性MVCC(多版本并发控制)或锁机制
一致性持久性 + 原子性 + 隔离性 共同保证

本篇重点介绍事务的隔离性,这也是面试中最常考的知识点。

并行事务会引发什么问题?

MySQL 服务端允许多个客户端连接,这意味着 MySQL 会出现同时处理多个事务的情况。在同时处理多个事务时,就可能出现**脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)**的问题。

脏读(Dirty Read)

如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。

假设有 A 和 B 两个事务同时在处理:事务 A 先开始读取某条余额数据,然后执行更新操作;此时事务 A 还没有提交事务,而事务 B 正好也从数据库中读取同一条余额数据,那么事务 B 读到的余额就是事务 A 更新后的数据,即使事务 A 尚未提交。

因为事务 A 还没提交,它随时可能发生回滚。如果此时事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。

不可重复读(Non-Repeatable Read)

在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。

假设事务 A 先读取余额数据,然后继续执行代码逻辑;在这过程中事务 B 更新了这条数据并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据不一致,这种现象就是不可重复读。

幻读(Phantom Read)

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

假设事务 A 查询"账户余额大于 100 万"的记录,发现共有 5 条;随后事务 B 也按相同条件查询出 5 条。接着事务 A 插入了一条余额超过 100 万的账号并提交,此时符合条件的记录数变为 6。当事务 B 再次查询时发现记录数量变成了 6 条——前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就是幻读。

事务的隔离级别有哪些?

多个事务并发执行时可能遇到的三种现象,会对事务一致性产生不同程度的影响:

  • 脏读:读到其他事务未提交的数据;
  • 不可重复读:前后读取的数据不一致;
  • 幻读:前后读取的记录数量不一致。

SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低:

  • 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读提交(read committed):一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读(repeatable read):一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。这是 MySQL InnoDB 引擎的默认隔离级别;
  • 串行化(serializable):会对记录加上读写锁,多个事务对这条记录进行读写操作时,如果发生读写冲突,后访问的事务必须等前一个事务执行完成才能继续执行。

针对不同的隔离级别,并发事务时可能发生的现象也不同:

隔离级别脏读不可重复读幻读
读未提交可能可能可能
读提交不可能可能可能
可重复读不可能不可能可能
串行化不可能不可能不可能

因此:要解决脏读现象,就要升级到「读提交」以上的隔离级别;要解决不可重复读现象,就要升级到「可重复读」的隔离级别;而要解决幻读现象,不建议将隔离级别升级到「串行化」,因为串行化会严重影响并发性能。

MySQL 与 SQL 标准的出入

不同的数据库厂商对 SQL 标准规定的 4 种隔离级别的支持不尽相同。MySQL 虽然支持全部 4 种隔离级别,但与 SQL 标准中规定的各级隔离级别允许发生的现象存在出入:MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生(注意是"很大程度避免",并不是彻底避免,详见配套文章 MySQL 可重复读隔离级别,完全解决幻读了吗?),因此 MySQL 并不会使用「串行化」隔离级别来避免幻读,因为那会严重影响性能。

MySQL InnoDB 在「可重复读」下解决幻读,依靠的是两种方案:

  • 针对快照读(普通 select 语句):通过 MVCC 方式解决了幻读。因为可重复读隔离级别下,事务执行过程中看到的数据一直跟事务启动时看到的数据一致,即使中途有其他事务插入了一条数据,也查询不出来,从而避免了幻读。
  • 针对当前读(select ... for update等语句):通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读。执行select ... for update时会加 next-key lock,如果有其他事务在锁范围内插入记录,插入语句会被阻塞、无法成功插入,从而避免幻读。关于锁的具体加锁范围分析可参考 MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗?。

四种隔离级别的直观演示

有一张账户余额表,里面有一条余额为 100 万的记录。事务 A 只负责查询余额,事务 B 则会将余额改成 200 万,按时间顺序执行。在不同隔离级别下,事务 A 执行过程中查询到的余额 V1、V2、V3 可能不同:

  • 读未提交:事务 B 修改余额后虽未提交,但余额已经可以被事务 A 看见,于是 V1、V2、V3 查询到的都是 200 万;
  • 读提交:事务 B 修改后未提交,所以事务 A 的 V1 还是 100 万;事务 B 提交后,V2、V3 都是 200 万;
  • 可重复读:事务 A 只能看见启动事务时的数据,所以 V1、V2 都是 100 万;事务 A 提交事务后(或事务 A 之后的新语句),V3 才能看到 200 万;
  • 串行化:事务 B 修改余额时,因为此前事务 A 执行了读操作,发生读写冲突,事务 B 会被锁住,直到事务 A 提交后才继续执行,所以事务 A 看到的 V1、V2 是 100 万,V3 是 200 万。

四种隔离级别的实现机制

  • 「读未提交」:因为可以读到未提交事务修改的数据,所以直接读取最新的数据即可;
  • 「串行化」:通过加读写锁的方式避免并行访问;
  • 「读提交」和「可重复读」:通过Read View实现,它们的区别在于创建 Read View 的时机不同。可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景——「读提交」隔离级别是在每个语句执行前都会重新生成一个 Read View,而「可重复读」隔离级别是启动事务时生成一个 Read View,整个事务期间都在用这个 Read View。

事务的启动时机

注意,执行「开始事务」命令并不意味着事务已经启动。MySQL 有两种开启事务的命令,事务的启动时机不同:

  • 第一种:begin/start transaction命令;
  • 第二种:start transaction with consistent snapshot命令。

区别在于:

  • 执行begin/start transaction命令后,并不代表事务启动了;只有在执行该命令后,再执行了增删查改操作的 SQL 语句,事务才真正启动;
  • 执行start transaction with consistent snapshot命令,会马上启动事务。

Read View 在 MVCC 里如何工作?

要理解 Read View,需要先掌握两个知识点:Read View 中四个字段的作用,以及聚簇索引记录中两个跟事务有关的隐藏列。

Read View 的四个字段

Read View 有四个重要的字段:

  • m_ids:创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表。注意是一个列表,"活跃事务"指启动了但还没提交的事务;
  • min_trx_id:创建 Read View 时,活跃事务中事务 id 最小的事务,也就是 m_ids 的最小值;
  • max_trx_id:注意这不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,即全局事务中最大的事务 id 值 + 1;
  • creator_trx_id:创建该 Read View 的事务的事务 id。

聚簇索引记录中的两个隐藏列

对于使用 InnoDB 存储引擎的数据库表,其聚簇索引记录中都包含两个隐藏列(数据以数据页为单位存储、页默认大小为 16KB 的相关背景可参考 从数据页的角度看 B+ 树):

  • trx_id:当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,这个隐藏列是个指针,指向每一个旧版本记录,于是可以通过它找到修改前的记录。

多个旧版本记录通过 roll_pointer 串成链表,就形成了版本链(undo log 版本链)。关于 undo log 如何为每次更新记录旧值并构成版本链,可进一步阅读 undo log、redo log、binlog 有什么用?。

记录的可见性判断规则

创建 Read View 后,可以将记录中的 trx_id 划分成三种情况来判断可见性(除了自己的更新记录总是可见之外):

  1. 记录的 trx_id 小于 Read View 中的min_trx_id:表示该版本记录是在创建 Read View前已经提交的事务生成的,对当前事务可见;
  2. 记录的 trx_id 大于等于 Read View 中的max_trx_id:表示该版本记录是在创建 Read View后才启动的事务生成的,对当前事务不可见;
  3. 记录的 trx_id 在min_trx_id和max_trx_id之间:需要进一步判断 trx_id 是否在 m_ids 列表中:
    • trx_id在m_ids 列表中:生成该版本记录的活跃事务依然活跃(还没提交),该版本对当前事务不可见;
    • trx_id不在m_ids 列表中:生成该版本记录的活跃事务已提交,该版本对当前事务可见。

这种通过「版本链」来控制并发事务访问同一个记录时的行为,就叫 MVCC(多版本并发控制)。

可重复读是如何工作的?

可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。

假设事务 A(事务 id 为 51)启动后,紧接着事务 B(事务 id 为 52)也启动了,两个事务创建的 Read View 内容如下:

  • 事务 A 的 Read View:它的事务 id 是 51,作为第一个启动的事务,此时活跃事务列表只有 51;活跃事务中最小事务 id 是事务 A 本身(51),下一个事务 id 是 52;
  • 事务 B 的 Read View:它的事务 id 是 52,由于事务 A 仍是活跃的,此时活跃事务列表是 51 和 52;最小活跃事务 id 是事务 A(51),下一个事务 id 是 53。

接着,在可重复读隔离级别下,事务 A 和事务 B 按顺序执行以下操作:

  1. 事务 B 读取小林的账户余额记录,读到余额是 100 万;
  2. 事务 A 将小林的账户余额修改成 200 万,但没有提交事务;
  3. 事务 B 再次读取该记录,读到余额还是 100 万;
  4. 事务 A 提交事务;
  5. 事务 B 第三次读取该记录,读到余额依然还是 100 万。

逐步分析原因:

  • 第一次读取:事务 B 找到记录后先看这条记录的 trx_id,发现 trx_id 为 50,比事务 B 的 Read View 中 min_trx_id(51)还小,说明修改这条记录的事务早在事务 B 启动前就已提交,该版本对事务 B可见,于是读到 100 万。
  • 事务 A 更新后:事务 A 通过 update 语句将余额改成 200 万(未提交),MySQL 会记录相应的 undo log,并以链表方式串联形成版本链:最新记录的 trx_id 是事务 A 的事务 id(trx_id = 51),旧版本记录通过 roll_pointer 指向上一个版本(trx_id = 50)。
  • 第二次读取:事务 B 发现这条记录的 trx_id 值为 51,位于自己 Read View 的 min_trx_id(51)和 max_trx_id(53)之间,需要判断 trx_id 是否在 m_ids(51、52)范围内——结果在范围内,说明该记录是被还未提交的事务修改的,事务 B 并不会读取这个版本,而是沿着 undo log 版本链往下找旧版本记录,直到找到 trx_id 可见的第一条记录(trx_id 小于 min_trx_id,或者位于 min_trx_id 与 max_trx_id 之间但不在 m_ids 范围内)。于是事务 B 读到的是 trx_id 为 50 的记录,也就是余额 100 万。
  • 第三次读取:即使事务 A 已提交,由于隔离级别是可重复读,事务 B 依然基于启动事务时创建的 Read View来判断版本可见性,所以读到的仍是余额 100 万的记录。

正是通过这样的方式,实现了「可重复读」隔离级别下事务期间读到的记录始终是事务启动前的记录。

读提交是如何工作的?

读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。这意味着事务期间多次读取同一条数据,前后两次读的数据可能出现不一致,因为可能这期间另一个事务修改了该记录并提交了事务。

还是用前面的例子。事务 A(事务 id 51)启动后,紧接着事务 B(事务 id 52)也启动,按顺序执行:

  1. 事务 B 读取数据(创建 Read View),余额为 100 万;
  2. 事务 A 修改数据(还没提交),将余额从 100 万改成 200 万;
  3. 事务 B 读取数据(创建 Read View),余额为 100 万;
  4. 事务 A 提交事务;
  5. 事务 B 读取数据(创建 Read View),余额为 200 万。

分析两次关键读取:

  • 第二次读取(事务 A 未提交时):事务 B 看到记录的 trx_id 是 51,位于自己 Read View 的 min_trx_id 和 max_trx_id 之间,且 trx_id 在 m_ids 范围内,说明该记录是被还未提交的事务修改的,事务 B 不读取这个版本,而是沿 undo log 版本链找到 trx_id 可见的第一条记录(trx_id = 50,余额 100 万)。
  • 第三次读取(事务 A 提交后):由于隔离级别是读提交,事务 B 每次读数据时都重新创建 Read View。此时事务 A 已经提交,不再活跃,新 Read View 中 m_ids 不再包含 51,min_trx_id 变为 52。事务 B 发现记录的 trx_id 是 51,比新 Read View 的 min_trx_id(52)还小,说明修改这条记录的事务早就在创建 Read View 前提交过了,该版本对事务 B 可见,于是读到 200 万。

正因为读提交隔离级别下每次读数据都重新创建 Read View,事务期间多次读取同一条数据才可能出现前后不一致——这就是不可重复读现象的根源。

快照读与当前读

在可重复读隔离级别中:

  • 快照读:普通的select语句就是基于 MVCC 实现的快照读,不会加锁,通过 Read View + undo log 版本链读取符合可见性的历史版本;
  • 当前读:select ... for update、update、insert、delete等语句不是快照读,而是当前读,每次读都拿到最新版本的数据,并且会对读到的记录加上 next-key lock(记录锁 + 间隙锁)锁。

关于当前读加锁后,不同索引(主键索引 vs 二级索引)下 next-key lock 的具体加锁范围,以及如何用performance_schema.data_locks查看事务加了什么锁,可以进一步阅读 MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗?。

可重复读真的完全解决幻读了吗?

并没有。MySQL InnoDB 的可重复读隔离级别虽然很大程度上避免了幻读,但仍有边界场景会发生幻读(详见 MySQL 可重复读隔离级别,完全解决幻读了吗?),典型的有两种:

场景一(快照读 + 更新引发的幻读):事务 A 在可重复读下先执行select * from t_stu where id = 5,查不到记录并生成了 Read View;随后事务 B 插入一条 id = 5 的记录并提交。此时事务 A 对 id = 5 执行 update 操作——虽然它看不到这条记录,但更新会成功,并且这条新记录的 trx_id 隐藏列会被改写为事务 A 自己的事务 id;之后事务 A 再用普通 select 查询,就能看到这条记录了,于是前后两次查询结果不一样,发生幻读。这个例子说明MVCC 并不能完全避免幻读现象。

场景二(先快照读、后当前读引发的幻读):事务 A 先执行快照读语句select * from t_test where id > 100得到 3 条记录;事务 B 插入一条 id = 200 的记录并提交;事务 A 再执行当前读语句select * from t_test where id > 100 for update,就会得到 4 条记录,此时也发生了幻读。要避免这类特殊场景下的幻读,就是尽量在开启事务之后马上执行select ... for update这类当前读语句,因为它会对记录加 next-key lock,从而阻止其他事务插入新记录。

总结

  • 事务是在 MySQL 引擎层实现的,InnoDB 引擎支持事务,事务四大特性是原子性、一致性、隔离性、持久性,本篇重点讲隔离性。
  • 多个事务并发执行会引发脏读、不可重复读、幻读三类问题。SQL 标准提出四种隔离级别:读未提交、读已提交、可重复读、串行化,从左往右隔离级别递增,隔离级别越高性能越差;InnoDB 引擎的默认隔离级别是可重复读。
  • 解决脏读需要升级到「读已提交」以上;解决不可重复读需要升级到「可重复读」以上;解决幻读不建议直接升级到「串行化」。
  • 对于「读提交」和「可重复读」隔离级别的事务,都是通过 Read View 实现的,区别在于创建 Read View 的时机:「读提交」是每个 select 都会生成一个新的 Read View(所以事务期间多次读取可能不一致);「可重复读」是启动事务时生成一个 Read View,整个事务期间复用(保证事务期间读到的都是事务启动前的数据)。
  • 这两个隔离级别通过「事务 Read View 里的字段」与「记录中的两个隐藏列(trx_id、roll_pointer)」比对,控制并发事务访问同一记录时的行为,顺着 undo log 版本链找到满足可见性的记录,这就叫MVCC(多版本并发控制)。
  • 在可重复读隔离级别下,普通 select 是 MVCC 快照读(不加锁);select ... for update是当前读(加 next-key lock),两者分别从"版本可见性"和"锁阻塞插入"两个角度规避幻读,但都存在特殊边界场景,因此 MySQL 可重复读只是很大程度避免幻读,并未彻底解决。

建议按此顺序继续深挖仓库中的配套文章:undo log、redo log、binlog 有什么用?(理解版本链的日志基础)、MySQL 可重复读隔离级别,完全解决幻读了吗?(幻读边界场景)、MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗?(next-key lock 加锁范围实战)、MySQL 有哪些锁?(锁体系全貌)。

  • 文档
  • 教程
  • 知识库

【免费下载链接】CS-Base

图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com

项目地址:https://gitcode.com/GitHub_Trending/cs/CS-Base
点击查看免费下载
上一篇:局域网文件传输的终极解决方案:为什么LAN Share能让你彻底告别U盘和网盘?
下一篇:PPT Master:把文档和主题变成原生可编辑的 PPTX

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 2:12:10

Unity游戏开发:血量、数组与坐标的内存管理实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 2:11:25

NIS与LDAP怎么选?内网几十台Linux主机统一账号的轻量级实践

前阵子被一个朋友拉去收拾他们实验室的测试集群。二十多台CentOS 7的机器,每台/etc/passwd里都躺着好几个重复创建的账号,密码改一次要跑遍所有机器,离职同事的账号更是没人敢动。我第一反应是上LDAP,但坐下来理了理需求&#xff…

作者头像 李华
网站建设 2026/10/1 2:09:57

Edge主页被劫持?四层控制机制深度解析与精准还原

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华