
先分享一个很多开发者在面试里被问过、在业务代码里踩过坑的场景你写了一个订单系统两个用户同时下单一个事务还在更新库存另一个事务已经查到了未提交的库存数字随后前者回滚页面上显示了一个压根不存在的“已扣减库存”。这就是典型的脏读。而“不可重复读”和“幻读”则是另外两种并发事务下的读异常它们一起构成了MySQL事务并发控制中最绕不开的三个经典问题。这篇文章想把根源讲透再把应对方案讲清楚。你会看到事务隔离级别、MVCC多版本并发控制、行锁与间隙锁是怎么协同工作又在哪些环节“漏”出了问题。代码层面会给出可以直接执行的SQL演示步骤方便你在本机MySQL里复现并观察现象。内容同时适合两类人一类是准备数据库面试的开发同学另一类是已经写了几年业务代码、想搞清楚“为什么我的并发查询偶发不一致”的后端工程师。1. 先搞清楚为什么会出现这三个“读异常”1.1 并行事务带来的三种“读异常”到底是什么当多个事务同时操作同一批数据时数据库为了保证数据最终一致必须对读写操作做约束。约束不够就会产生异常约束过度并发性能又急剧下降。脏读、不可重复读、幻读正是“约束不够”时暴露出的三种典型问题。脏读事务A修改了一条数据但还没提交事务B就读到了这条“修改后”的数据。结果事务A回滚了事务B读到的数据就是凭空出现的、从未正式生效的数据。不可重复读事务A第一次读取某条记录时是值1事务B修改这条记录并提交后事务A再次读取同一行发现值变成了2。同一个事务里同一行数据前后读取不一致。幻读事务A用同一个范围条件查询两次第一次查到10条记录事务B插入了一条满足条件的新记录并提交事务A第二次查询时变成了11条。多的那条记录就像“幻影”一样出现在结果集里。三种异常放在一起看脏读是“读到了没提交的修改”不可重复读是“同一条记录的内容变了”幻读是“记录的总数变了”。后两者的区别经常有人混淆记住一个粗暴的区分方式不可重复读关心的是UPDATE幻读关心的是INSERT。用生活化的类比来理解三个人合伙管一个流水账本记账员A正在凭记忆改账目还没落笔确认B从旁边偷看到了A脑子里改完的数字这是脏读A下午核对账本某行金额时记的是100晚上再核对同一行发现变成了80这是不可重复读A统计今天一共10笔入账深夜一数变成了11笔多出一笔白天没见过的这是幻读。1.2 问题根源隔离级别本身就是在“并发”和“一致”之间做取舍这三个异常并不是凭空随机的。SQL标准根据对并发问题的容忍程度定义了四个事务隔离级别从低到高依次是读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。隔离级别脏读不可重复读幻读并发能力READ UNCOMMITTED可能发生可能发生可能发生最高READ COMMITTED已解决可能发生可能发生较高REPEATABLE READ已解决已解决InnoDB下基本解决较低SERIALIZABLE已解决已解决已解决最低隔离级别越低并发能力越强但数据一致性越差隔离级别越高数据越稳定但并发能力被牺牲得越狠。MySQL默认使用REPEATABLE READ不是因为它读性能最好而是因为InnoDB引擎用MVCC加间隙锁在可重复读级别下就已经把幻读问题解决得差不多不需要被迫升到SERIALIZABLE。注意SQL标准说REPEATABLE READ“允许幻读”这是理论层面的定义。InnoDB在这个隔离级别下用临键锁Next-Key Lock对范围查询加锁反而把幻读挡住了。这也是面试里最容易出现的一个知识点MySQL可重复读到底能不能解决幻读答案不是简单的是或否要看当前是快照读还是当前读。理解了根源是“隔离级别与并发控制机制之间的配合”接下来就能看懂脏读主要靠隔离级别挡不可重复读主要靠MVCC挡幻读则要靠锁机制兜底。2. 逐帧拆解脏读、不可重复读、幻读的触发机制2.1 脏读读到别人“还没写完”的数据脏读只会在READ UNCOMMITTED级别下出现。这个级别几乎不做隔离一个事务的修改对其他事务完全可见即使那个修改还没提交。复现步骤很直白本机开两个MySQL会话窗口-- 会话1 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; -- 此时不提交等会话2执行查询-- 会话2同样设置为READ UNCOMMITTED SELECT balance FROM account WHERE id 1; -- 读到了900但900这笔修改还没提交会话1执行ROLLBACK后账户余额回到1000但会话2刚才读到的900就是“脏数据”。为什么会产生因为READ UNCOMMITTED级别下SELECT语句不会检查记录的“可见性版本”直接读最新版本的数据。实际业务里很少有人会把隔离级别调到READ UNCOMMITTED数据一旦回滚就会造成线上金额错乱风险极大。我见过个别报表查询为了“减少锁等待”把整个会话设置成READ UNCOMMITTED结果拉数拉到一半基础表被回滚报表数字对不上账排查半天才找到根因。除非你很清楚自己在做什么否则不要在生产环境使用这个级别。2.2 不可重复读同一行数据两次读取对不上把隔离级别提升到READ COMMITTED脏读被解决但不可重复读依然存在。READ COMMITTED的语义是“一个事务只能读到其他事务已提交的数据”这在脏读层面是安全了可它没有保证“同一个事务内多次读取结果一致”。复现步骤-- 会话1 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 第一次读得到1000-- 会话2 UPDATE account SET balance 800 WHERE id 1; COMMIT;-- 会话1 SELECT balance FROM account WHERE id 1; -- 第二次读得到800 COMMIT;同一个事务里第一次读到1000第二次读到800。数据是“已提交”的不脏但前后不一致。根因在MVCC的Read View机制上。READ COMMITTED级别下每次SELECT都会生成一个全新的Read View也就是“重新看一遍当前已提交的数据快照”。这样一来别的事务只要在两次SELECT之间提交了修改下一次读取就会把新版本放到可见范围里。不可重复读本质上是“同事务内Read View污染”每次读都换一副新眼镜自然看不清“固定画面”。2.3 幻读范围查询的行数“凭空”变化幻读比不可重复读更难缠因为它牵涉到范围锁。不可重复读关心的是某一行更新幻读关心的是某个范围内“多出来一行”。理论上READ COMMITTED和更低级别都会发生幻读。要复现也很容易-- 会话1 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT COUNT(*) FROM orders WHERE amount 100; -- 第一次结果10-- 会话2 INSERT INTO orders (id, amount) VALUES (1001, 200); COMMIT;-- 会话1 SELECT COUNT(*) FROM orders WHERE amount 100; -- 第二次结果11 COMMIT;两个SELECT之间另一个事务插入了一条amount大于100的新订单第二次查询多了一条记录。这条记录不是“修改了已有内容”而是凭空新增的像幻影一样所以叫幻读。到了REPEATABLE READ级别InnoDB的快照读不会出现幻读因为整个事务期间复用第一次SELECT生成的Read View新提交的INSERT记录在版本链上对这个事务不可见。但如果走的是当前读——比如SELECT ... FOR UPDATE、UPDATE、DELETE这类加锁语句——依然可能出现幻读。原因就是锁的范围覆盖不到位后面章节会详细展开。3. 解决办法隔离级别、MVCC与锁机制怎么配合3.1 隔离级别怎么选从默认值到业务场景生产环境里MySQL默认隔离级别是REPEATABLE READ。绝大多数情况下你不用改原因有三点一是InnoDB在RR下已经通过MVCC和临键锁解决了大部分幻读问题二是很多线上工具、备份程序默认按RR语义工作三是切换隔离级别本身有运维成本和风险。但有个例外值得注意。如果你的系统主从复制用的是基于语句的binlogSTATEMENT格式RR级别更安全因为它在当前读时会锁住必要的间隙避免INSERT在主从库上执行顺序不一致。MySQL 8.0默认的binlog格式是ROW此时将隔离级别调整为READ COMMITTED锁竞争通常会更小死锁概率也会下降。不少高并发互联网团队会把线上库切成RC目的就是减少间隙锁带来的插入阻塞。给你一个简单的选择思路涉及资金、订单状态流转、库存扣减等高一致性业务保持REPEATABLE READ别乱动。纯查询报表、统计分析、读多写少且对一致性要求不苛刻的场景可以考虑READ COMMITTED换取更好的并发性能。几乎不用SERIALIZABLE除非数据量极小、并发极低、且对一致性有变态要求。SERIALIZABLE把读操作也升级成加锁读并发能力下降非常明显。一句话总结隔离级别的选择不是越严越好而是在“我能不能容忍偶尔的读不一致”和“我要不要留出足够的写并发空间”之间做权衡。3.2 MVCC让“读”不再阻塞“写”MVCCMulti-Version Concurrency Control多版本并发控制是InnoDB能同时提供高并发和一致性读的核心武器。它不靠加锁让读写互斥而是通过保留数据行的多个历史版本来实现“读写不冲突”。每行数据在InnoDB里都有几个隐藏列关键的两个是DB_TRX_ID最近一次修改该行记录的事务ID。DB_ROLL_PTR回滚指针指向undo log里的上一个版本。当一个事务SELECT某行数据时InnoDB会拿当前事务的“Read View”去判断这行的哪个版本对当前事务可见。Read View本质上是一个活跃事务ID列表里面记录着“创建这一刻还有哪些事务尚未提交”。可见性判断规则简化后就是DB_TRX_ID小于Read View内最小活跃事务ID的版本可见DB_TRX_ID属于当前事务自己的版本可见DB_TRX_ID在活跃列表内但又不是自己的版本不可见需要沿undo log回滚指针往上找更早的版本。脏读和不可重复读的差异就体现在Read View的生成时机READ COMMITTED每条SELECT语句生成新的Read View所以两次读能看到不同已提交版本产生不可重复读。REPEATABLE READ事务内第一个SELECT生成Read View后复用整个事务都按“第一次看到的数据版本”来读快照固定自然不可能出现不可重复读。用大白话讲RR把事务开始时的数据快照“拍照”存档了后面所有查询都看这张照片RC则每次查询都现场拍一张新照片。照片固定看到的内容就固定照片每次实时更新看到的内容自然跟着变。3.3 锁机制记录锁、间隙锁与临键锁如何防住幻读MVCC解决的是普通SELECT这类的快照读问题但像SELECT ... FOR UPDATE、UPDATE、DELETE这样的当前读必须读到最新版本数据不能看快照所以要靠锁来保证并发安全。InnoDB的锁从粒度上分为三类记录锁Record Lock只锁索引记录本身锁住一行。间隙锁Gap Lock锁住索引记录之间的“间隙”阻止其他事务在间隙内插入新记录。临键锁Next-Key Lock记录锁和间隙锁的组合体左开右闭区间既锁住行又锁住行前的间隙是InnoDB在RR级别下防止幻读的主要手段。举例说明表里索引列的值有10、20、30三行记录执行SELECT ... WHERE id 10 FOR UPDATE时InnoDB在RR下会对(10,20]、(20,30]、(30,∞)这些区间加临键锁或间隙锁。其他事务想在10到正无穷之间插入新记录会被锁挡住直到当前事务提交或回滚。这就堵住了“范围查询时新增记录导致幻读”的通道。这也是为什么把隔离级别改成READ COMMITTED能提高并发性能——RC级别下InnoDB只保留记录锁不再加间隙锁插入操作被阻塞的概率大大降低。但代价是RC级别下可能产生幻读所以业务要自己权衡。3.4 实操验证两个会话完整复现“可重复读如何防幻读”为了让你看到MVCC和临键锁的实际效果我们做一个混合验证。前提是MySQL默认的REPEATABLE READ级别表结构中id是主键balance是普通字段。先建一张测试表CREATE TABLE account ( id INT PRIMARY KEY, balance DECIMAL(10,2) ); INSERT INTO account VALUES (1, 1000), (2, 1000), (3, 1000);会话1开启事务做一次普通SELECT快照读-- 会话1 START TRANSACTION; SELECT * FROM account; -- 返回3行会话2插入一条新记录并提交-- 会话2 INSERT INTO account VALUES (4, 2000); COMMIT;会话1再次执行相同的SELECT结果还是3行。这就是RR级别下MVCC的作用事务期间快照固定新插入的记录对当前事务不可见幻读被MVCC挡掉了。但如果把会话1的查询改成当前读-- 会话1 SELECT * FROM account WHERE id 0 FOR UPDATE; -- 返回4行刚才会话2插入的第4行出现了因为FOR UPDATE必须读最新版本并加锁快照机制失效。更关键的是这条语句在RR级别下会为id 0的范围加上临键锁。此时你在会话2执行同样的INSERT会一直阻塞直到会话1提交或回滚。这就是锁机制在防幻读时做的事。实践心得想彻底观察锁等待把innodb_lock_wait_timeout调小一点比如默认50秒可临时改为5秒这样复现锁阻塞时不用干等50秒。执行SET SESSION innodb_lock_wait_timeout 5; 再测试。4. 问题排查锁等待、死锁与隔离级别选择的实战建议4.1 锁等待超时怎么查线上出现“Lock wait timeout exceeded”是最常见的并发事务报警字面意思是当前事务等待获取某行记录的锁超过了阈值被系统强行终止。引发原因通常是另一个事务持有锁时间过长比如没有及时提交、事务里执行了慢SQL、长事务嵌套多个查询等。定位锁等待的操作步骤-- 查看当前正在锁等待的事务 SELECT * FROM information_schema.innodb_trx\G; -- 看哪些锁正在被等待 SELECT * FROM sys.innodb_lock_waits\G;innodb_trx表能看事务状态、开始时间、锁等待时间。一旦发现有事务的trx_state是LOCK WAIT基本可以判定它在等待别人的锁释放。配合sys.innodb_lock_waits可以直接看到阻塞者和被阻塞者的事务ID、耗时和锁资源关系。如果不想用SQL还有一个更直接的方式SHOW ENGINE INNODB STATUS\G;结果里有LATEST DETECTED DEADLOCK和TRANSACTIONS两个段落TRANSACTIONS段落里会列出当前未结束的事务及其锁状态。注意这个命令会附带大量引擎状态信息输出比较长建议在排查问题时使用别频繁跑。4.2 死锁怎么定位和规避死锁是并发事务互相持有对方需要的锁导致谁都无法继续。MySQL检测到死锁后会回滚其中一个事务让另一个继续执行错误信息形如“Deadlock found when trying to get lock; try restarting transaction”。最常见的死锁场景是事务1先更新了id1再更新id2事务2先更新id2再更新id1。两个事务交错执行互相等对方的锁死锁形成。规避死锁的三个实操方向多个事务更新多条记录时遵守相同的顺序。比如所有事务都按id从小到大的顺序更新记录交叉等待就不存在了。缩小事务范围减少持锁时间。事务里尽量只执行必要的SQL提交要快别在事务中做远程调用或等待用户输入。合理利用索引选择。UPDATE的WHERE条件能用索引命中多行时加锁范围更可控如果走全表扫描锁覆盖范围大死锁概率更高。另外查看死锁具体原因时最有用的还是SHOW ENGINE INNODB STATUS\G;重点看LATEST DETECTED DEADLOCK段里面会列出两个事务分别持有和等待的锁还有导致死锁的SQL语句。根据语句反推出加锁顺序再调整代码会比盲目加超时时间有效得多。4.3 实践中的隔离级别选择建议关于隔离级别我在实际项目里给过很多次建议核心判断标准有两个业务能不能容忍不可重复读或幻读写并发高不高。电商订单用户下单后读订单状态如果两次读的状态不一致比如支付回调已经改了状态但查询还是“待支付”用户端看到的信息就明显错乱。这类业务用RR保证事务内读取稳定。日志监控类结果只做展示允许轻微不一致用RC或直接RR都行反正查的是历史数据。会在事务里先查后插的业务比如“查询某订单是否存在不存在则插入”用RR更稳妥因为RR下的临键锁会锁住对应间隙防止两个并发事务同时查到“不存在”并同时插入。高并发秒杀类库存扣减用原子UPDATE或者条件更新别依赖事务先SELECT再UPDATE否则即使隔离级别再高也扛不住并发。我踩过一个印象很深的坑某次为了提升写入吞吐把线上一个核心配置表的隔离级别从RR切到了RC。结果下游服务在同一个事务里先读配置再更新配置事务内两次读取结果不一致导致配置被错误覆盖。排查了一下午最后发现是隔离级别改动引起的。后来我们在配置中心这类“读多写少、读取频繁”的表上坚持使用RR同时在代码层避免在事务里做“先读后写”的长流程操作。4.4 死磕一下快照读和当前读什么时候用很多初学者在代码里不知道该用普通SELECT还是SELECT ... FOR UPDATE这里顺便梳理一下纯查询不需要关心最新数据用普通SELECT。它走MVCC快照读不加锁性能最好。查询后要基于查询结果做修改且不希望其他事务在这个期间改这些行使用SELECT ... FOR UPDATE。比如扣库存前先锁住库存行防止别人同时扣减。查询后要基于查询结果做修改但只希望阻止别人改、不阻止别人读可以用SELECT ... LOCK IN SHARE MODE8.0里也写作SELECT ... FOR SHARE。不过业务里很少用到它了解即可。FOR UPDATE的锁是在事务提交或回滚时才释放所以事务结束后要尽快COMMIT否则这个行的其他写操作会全部排队。时机提醒代码里尽量不要每个查询都加FOR UPDATE锁范围越大、持锁越久死锁和锁等待的概率越高。能用条件更新解决的事情就别先查后改。个人实操心得这几个问题我在面试和线上问题排查里反复遇到。面试时面试官真正想听到的往往不是“脏读读未提交、不可重复读是两次读不一致”这种背诵式答案而是你能否把隔离级别、MVCC、锁机制串成一条线解释出“为什么RR下快照读没有幻读但当前读可能还有”这类有层次的思考。线上排查时我的第一反应不是翻代码而是先看两个东西事务隔离级别和当前正在跑的活跃事务。执行一遍SELECT transaction_isolation;再用innodb_trx看一下有没有老事务长时间不提交。绝大多数并发读异常根子上都是“长事务持有旧快照加上隔离级别设置不当”造成的。如果你时间有限建议至少动手做一遍文中的复现实验开两个会话设置不同隔离级别逐步复现脏读、不可重复读、幻读再切换到RR验证MVCC和临键锁的效果。这个实验做完你对MySQL事务控制的理解会比背十遍八股文都扎实。