尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/16 3:18:19

资讯中心
01
ARTICLE

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

MySQL事务隔离级别详解:从脏读、幻读到MVCC底层原理
1. 并发问题的根源你真的搞懂事务了吗脏读、不可重复读、幻读这哥仨是数据库面试里雷打不动的“老三样”。说实话我在带团队面试候选人时十个人里有八个能说得出这三个名词但你再追问一句“你们库默认隔离级别是什么它到底有没有解决幻读”立马就哑火一半。今天我不打算背教科书就从实际开发中遇到的场景出发把这几个问题掰开揉碎了讲清楚顺便把 MySQL InnoDB 底层的那些实现机制也一并说了。要说清楚并发问题得先明确一个前提这三个问题全部发生在多个事务并行操作同一批数据的场景里。单事务跑数据天塌下来也不会出这些乱子。所以先花点时间把事务的底层模型讲透后面理解隔离级别会顺很多。1.1 事务的原子性和持久性是并发问题的“地基”事务有 ACID 四个特性原子性、一致性、隔离性、持久性。其中和本文最相关的就是隔离性但要说清楚隔离性为什么难做得先看另外两个——原子性和持久性。原子性保证一个事务要么全做要么全不做。实现原子性的核心机制是undo log回滚日志。事务执行过程中每修改一条记录InnoDB 就会生成一条对应的反向操作记录UPDATE 就记一条反向 UPDATEDELETE 就记一条 INSERT。事务一旦回滚就直接执行这些反向日志把数据还原回去。持久性靠的是redo log重做日志。InnoDB 采用的是 WALWrite-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) ) ENGINEInnoDB;初始数据就一条user_id 1balance 1000.00。2.1 脏读读到别人没提交的数据脏读的定义是事务 A 读到了事务 B 未提交的数据。注意“未提交”这三个字是重点。演示过程如下时间线事务 A事务 BT1启动事务读 balance得到 1000T2启动事务UPDATE balance SET balance 500T3再次读 balance得到 500脏数据T4ROLLBACK余额恢复为 1000在脏读的场景下事务 B 把余额从 1000 改成了 500但这一步操作还没有提交。此时事务 A 去读如果读到的是 500就产生了脏读。因为 B 随时可能回滚一旦回滚这 500 就是一条根本不存在的数据A 等于拿着一个幻影数据在做业务判断。举一个现实的例子A 事务在跑报表统计用户资产总额。B 事务正在给一批用户扣款扣到一半还没提交。如果 A 能读到 B 未提交的修改报表里的总额就乱了而且这个乱是“凭空出现又凭空消失”的乱排查起来极其痛苦。2.2 不可重复读同一条记录两次读取结果不一样不可重复读的定义是事务 A 内两次读取同一条记录第二次读到的值和第一次不一致。和脏读的区别在于——这次读到的是其他事务已提交的数据。还是用余额表的例子时间线事务 A事务 BT1启动事务读 balance 1000T2启动事务UPDATE balance SET balance 800T3COMMITT4再次读 balance得到 800和 T1 的值不一致事务 A 在 T1 时刻读到余额是 1000事务 B 在 T2 提交了修改A 在 T4 再读就变成了 800。同一个事务里同一条记录两次读取结果不同这就叫不可重复读。为什么它会带来麻烦想想转账的场景。事务 A 要做两笔扣款操作先检查余额是否足够够则执行扣款。如果两次读取的余额不一致第一笔扣款按照 1000 的余额做了资格校验第二笔却发现余额只剩 800业务逻辑就出现了自相矛盾。更严重的是这种不一致会导致账务对不上对账系统跑出来永远是 0.01 的差异但你就是找不出差异在哪。2.3 幻读查询结果集的行数变了幻读的定义是事务 A 两次执行同一条查询语句第二次返回的结果集行数和第一次不一样。注意幻读强调的是行数的变化而不可重复读强调的是同一条记录的值变化。演示场景换一下事务 A 要查询所有 balance 500 的用户时间线事务 A事务 BT1启动事务SELECT COUNT(*) FROM account WHERE balance 500得到 1T2启动事务INSERT INTO account (user_id, balance) VALUES (2, 600)T3COMMITT4再次执行同一条 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 UPDATE、UPDATE、DELETE这类当前读操作它读的不是快照而是数据库的最新已提交数据。此时事务 B 刚插入的数据就会出现在当前读的结果集里幻读就发生了。简单说快照读下不幻读当前读下会幻读。SQL 标准里对隔离级别的定义比较粗没有区分快照读和当前读所以它给的结论是“REPEATABLE READ 可能产生幻读”。3.3 各数据库的默认隔离级别不同数据库的默认隔离级别有很大差异这是一个必备的常识数据库默认隔离级别MySQLInnoDBREPEATABLE READPostgreSQLREAD COMMITTEDOracleREAD COMMITTEDSQL 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 ViewB 的修改版本对 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 UPDATEInnoDB 在扫描过程中会对扫过的索引范围加 Next-Key Lock。事务 B 想往这个范围里插入一条 balance 600 的新记录插入时需要先检查目标位置是否有间隙锁——有于是被阻塞直到 A 提交或回滚。注意这里有两个限制条件必须通过索引条件加锁。如果 WHERE 条件没有索引InnoDB 会对整个表加锁性能和并发量直接崩。快照读不需要加锁也能防幻读。因为 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_PASSWORD123456 \ -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 ) ENGINEInnoDB; 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\G6.3 再聊两句心里话数据库隔离级别这套东西刚接触的时候容易觉得枯燥名词又多又绕。但我自己的体会是一旦亲手把脏读、不可重复读、幻读都复现了一遍这四个隔离级别就不再是考点而是实实在在的工具——你知道系统里哪个地方会发生哪种冲突知道该用什么级别才能压住它也知道了代价是什么。面试的时候我最喜欢问的一个问题是“你线上用的什么隔离级别为什么”能答上来 MySQL 默认 RR、InnoDB 的 RR 和标准 RR 的区别、以及 RC 的历史包袱这三个点的人基本就是个靠谱的数据库开发者了。希望这篇写完之后你也能胸有成竹地把这三个问题讲明白。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。