news 2026/9/16 3:18:14

MySQL事务隔离级别详解:从脏读、幻读到MVCC底层原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL事务隔离级别详解:从脏读、幻读到MVCC底层原理

1. 并发问题的根源:你真的搞懂事务了吗

脏读、不可重复读、幻读这哥仨,是数据库面试里雷打不动的“老三样”。说实话,我在带团队面试候选人时,十个人里有八个能说得出这三个名词,但你再追问一句“你们库默认隔离级别是什么?它到底有没有解决幻读?”,立马就哑火一半。今天我不打算背教科书,就从实际开发中遇到的场景出发,把这几个问题掰开揉碎了讲清楚,顺便把 MySQL InnoDB 底层的那些实现机制也一并说了。

要说清楚并发问题,得先明确一个前提:这三个问题全部发生在多个事务并行操作同一批数据的场景里。单事务跑数据,天塌下来也不会出这些乱子。所以先花点时间把事务的底层模型讲透,后面理解隔离级别会顺很多。

1.1 事务的原子性和持久性,是并发问题的“地基”

事务有 ACID 四个特性:原子性、一致性、隔离性、持久性。其中和本文最相关的就是隔离性,但要说清楚隔离性为什么难做,得先看另外两个——原子性和持久性。

原子性保证一个事务要么全做,要么全不做。实现原子性的核心机制是undo log(回滚日志)。事务执行过程中,每修改一条记录,InnoDB 就会生成一条对应的反向操作记录:UPDATE 就记一条反向 UPDATE,DELETE 就记一条 INSERT。事务一旦回滚,就直接执行这些反向日志把数据还原回去。

持久性靠的是redo log(重做日志)。InnoDB 采用的是 WAL(Write-Ahead Logging)机制,事务提交时数据页不一定要立刻刷盘,但 redo log 必须先写盘。这样即使数据库突然宕机,重启后也能从 redo log 把已经提交但还没落盘的数据恢复出来。

有意思的事情来了。undo log 记录的是“历史版本”,redo log 记录的是“最新操作”。这两个日志接下来会和隔离级别的实现产生直接关系——MVCC(多版本并发控制)就是建立在 undo log 版本链之上的

1.2 锁和版本链:解决并发的两条路线

数据库解决并发冲突,思路无非两条:悲观锁乐观锁,对应到底层就是锁机制MVCC

锁机制好理解,就是“我干活的时候,你就在门口等着”。读锁(共享锁)和写锁(排他锁)互相排斥,写写之间、读写之间都不能并行,数据安全,但是并发性能直线下降。

MVCC 的思路完全不同:读的人去读历史版本,写的人写最新版本,两边各干各的互不干扰。每个事务在启动时会生成一个 Read View(读视图),里面记录了当前所有活跃事务的 ID 列表。当读取某条记录时,会根据 undo log 版本链上每个版本的事务 ID 与 Read View 的比较结果,决定这个版本对当前事务是否“可见”。

脏读、不可重复读、幻读这三个问题的产生,本质就是在并发场景下,“读操作该看到哪个版本的数据”这个规则没有被定义清楚。而隔离级别,就是在定义这个规则。

2. 三大并发问题逐个击破

下面进入正题。我不会只给定义,会用同一个业务场景把三个问题串起来演示,这样对比效果最直观。

假设有一张用户余额表,结构很简单:

CREATE TABLE `account` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int DEFAULT NULL, `balance` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB;

初始数据就一条:user_id = 1,balance = 1000.00。

2.1 脏读:读到别人没提交的数据

脏读的定义是:事务 A 读到了事务 B 未提交的数据。注意“未提交”这三个字是重点。

演示过程如下:

时间线事务 A事务 B
T1启动事务,读 balance,得到 1000
T2启动事务,UPDATE balance SET balance = 500
T3再次读 balance,得到 500(脏数据!)
T4ROLLBACK,余额恢复为 1000

在脏读的场景下,事务 B 把余额从 1000 改成了 500,但这一步操作还没有提交。此时事务 A 去读,如果读到的是 500,就产生了脏读。因为 B 随时可能回滚,一旦回滚,这 500 就是一条根本不存在的数据,A 等于拿着一个幻影数据在做业务判断。

举一个现实的例子:A 事务在跑报表,统计用户资产总额。B 事务正在给一批用户扣款,扣到一半还没提交。如果 A 能读到 B 未提交的修改,报表里的总额就乱了,而且这个乱是“凭空出现又凭空消失”的乱,排查起来极其痛苦。

2.2 不可重复读:同一条记录,两次读取结果不一样

不可重复读的定义是:事务 A 内两次读取同一条记录,第二次读到的值和第一次不一致。和脏读的区别在于——这次读到的是其他事务已提交的数据。

还是用余额表的例子:

时间线事务 A事务 B
T1启动事务,读 balance = 1000
T2启动事务,UPDATE balance SET balance = 800
T3COMMIT
T4再次读 balance,得到 800(和 T1 的值不一致)

事务 A 在 T1 时刻读到余额是 1000,事务 B 在 T2 提交了修改,A 在 T4 再读就变成了 800。同一个事务里,同一条记录,两次读取结果不同,这就叫不可重复读。

为什么它会带来麻烦?想想转账的场景。事务 A 要做两笔扣款操作,先检查余额是否足够,够则执行扣款。如果两次读取的余额不一致,第一笔扣款按照 1000 的余额做了资格校验,第二笔却发现余额只剩 800,业务逻辑就出现了自相矛盾。更严重的是,这种不一致会导致账务对不上,对账系统跑出来永远是 0.01 的差异,但你就是找不出差异在哪。

2.3 幻读:查询结果集的行数变了

幻读的定义是:事务 A 两次执行同一条查询语句,第二次返回的结果集行数和第一次不一样。注意,幻读强调的是行数的变化,而不可重复读强调的是同一条记录的值变化

演示场景换一下,事务 A 要查询所有 balance > 500 的用户:

时间线事务 A事务 B
T1启动事务,SELECT COUNT(*) FROM account WHERE balance > 500,得到 1
T2启动事务,INSERT INTO account (user_id, balance) VALUES (2, 600)
T3COMMIT
T4再次执行同一条 SELECT,得到 2(多了一条!)

事务 A 第一次查到的是 1 条记录,事务 B 插入了一条新的符合条件的记录并提交,A 第二次查询就变成了 2 条。多出来的那条记录就像幽灵一样凭空出现,所以叫“幻读”。

这里有一个非常容易混淆的点:如果事务 B 插入的是一条 balance = 300 的记录,不满足 WHERE 条件,那么 A 的两次查询结果集行数不变,这就不属于幻读。幻读针对的是结果集行数的变化,而不是范围内的所有数据变化。

3. SQL 标准里的四种隔离级别

SQL 标准定义了四种隔离级别,层层递进,越往后隔离越严格,但并发性能也在逐级牺牲。

3.1 四种隔离级别的定义与对比

隔离级别脏读不可重复读幻读
READ UNCOMMITTED(读未提交)可能可能可能
READ COMMITTED(读已提交)不可能可能可能
REPEATABLE READ(可重复读)不可能不可能可能(标准定义下)
SERIALIZABLE(串行化)不可能不可能不可能

四种隔离级别其实是按照“允许什么并发操作”来划分的:

  • READ UNCOMMITTED:啥都不管,读操作不加锁,写操作加锁。性能最好,但脏读、不可重复读、幻读一个不落。
  • READ COMMITTED:用 MVCC 保证每次读只能看到已提交的数据,脏读没了,但两次独立的读之间如果有其他事务提交了修改,依然会读到新值。
  • REPEATABLE READ:用 MVCC + Read View 复用机制,事务启动时创建一次 Read View,后续所有快照读都复用这一个视图,保证同一条记录在事务内多次读取结果一致。
  • SERIALIZABLE:所有操作强制加锁,读写互斥,相当于所有事务排队执行。隔离性最强,并发能力基本为零。

3.2 为什么说 REPEATABLE READ 在标准定义下不解决幻读

这里要特别关注 REPEATABLE READ 这一行——标准定义下它是可能产生幻读的。原因在于标准只规定了“读操作要看到一致的快照”,但没有规定写操作的行为

幻读的产生场景通常是:事务 A 做了一次快照读,得到一个结果集;事务 B 插入了一条新数据并提交;事务 A 再次做快照读,理论上 MVCC 会复用旧的 Read View,新数据是不可见的,所以结果集行数应该还是和第一次一样。

但问题出在“当前读”上。如果事务 A 执行的是SELECT ... FOR UPDATEUPDATEDELETE这类当前读操作,它读的不是快照,而是数据库的最新已提交数据。此时事务 B 刚插入的数据就会出现在当前读的结果集里,幻读就发生了。

简单说:快照读下不幻读,当前读下会幻读。SQL 标准里对隔离级别的定义比较粗,没有区分快照读和当前读,所以它给的结论是“REPEATABLE READ 可能产生幻读”。

3.3 各数据库的默认隔离级别

不同数据库的默认隔离级别有很大差异,这是一个必备的常识:

数据库默认隔离级别
MySQL(InnoDB)REPEATABLE READ
PostgreSQLREAD COMMITTED
OracleREAD COMMITTED
SQL ServerREAD COMMITTED(默认),可配置

MySQL 把可重复读当作默认级别,很多人以为是因为它比读已提交更安全,其实只猜对了一半。另一个理由是历史原因:MySQL 的主从复制在早期基于 binlog 的 STATEMENT 格式时,只有在 RR 级别下才能保证主从数据一致。虽然现在 ROW 格式的 binlog 在 RC 级别下也能正确复制了,但默认值一直没变。

4. MySQL InnoDB 底层是怎么实现隔离级别的

前面讲的是标准层面的理论,这一节进入实操层面。因为标准是一套,各家数据库的实现是另一套。你用 MySQL,就必须理解 InnoDB 的具体行为。

4.1 MVCC 与 undo log 版本链

InnoDB 的每一行记录上隐藏着几个关键字段:DB_TRX_ID(最近一次修改该记录的事务 ID)、DB_ROLL_PTR(指向 undo log 中该记录上一个版本的指针)、DB_ROW_ID(行 ID)。

当一条记录被多次修改时,undo log 会形成一个版本链:

最新版本 <- 版本2 <- 版本1 <- 初始版本

每个版本都记录了产生它的事务 ID。MVCC 读数据时,做的事情就是沿着版本链往回找,找到第一个对当前事务可见的版本

那“可见”怎么判断?靠 Read View。Read View 里最关键的有两个字段:

  • m_low_limit_id:当前系统中最大的事务 ID + 1。事务 ID 大于等于这个值的版本,说明是当前事务启动之后才开始的,不可见。
  • m_up_limit_id:当前活跃事务中的最小事务 ID。事务 ID 小于这个值的版本,说明事务已经提交了,可见。

判断规则大概是这样:版本的事务 ID 在 [up_limit_id, low_limit_id) 区间内,且不在活跃事务列表里,说明该事务已提交,这个版本可见;否则继续往前找上一个版本。

4.2 READ COMMITTED 和 REPEATABLE READ 的核心差异

这两种隔离级别的代码实现差别其实就一行:每次读的时候要不要新建 Read View

  • READ COMMITTED:每条快照读语句执行时都会创建一个新的 Read View。
  • REPEATABLE READ:事务启动时创建一次 Read View,整个事务内后续所有快照读都复用这一个。

就这么一个差别,造成了行为上的巨大不同。RC 级别下,事务 A 两次 SELECT 之间,事务 B 提交了新修改,A 第二次读就会走新的 Read View,发现 B 的版本已经可见,于是读到了新值——不可重复读。

RR 级别下,A 的 Read View 是第一次读时创建的,后面无论 B 提交了什么,哪怕 B 的事务 ID 比 A 的 Read View 里的判断阈值小(已提交),A 也看不见,因为版本链上找到 B 的版本后,判断它的事务 ID 是否在自己事务启动的快照之后——由于 A 复用旧 Read View,B 的修改版本对 A 来说永远是“未来事务”,不可见。于是同一条记录在 A 内反复读,结果始终一致。

这里注意一个细节:RR 下的 Read View 是“第一次读”时创建的,不是“事务开始”时创建的。如果一个 RR 事务先执行了一个写操作(当前读),再执行快照读,Read View 的创建时机是以第一次读为准。这个细节在排查问题时会非常关键。

4.3 InnoDB 如何解决幻读:Next-Key Lock

前面说了,标准定义下 RR 不解决幻读,但 MySQL 的 RR 级别实际上解决了绝大部分幻读场景。靠的就是Next-Key Lock(临键锁),它就是记录锁和间隙锁的组合。

  • Record Lock:锁定单条索引记录。
  • Gap Lock:间隙锁,锁定一个范围,但不锁定范围内的具体记录。它不和记录本身冲突,只和“往这个间隙插入新记录”的操作冲突。
  • Next-Key Lock:左开右闭区间,比如(1, 10],既锁住了记录 10,又锁住了 1 到 10 之间的间隙。

回到幻读的演示场景。事务 A 执行SELECT * FROM account WHERE balance > 500 FOR UPDATE,InnoDB 在扫描过程中,会对扫过的索引范围加 Next-Key Lock。事务 B 想往这个范围里插入一条 balance = 600 的新记录,插入时需要先检查目标位置是否有间隙锁——有,于是被阻塞,直到 A 提交或回滚。

注意,这里有两个限制条件:

  1. 必须通过索引条件加锁。如果 WHERE 条件没有索引,InnoDB 会对整个表加锁,性能和并发量直接崩。
  2. 快照读不需要加锁也能防幻读。因为 MVCC 已经保证快照的一致性,只有当前读才需要 Next-Key Lock。

还有一点:如果对balance列没有建立索引,事务 A 的SELECT ... FOR UPDATE会触发全表扫描,把全表所有记录和间隙全部加锁,这基本等同于串行化。

5. 现在能动手了:一套完整的验证方案

理论讲了一大堆,不如亲手验证一遍。下面把每个问题在 MySQL 8.0 里的复现步骤写出来,你照着操作一遍,理解立刻不一样。

5.1 环境准备和初始化脚本

建议直接使用 Docker 起一个 MySQL 8.0 容器,干净利落不污染宿主机器:

docker run --name mysql-test \ -e MYSQL_ROOT_PASSWORD=123456 \ -p 3306:3306 \ -d mysql:8.0

然后创建测试表:

CREATE DATABASE IF NOT EXISTS isolation_test; USE isolation_test; CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, balance DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB; INSERT INTO account (user_id, balance) VALUES (1, 1000.00);

5.2 复现脏读

两个终端分别打开 mysql 客户端,都执行以下命令手动管理事务:

-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT * FROM account WHERE user_id = 1;
-- 终端 B(事务 B) START TRANSACTION; UPDATE account SET balance = 500.00 WHERE user_id = 1; -- 不要执行 COMMIT

此时回到终端 A 再执行一次查询:

SELECT * FROM account WHERE user_id = 1;

如果看到 balance = 500.00,脏读复现成功。这时候数据其实还没提交。回到终端 B 执行ROLLBACK,再回到 A 查询,余额又变回 1000.00——凭空多出来又消失掉的 500 就是脏数据。

5.3 复现不可重复读

把两个终端都改成 READ COMMITTED 级别:

-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM account WHERE user_id = 1;
-- 终端 B(事务 B) START TRANSACTION; UPDATE account SET balance = 800.00 WHERE user_id = 1; COMMIT;

回到终端 A 再次查询,会看到 balance 变成了 800.00。两次查询结果不一致——不可重复读复现成功。

5.4 复现幻读

MySQL 默认的 RR 级别下,想复现幻读要绕个弯,因为快照读已经被 MVCC 管住了。最标准的复现姿势是用当前读:

-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM account WHERE balance > 500 FOR UPDATE; -- 此时只查到了 user_id = 1 这一条
-- 终端 B(事务 B) START TRANSACTION; INSERT INTO account (user_id, balance) VALUES (2, 600.00); COMMIT;

如果 B 的 INSERT 被阻塞了,说明 Next-Key Lock 已经生效,幻读被挡在了门外。但如果 B 顺利插入并提交成功,回到 A 再执行同一条SELECT ... FOR UPDATE,结果集里就会多出 user_id = 2 这条记录——幻读发生。

在这个测试里,事务 A 的WHERE balance > 500如果没有走索引,InnoDB 会对全表加锁,B 的插入必然被阻塞。为了演示幻读发生的场景,可以让balance条件的筛选没命中索引,或者调整隔离级别到 READ COMMITTED。

5.5 实操过程中我踩过的坑

第一个坑:忘记START TRANSACTION。MySQL 默认 autocommit = 1,每条 SQL 自动提交。如果不手动开启事务,你的验证语句等于一条语句一个事务,隔离级别再高也看不到效果。

第二个坑:隔离级别的设置位置。用SET SESSION TRANSACTION ISOLATION LEVEL只对当前会话生效,如果开了新终端忘了设置,级别就回到了默认值。我习惯在每条验证语句前面都加上一句确认:

SELECT @@transaction_isolation;

第三个坑:客户端工具干扰。用 Navicat 这类图形工具做并发测试很容易出问题,因为它可能自己包了一层连接池,你把终端 A 的查询和终端 B 的查询放在同一个连接里执行,结果完全不是你以为的那样。建议老老实实开两个终端,用命令行客户端操作。

6. 事务隔离级别的选择策略与实战建议

理论讲完,验证也做完了,最后落到实际问题上:我到底该把数据库配成哪个隔离级别?

6.1 不同业务场景下的推荐配置

默认就用 MySQL 的 REPEATABLE READ,不用改。这是绝大多数 Web 项目的正确姿势。原因如下:

  • RR 级别下快照读完全一致,对开发者最友好,不容易写出逻辑错误的代码。
  • InnoDB 的 RR 已经通过 Next-Key Lock 解决了幻读,你不太会遇到标准定义下 RR 的经典坑。
  • 主从复制的 binlog 兼容性最好,历史包袱少。

有强一致需求的账务系统,建议直接上 SERIALIZABLE,但要做降级预案。不要一听到串行化就摇头。如果你的业务量不大,比如内部管理系统、后台运营系统,SERIALIZABLE 带来的排队开销完全可以接受,但它换来的“零并发问题”能让对账逻辑简单一个数量级。使用的时候可以配合超时设置,避免锁等待无限蔓延。

追求高并发的互联网业务,可以考虑 READ COMMITTED。如果你们的数据库团队有足够的 DBA 支撑,业务代码对“一事务内多次读看同一份快照”没有硬性要求,RC 级别能减少间隙锁的加锁范围,提升并发吞吐量。但前提是你能接受那个事务里读两次可能结果不一样。

6.2 排查并发问题的三个独家技巧

最后分享三个实战排查经验。

第一个技巧:先确认隔离级别,再看锁等待。遇到莫名其妙的并发相关问题,第一步永远不要猜业务代码,先查隔离级别和当前的事务状态:

SELECT @@transaction_isolation; SELECT * FROM information_schema.innodb_trx\G SELECT * FROM performance_schema.data_lock_waits\G

第二个技巧:区分快照读和当前读。很多初学者排查半天发现“明明加了事务,怎么还是会读到新数据”,原因就是用了SELECT * FROM table WHERE ...(快照读)而不是SELECT * FROM table WHERE ... FOR UPDATE(当前读)。前者走 MVCC,后者走锁。如果你业务上需要“读取最新已提交数据”,必须用当前读,别指望 MVCC 给你新数据。

第三个技巧:用 SHOW ENGINE INNODB STATUS 看死锁日志。一条命令能查到最后一次死锁的详细信息,包含涉及的事务、持有和等待的锁、SQL 语句。生产环境遇到难缠的死锁问题,靠这个命令比靠代码 review 高效得多:

SHOW ENGINE INNODB STATUS\G

6.3 再聊两句心里话

数据库隔离级别这套东西,刚接触的时候容易觉得枯燥,名词又多又绕。但我自己的体会是,一旦亲手把脏读、不可重复读、幻读都复现了一遍,这四个隔离级别就不再是考点,而是实实在在的工具——你知道系统里哪个地方会发生哪种冲突,知道该用什么级别才能压住它,也知道了代价是什么。

面试的时候我最喜欢问的一个问题是:“你线上用的什么隔离级别?为什么?”能答上来 MySQL 默认 RR、InnoDB 的 RR 和标准 RR 的区别、以及 RC 的历史包袱这三个点的人,基本就是个靠谱的数据库开发者了。希望这篇写完之后,你也能胸有成竹地把这三个问题讲明白。

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

C++ Lambda捕获机制详解:从闭包模型到生命周期避坑指南

用了几年的C&#xff0c;我越来越觉得lambda表达式是现代C里最“好用但不好懂”的特性之一。说它好用&#xff0c;是因为你随手就能写出一个匿名函数对象&#xff0c;把逻辑塞到算法、回调、异步任务里去&#xff0c;代码比手写仿函数简洁不少&#xff1b;说它不好懂&#xff0…

作者头像 李华
网站建设 2026/9/16 3:16:48

VSCode+Keil开发51单片机:C语言环境配置与调试全攻略

简介&#xff1a;面向51单片机入门者与课程设计学生的一份精简资源包&#xff0c;主旨是演示如何用VSCode编写、编译并下载C程序到51单片机&#xff0c;解决新手在编辑器选型与开发环境配置上的常见障碍。包内共13个文件&#xff0c;以json、bat、c、hex、md、png等类型为主&am…

作者头像 李华
网站建设 2026/9/16 3:16:38

数据库游标详解:原理、实战与性能优化避坑指南

做数据库开发这些年&#xff0c;最常被新手问到的SQL概念&#xff0c;“游标”绝对排得上号。不管是面试题里让人发懵的名词解释&#xff0c;还是实际业务中需要逐行处理数据、做复杂业务计算&#xff0c;游标都会突然跳出来刷一波存在感。我第一次真正理解它&#xff0c;不是靠…

作者头像 李华
网站建设 2026/9/16 3:16:33

二叉树最近公共祖先(LCA)详解:三种解法与面试扩展

1. 题目到底在问什么&#xff1a;最近公共祖先的直觉与定义刷 LeetCode 的人迟早会遇到这道题&#xff1a;236. 二叉树的最近公共祖先。它在面试里出现的频率高到什么程度&#xff1f;我见过至少五家公司把原题搬进面试&#xff0c;有的是直接让你写&#xff0c;有的是换个皮考…

作者头像 李华
网站建设 2026/9/16 3:16:04

Go调度器公平性深度解析:从GMP模型到抢占与优先级

1. 调度公平性的源头&#xff1a;P本地队列、全局队列和工作窃取如果你写过一段长时间运行的 Go 服务&#xff0c;大概会遇到过这种诡异场景&#xff1a;某个 goroutine 明明在正常跑&#xff0c;其他 goroutine 却像被堵在早高峰地铁口一样&#xff0c;怎么挤都上不了车。表面…

作者头像 李华
网站建设 2026/9/16 3:15:31

智能家居APP怎么选?兼容性、响应速度与离线能力实测对比

1. 这不是选APP&#xff0c;是选未来三年的家居控制中枢“智能家居APP哪个好”——这句话背后藏着的&#xff0c;根本不是点开应用商店随便下个软件的事。它实际在问&#xff1a;我花三万装的全屋智能&#xff0c;会不会因为一个APP卡顿、掉线、不兼容&#xff0c;变成客厅里一…

作者头像 李华