深入理解MySQL事务隔离级别与MVCC:脏读、幻读的底层原理与实战
1. 问题根源事务隔离级别与锁机制的内在矛盾先说结论脏读、不可重复读、幻读这些问题本质上是数据库在并发控制和性能之间做的取舍导致的。MySQL 不会无缘无故出现这些异常它们都是在多个事务同时操作同一批数据时由于隔离级别设置不同、锁的粒度不同而产生的。要理解这个问题先得搞清楚一个基础概念事务的 ACID 特性。其中IIsolation隔离性要求多个事务并发执行时彼此之间不能互相干扰。但“完全不干扰”在工程上是做不到的因为那意味着所有事务必须串行执行性能会低到没法用。所以 SQL 标准定义了四种事务隔离级别从松到严分别是隔离级别脏读不可重复读幻读READ UNCOMMITTED读未提交可能可能可能READ COMMITTED读已提交不会可能可能REPEATABLE READ可重复读不会不会可能InnoDB下不会SERIALIZABLE串行化不会不会不会1.1 脏读读到别人还没提交的数据脏读Dirty Read指的是一个事务读到了另一个事务尚未提交的数据。如果那个事务最终回滚了你读到的就是“不存在”的数据这就是“脏”的含义。举个例子事务A把某条记录的金额从100改成200但还没提交。此时事务B去读这条记录读到的是200。接着事务A因为某种原因回滚了金额变回100。事务B刚才读到的200就是一个脏数据基于这个数据做的任何业务判断都是错的。脏读只在 READ UNCOMMITTED 级别下才会发生。这个级别就是“啥也不管”读数据的时候不加任何锁也不检查别人有没有加锁直接读最新版本。实际业务中几乎不会用这个级别除非你做的是一些对数据一致性完全无感的统计类查询。1.2 不可重复读同一条记录两次读不一样不可重复读Non-Repeatable Read说的是在同一个事务内同一条记录被读取两次结果却不一样。原因是另一个事务在这期间提交了更新操作。场景是这样的事务A先读了一条记录值是100。事务B这时把这条记录改成了200并提交。事务A再次读同一条记录发现变成了200。对于事务A来说它在自己事务内部看到的数据“不可重复”这会导致业务逻辑混乱。注意不可重复读和脏读的区别在于不可重复读读到的数据是已经提交的只是两次读取的时机不同数据被别的事务改了。这通常发生在 READ COMMITTED 级别下因为这个级别每次读都生成一个快照但下一次读会重新生成快照。1.3 幻读记录数量变化了幻读Phantom Read是三种异常里最微妙的一个。它指的是同一个事务内用同一条查询条件执行两次查询返回的记录数量不一样。第二次查询多出来几行像“幻觉”一样。具体场景事务A执行SELECT * FROM orders WHERE amount 100返回了10条记录。事务B插入了一条 amount200 的新订单并提交。事务A再次执行同样的查询返回了11条记录。多出来的那条就是“幻影记录”。幻读和不可重复读的区别很关键不可重复读关注的是同一条记录的值变化幻读关注的是记录集合的变化重点在于有新的行插入进来。这个区分对后续理解锁机制非常重要。2. 深入底层MySQL InnoDB 是怎么避免这些问题的MySQL 默认的存储引擎是 InnoDB它的默认隔离级别是 REPEATABLE READ。在 InnoDB 的实现下这个级别不仅能避免脏读和不可重复读连理论上该级别可能出现的幻读也能解决掉。这里面的核心机制就是MVCC多版本并发控制和Next-Key Lock临键锁。2.1 MVCC快照读的版本链MVCC 的全称是 Multi-Version Concurrency Control中文叫多版本并发控制。它的核心思想是读操作不阻塞写操作写操作也不阻塞读操作通过保存数据的多个历史版本来实现并发控制。InnoDB 在每一行记录后面隐藏了两个字段实际上还有更多但这两个最关键DB_TRX_ID最近一次修改这行记录的事务ID。DB_ROLL_PTR回滚指针指向这行记录在 undo log 中的上一个版本。当一个事务要读数据时它会根据当前事务的隔离级别生成一个ReadView读视图。这个 ReadView 里记录了生成时刻所有“活跃事务”的ID列表。判断一条记录是否对当前事务可见规则是这样的如果记录的DB_TRX_ID比 ReadView 里最小的活跃事务ID还小说明这个版本在 ReadView 生成之前就已经提交了可见。如果DB_TRX_ID比 ReadView 里最大的活跃事务ID还大说明这个版本是在 ReadView 生成之后才产生的不可见。如果DB_TRX_ID在活跃事务ID列表里说明这个事务还没提交不可见。如果DB_TRX_ID不在活跃事务ID列表里但小于最大ID说明这个事务在 ReadView 生成前已经提交了可见。在 REPEATABLE READ 级别下ReadView 是事务第一次执行查询时生成之后整个事务内都复用同一个 ReadView。这就保证了事务内所有查询看到的都是同一个数据快照自然就避免了不可重复读。而在 READ COMMITTED 级别下每次查询都会重新生成 ReadView所以能看到其他事务已经提交的最新数据就会出现不可重复读。用生活化的类比来解释REPEATABLE READ 相当于你进入商场时拍了一张全景照片之后无论别人怎么改价签、换商品你看的都是自己手里这张照片READ COMMITTED 则是每看一件商品就重新拍一张照别人刚改过价格你立刻就能看到。2.2 当前读与 Next-Key LockMVCC 解决的是快照读的问题也就是普通的SELECT语句。但如果你执行的是当前读Current Read比如SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT这些操作必须读取数据的最新版本并且要对涉及的数据加锁。这时候 MVCC 就帮不上忙了需要靠锁机制来避免并发问题。InnoDB 的锁有三种主要类型Record Lock记录锁锁住某一行记录。Gap Lock间隙锁锁住一个区间但不包括记录本身。目的是防止其他事务在这个区间内插入新记录。Next-Key Lock临键锁是记录锁和间隙锁的组合锁住一个左开右闭的区间既包含记录本身也包含记录前面的间隙。幻读的产生就是因为你查询一批记录时另一个事务往结果集里插入了新行。为了堵住这个口子InnoDB 在执行当前读时不仅锁住已经存在的记录行还会锁住这些记录之间的间隙让别人没法往里面插数据。比如你执行SELECT * FROM orders WHERE amount 100 FOR UPDATE;假设表中 amount 有90、110、130三档数据InnoDB 除了给110和130这两行加记录锁还会给 (110, 130) 这个区间以及 (130, ∞) 这个区间加间隙锁。另一个事务想插入 amount120 的记录时会因为命中 (110, 130) 的间隙锁而阻塞直到当前事务提交。REPEATABLE READ 级别下间隙锁是默认开启的所以幻读被天然遏制了。READ COMMITTED 级别下间隙锁基本不起作用只保留记录锁用于外键检查和唯一性检查这就是为什么该级别下幻读仍然可能发生。注意InnoDB 的间隙锁有一个特例——在唯一索引上如果查询条件是等值匹配且能定位到唯一记录间隙锁会被优化掉退化为记录锁。因为唯一索引已经保证了不会插入重复值不需要锁间隙。3. 实操验证用具体SQL复现三类问题光讲理论不够必须亲手复现一遍才能建立直观感受。下面我给出完整的验证步骤你可以在自己的 MySQL 环境里跑一遍。测试前先确认一下版本和隔离级别SELECT VERSION(); SHOW VARIABLES LIKE transaction_isolation;我用的是 MySQL 8.0默认隔离级别就是 REPEATABLE READ。测试用的表结构很简单CREATE TABLE account ( id int NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL, balance int DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO account (name, balance) VALUES (Alice, 100), (Bob, 200), (Charlie, 300);3.1 复现脏读需要把隔离级别降到最低在 REPEATABLE READ 下是复现不了脏读的必须把隔离级别改成 READ UNCOMMITTED。两个会话一起操作事务A会话1SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE account SET balance 150 WHERE id 1;此时事务A还没提交注意观察SHOW PROCESSLIST能看到这个事务处于未提交状态。事务B会话2SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1;执行结果balance 显示为 150。而实际上事务A还没提交如果事务A现在执行ROLLBACK这条记录的 balance 会回到 100。事务B刚才读到的 150 就是脏数据。验证结论脏读只有在SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED时才会出现。MySQL 8.0 默认隔离级别下不存在这个问题。3.2 复现不可重复读挂在 READ COMMITTED 级别下事务A会话1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1;此时返回 100。事务B会话2SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; UPDATE account SET balance 500 WHERE id 1; COMMIT;事务B把 Alice 的余额改成了500并提交。事务A会话1再次查询SELECT balance FROM account WHERE id 1;此时返回 500。同一个事务里两次读取同一条记录值从100变成了500不可重复读出现了。如果事务A把隔离级别改成 REPEATABLE READ同样操作再跑一遍第二次查询依然返回100因为 ReadView 复用了第一次查询时的快照。3.3 复现幻读注意InnoDB的特殊性如果你在纯 InnoDB 且 REPEATABLE READ 级别下通过常规SELECT语句复现幻读是复现不出来的因为快照读机制已经挡住了。但这不代表幻读这个概念不存在它在你切换到 READ COMMITTED 级别时就会出现。事务A会话1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT COUNT(*) FROM account WHERE balance 100;此时返回2Bob和Charlie。事务B会话2SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; INSERT INTO account (name, balance) VALUES (David, 400); COMMIT;事务A会话1再次查询SELECT COUNT(*) FROM account WHERE balance 100;此时返回3多了一个David。这就是幻读。在 REPEATABLE READ 级别下如果你想通过SELECT看到这种数量变化需要把查询改成当前读比如加FOR UPDATE。但更常见的情况是当你执行UPDATE或SELECT ... FOR UPDATE时InnoDB 会用间隙锁阻止新数据插入你在事务提交前看到的记录集合始终保持一致。3.4 一个容易踩的坑先快照读再当前读这里有个特别容易让人困惑的细节在 REPEATABLE READ 级别下如果你在事务里先做了快照读然后再做当前读是有可能看到其他事务刚提交的新数据的。因为当前读不走 ReadView它读的是最新版本并加锁。顺序是这样事务A执行SELECT * FROM account WHERE balance 100用快照读生成 ReadView看到2条记录。事务B插入一条新记录并提交。事务A执行UPDATE account SET balance balance 10 WHERE balance 100这是当前读能看到事务B插入的新记录并把它也更新了。这个现象在严格意义上也算一种“幻读”但因为查询方式从快照读切到了当前读很多人会忽略它。如果你恰好是数据库开发人员而且对隔离级别有很高要求一定要注意这种“切换读模式”带来的不一致问题。实际业务中如果要避免思路是在事务一开始就用SELECT ... FOR UPDATE锁定范围或者把隔离级别提升到 SERIALIZABLE。4. 如何避免隔离级别选型与锁策略实战4.1 隔离级别怎么选业务场景决定一切很多初学者一上来就问哪个隔离级别最好答案是没有绝对的好只有适不适合。隔离级别越高并发性能越差隔离级别越低出现异常数据的风险越高。关键看业务对数据一致性的容忍度。下面是不同业务场景的选型参考隔离级别适用场景理由READ UNCOMMITTED几乎不用除非做非关键性的统计、报表能容忍读到未提交数据READ COMMITTED大多数互联网业务、分布式系统每次读都能看到最新已提交数据Oracle默认此级别适合分库分表场景REPEATABLE READMySQL 默认金融类、订单类业务事务内数据一致保证财务对账等场景的准确性SERIALIZABLE强一致性要求、金额极高敏感操作完全串行基本没有并发性能代价最大注意一个实际工程中的选择问题如果你要做分库分表或者使用一些分布式事务中间件比如 Seata很多中间件默认假设数据库隔离级别是 READ COMMITTED。因为 REPEATABLE READ 下的间隙锁在分布式环境下会成为死锁和性能瓶颈的高发点。这属于架构层面的取舍。4.2 从应用层避免并发问题的三个套路隔离级别是数据库层面的防御。但工程实践中光靠数据库是不够的还需要在应用层做好并发控制。有三个常用套路套路一悲观锁在更新前先SELECT ... FOR UPDATE锁定记录直到事务结束才释放。适合并发冲突频繁的场景。代价是阻塞时间长容易死锁。-- 事务A START TRANSACTION; SELECT * FROM account WHERE id 1 FOR UPDATE; -- 做一些业务判断 UPDATE account SET balance balance - 50 WHERE id 1; COMMIT;在此期间事务B执行SELECT * FROM account WHERE id 1 FOR UPDATE会被阻塞必须等事务A提交。套路二乐观锁在表里加一个版本号字段更新时比对版本号版本号不匹配则更新失败需要重试。适合并发冲突少的场景。MVCC 本身就是这种思路的体现。-- 事务A UPDATE account SET balance balance - 50, version version 1 WHERE id 1 AND version 5;如果影响行数为0说明 version 已经变了需要重读数据、重新计算。套路三唯一约束兜底针对并发插入导致的幻读问题可以在业务表上建唯一索引让数据库在底层拒绝重复插入。这比应用层判断“是否存在”要可靠得多。4.3 间隙锁的几个实用细节关于间隙锁有几个实操经验值得记录下来间隙锁只在 REPEATABLE READ 级别生效。如果你在 READ COMMITTED 级别下执行SELECT ... FOR UPDATEInnoDB 不会锁间隙而是靠其他机制避免并发问题。间隙锁锁的是区间不是单行。比如WHERE id 5 AND id 10锁住的是 (5, 10) 这个范围其他事务往这个范围插入 id7 时会被阻塞。死锁高发区。因为间隙锁和记录锁是分开的两个事务可能互相持有对方需要的锁。比如事务A锁了 id5 的记录、准备插入 id6 的数据事务B锁了 id6 的记录、准备插入 id5 的数据互相等待就会死锁。MySQL 会自动检测死锁并回滚其中一个事务但应用需要做好重试。大范围更新时尽量减少锁范围。SQL 的 WHERE 条件越精确锁的范围越小并发度越高。如果一条 UPDATE 的 WHERE 条件没走索引InnoDB 会退化成锁全表实际上是对所有扫描到的记录加锁这是非常常见的性能事故。5. 常见问题速查与排查思路实际操作中遇到这些问题别急着改配置按下面这个排查顺序走一遍。5.1 排查脏读出现脏读先检查隔离级别SHOW VARIABLES LIKE transaction_isolation;如果显示READ-UNCOMMITTED那就是根因。修改方式SET GLOBAL transaction_isolation REPEATABLE READ; SET SESSION transaction_isolation REPEATABLE READ;注意SET GLOBAL只影响新连接已经存在的连接不受影响。而且 MySQL 重启后全局配置会被重置如果要持久化需要修改配置文件my.cnf或my.ini在[mysqld]段下加transaction-isolation REPEATABLE READ。5.2 排查不可重复读如果应用里发生了不可重复读排查思路确认隔离级别。如果是 READ COMMITTED这属于该级别的正常行为不是bug。确认是否同一个事务。很多线上“不可重复读”问题其实是应用代码里每次查询都自动提交了事务导致前后两次查询根本不在同一个事务里。确认是否有隐式提交。DDL语句CREATE、ALTER、DROP、SET操作、START TRANSACTION都可能触发隐式提交导致事务提前结束。5.3 排查幻读在 REPEATABLE READ 下还遇到“幻读”类问题优先考虑是否切换了读模式。前面提到的“先快照读、再当前读”是最典型的隐性触发场景。是否使用了非 InnoDB 引擎。比如 MyISAM 不支持事务和行锁隔离级别形同虚设。是否使用了SELECT ... LOCK IN SHARE MODE但没加索引。如果查询条件没有索引锁会覆盖所有扫描到的记录效果和锁表差不多反而更容易阻塞。5.4 隔离级别修改的版本坑MySQL 5.7 及之前参数名是tx_isolationMySQL 8.0 开始参数名改成了transaction_isolation。很多老教程还写着SET tx_isolation ...在 8.0 上执行不会生效会报Unknown system variable tx_isolation。这是版本升级踩坑的高频问题。检查当前会话的隔离级别SELECT transaction_isolation; SELECT global.transaction_isolation;5.5 一条重要的实操心得根据我个人经验大部分“脏读、幻读、不可重复读”问题不是数据库配置出了问题而是应用的事务边界设置不合理。最常见的情况有三种事务开启得太早提交得又太晚导致锁持有时长过大并发度下降死锁概率上升。事务里混入了远程调用RPC、外部接口请求一个事务里还去调别人的接口这种事务基本注定会出问题。读取和更新不在同一个事务里先查后改的场景没有用FOR UPDATE锁住导致更新时数据已经变了。正确的做法是事务要短、要精准。只把必要的查询和更新包含在事务里任何非数据库操作发消息、调接口、文件读写都要放到事务外面。这一点在排查问题的时候往往比调隔离级别更有效。6. 写在最后别只背概念要上手验证MySQL 的并发控制是一个理论性和实践性都很强的主题。面试题里喜欢问脏读、幻读、不可重复读的区别但真正到了生产环境你遇到的问题是各种因素交织在一起的隔离级别设置不合适、索引没建好、事务边界过长、锁等待超时、死锁回滚……每一个都能让线上服务抖三抖。我的建议是把文中的实验在自己本地跑一遍亲眼看到三种异常的发生再亲手用隔离级别和锁机制把它们挡住。只有亲自动手验证过你才会真正理解 MVCC 和间隙锁的设计精妙之处。最后再分享一个小技巧排查锁相关问题的时候打开SHOW ENGINE INNODB STATUS\G看看LATEST DETECTED DEADLOCK部分里面会详细记录死锁的 SQL 和涉及的索引这是定位问题最快的路径。希望这篇文章能帮你把这块知识真正啃下来。