MySQL事务隔离级别实战:脏读、不可重复读、幻读复现与锁机制解析
事务隔离级别这个概念面试里常问但真正在数据库里亲手复现过三种并发问题的人并不多。我见过不少同事能准确背出四种隔离级别的名字一遇到线上“这个事务读到的东西怎么跟预期不一样”就抓瞎。这篇文章直接从实战入手把脏读、不可重复读、幻读一个个在现场复现出来顺带把MVCC、间隙锁这些底层机制讲明白然后再聊聊生产环境到底该用哪种隔离级别、锁超时和死锁怎么排查。环境以MySQL 8.0为主5.7用户照样能照着操作只要开了两个命令行会话手上有数据库就能跟着做。说明一点下面的所有实验都在同一台MySQL实例上用两个会话窗口模拟并发不需要额外的测试工具。我会把每个会话里敲的命令完整贴出来你照着执行就行。1. 为什么绕不开事务隔离级别1.1 并发事务的三大隐患脏读、不可重复读、幻读先说结论隔离级别不是DBA拍脑袋定出来的规则它是用来处理“多个事务同时读写同一批数据”时必然出现的三类问题而设计的。脏读最直观。事务A改了数据但还没提交事务B就查到了这笔未提交的修改。如果事务A后来回滚了事务B读到的就是一个现实里从未存在过的值。想象一下两个人合伙管账甲往账上记了一笔收入还没确认乙已经拿着这个数去做报表了甲后来发现记错了又删掉报表自然就废了。不可重复读指的是同一个事务内两次读取同一行数据结果却不一样。比如你在一个事务里先查余额是1000元处理了一些业务逻辑后再次查询余额变成了800元因为另一个事务在这期间修改并提交了。最头疼的是你基于第一次查询做的判断可能已经落库了前后数据对不上。幻读比不可重复读更进一步。不可重复读是同一行的值变了幻读是查询结果的行数变了。第一次查出10条记录第二次查出11条多出来的那条“凭空出现”的数据就是幻影行。这类问题在处理范围数据、统计汇总时杀伤力特别大。1.2 四种隔离级别横向对比SQL标准按强度从低到高定义了四种隔离级别它们能解决的问题、会遗留的问题不一样隔离级别脏读不可重复读幻读底层实现基础READ UNCOMMITTED读未提交可能可能可能基本无隔离读最新未提交版本READ COMMITTED读已提交不可能可能可能每次查询生成新的快照REPEATABLE READ可重复读不可能不可能InnoDB下基本不可能事务首次读创建快照配合间隙锁SERIALIZABLE串行化不可能不可能不可能所有读操作加共享锁注意一个细节SQL标准里REPEATABLE READ允许幻读MySQL的InnoDB存储引擎通过MVCC和Next-Key Lock把这个缺口补上了所以在绝大多数场景下InnoDB的RR级别并不会出现幻读。这一点是MySQL容易被误解的地方后面实操里我会专门验证。1.3 为什么MySQL默认是REPEATABLE READOracle、PostgreSQL这些数据库默认的是READ COMMITTEDMySQL偏偏默认REPEATABLE READ很多刚接触MySQL的人都不理解。这个问题确实有历史原因。早期MySQL的binlog主流格式是STATEMENT记录的是执行的SQL语句本身。主从复制时从库要重放这些SQL。如果主库在READ COMMITTED级别下执行一个事务事务内前后多次查询的结果可能不一致而基于语句的复制会把这种不一致原样带到从库造成主从数据对不上。REPEATABLE READ能保证事务内每次读取看到的是同一份快照配合基于语句的复制才安全。后来ROW格式的binlog普及了从库不再依赖SQL语义READ COMMITTED的短板被补上但MySQL默认值一直没有改。所以你会看到很多互联网团队会主动把隔离级别改成READ COMMITTED不是他们不认可RR而是RR的间隙锁机制在部分高并发场景下更容易引发死锁换RC加上ROW格式binlog既安全又省心。这个选型权衡我会在最后一章详细说。2. 隔离级别的底层支撑机制2.1 MVCC快照读读不加锁的核心InnoDB实现READ COMMITTED和REPEATABLE READ靠的是MVCC全称多版本并发控制。简单说每行数据在更新时不会直接覆盖旧值而是通过undo log把旧版本链保存下来读操作可以选择读哪个版本。每行记录有两个隐藏字段和一个回滚指针最近修改这行的事务IDDB_TRX_ID、指向旧版本的回滚指针DB_ROLL_PTR以及一个自增的DB_ROW_ID。当你执行一条普通的SELECTInnoDB会生成一个“一致性视图”业界也叫Read View。视图里记录了当前有哪些活跃事务。判断一条数据版本是否可见就按下面四条规则来如果这一行的事务ID等于当前事务自己的ID说明是自己改的必须可见。如果事务ID小于视图里最小活跃事务ID说明这个版本在视图创建前已经提交可见。如果事务ID大于等于视图创建时最大的事务ID说明这行是视图创建之后才被改的不可见。如果事务ID落在中间区间要判断它是否还存在于活跃事务列表里。在列表里说明还没提交不可见不在列表里说明已经提交可见。这套规则理解透了就能明白两种隔离级别的差异READ COMMITTED每次执行SELECT都会新建一个视图所以能看到其他事务新提交的修改REPEATABLE READ只在事务第一次执行SELECT时创建视图之后整个事务都用同一个视图后提交的数据自然看不到了。2.2 行锁三兄弟Record Lock、Gap Lock、Next-Key LockMVCC只解决普通查询的隔离问题UPDATE、DELETE、SELECT FOR UPDATE这类“当前读”必须读到最新数据并加锁否则并发写就会乱套。InnoDB的行锁不是铁板一块它根据锁定的范围分了三种Record Lock是记录锁直接锁住索引上的一行。锁的是索引记录不是数据行本身这是理解InnoDB锁的前提即使表上没有显式索引InnoDB也会通过隐藏的主键索引来完成锁定。Gap Lock是间隙锁锁的是索引记录之间的“空隙”目的是阻止其他事务在某个区间内插入新记录。注意间隙锁不锁记录本身只锁“中间没有东西”的那段空间。Next-Key Lock是前两者的组合左开右闭区间比如锁住(10, 20]这个范围既锁了20这个记录又不让其他事务在10到20之间插入任何数据。正是这种组合锁让REPEATABLE READ下的当前读能防住幻读。把隔离级别和锁对应起来READ COMMITTED下普通语句只加Record Lock不加Gap Lock并发度更高REPEATABLE READ下走的是Next-Key Lock间隙也被锁住并发度自然下降但换来的是更强的一致性保证。脏读则是因为READ UNCOMMITTED连写都不太讲究读操作直接读最新版本。3. 环境准备与全场景复现3.1 搭建测试环境先建一张账户表模拟最简单的转账场景CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0 ) ENGINEInnoDB; INSERT INTO account (name, balance) VALUES (张三, 1000), (李四, 1000);然后确认当前隔离级别SELECT transaction_isolation;MySQL 8.0输出一般是------------------------- | transaction_isolation | ------------------------- | REPEATABLE-READ | -------------------------5.7及更早的版本变量名是tx_isolation如果习惯用SHOW VARIABLES可以写SHOW VARIABLES LIKE transaction_isolation;。开启两个会话窗口我这里统一叫会话A和会话B。每个实验我都会明确告诉你在哪个窗口执行。所有会话都保持默认的autocommit1我们用显式的START TRANSACTION控制事务。3.2 READ UNCOMMITTED 下的脏读复现会话A先切到读未提交SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT transaction_isolation;然后开启事务并查询张三的余额START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 结果1000.00此时切到会话B开启一个事务把张三余额减200但注意不提交START TRANSACTION; UPDATE account SET balance balance - 200 WHERE id 1;回到会话A再查一次SELECT balance FROM account WHERE id 1; -- 结果800.00看到了吗会话B的数据根本没有提交会话A却读到了“已经修改后的800元”。这就是脏读。接着让会话B回滚ROLLBACK;再回到会话A查询余额又变回1000元。如果你这时候已经基于800元做了后续业务处理数据就全乱了。READ UNCOMMITTED就是把“读最新版本”贯彻到底完全放弃隔离实际上在业务里基本没人敢用。3.3 READ COMMITTED 的不可重复读复现把会话A的隔离级别改成已提交读SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;会话A开启事务先查张三余额START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 结果1000.00切换到会话B直接更新并提交START TRANSACTION; UPDATE account SET balance 800 WHERE id 1; COMMIT;回到会话A的同一个事务里再查一次SELECT balance FROM account WHERE id 1; -- 结果800.00同一个事务里两次查询结果不一致这是标准的不可重复读。为什么因为READ COMMITTED在每条SELECT语句执行时都重新生成一份视图B提交后新的视图能看到最新提交状态所以A读到了新值。3.4 REPEATABLE READ 如何压制不可重复读与幻读把会话A切回默认的可重复读SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;再走一遍刚才的流程。会话A开启事务查询余额是1000会话B把余额改成800并提交会话A再查看到的结果仍然是1000。这个级别的关键点在于事务第一次执行SELECT时就把视图固定住了后续所有读取都基于这份“照片”不管外面发生了什么。更有意思的是可重复读对幻读的处理。我们来试一个普通快照读的幻读场景。会话A开启事务执行一个范围查询START TRANSACTION; SELECT * FROM account WHERE id BETWEEN 1 AND 5; -- 结果1、2 两行会话B插入一条新记录并提交START TRANSACTION; INSERT INTO account (name, balance) VALUES (王五, 500); COMMIT;会话A再执行同样的查询SELECT * FROM account WHERE id BETWEEN 1 AND 5; -- 结果仍然是 1、2 两行普通SELECT看不到新插入的“王五”这是MVCC快照隔离的效果。但如果我们改用当前读情况又不一样了。会话A执行SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;此时InnoDB会对id范围为(1, 5)的间隙加上Next-Key Lock。会话B的插入操作会被阻塞直到会话A提交或回滚后才会执行。这就是REPEATABLE READ防止幻读的另一只手快照读靠MVCC当前读靠间隙锁。两者配合InnoDB才能把SQL标准里允许幻读的空子堵住。3.5 SERIALIZABLE 的效果验证串行化是最强的隔离级别代价也最大。它会把这些普通SELECT都隐式转成加共享锁的当前读读和读不冲突但读和写之间完全互斥。把会话A和会话B都切到串行化SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;会话A开启事务并执行查询START TRANSACTION; SELECT * FROM account WHERE id 1;此时会话B想更新张三的余额START TRANSACTION; UPDATE account SET balance 900 WHERE id 1;这条UPDATE会一直卡住直到会话A执行COMMIT或ROLLBACK释放共享锁B的更新才能继续。串行化把并发降成了真正的串行执行虽然数据一致性最强但整体吞吐量会直线下降生产环境里除了极少数强一致场景我不会推荐它。4. 实战中的常见问题与排查方案4.1 锁等待超时SQL明明很慢却报1205线上最常见的报错之一是Lock wait timeout exceeded; try restarting transaction错误码1205。含义是一条语句等锁的时间超过了锁等待阈值默认50秒配置项是innodb_lock_wait_timeout。排查这类问题先看当前有哪些事务在跑SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.INNODB_TRX;trx_state为RUNNING但持续了很久的事务往往是元凶记下trx_mysql_thread_id这个对应的其实是MySQL的连接线程ID可以在performance_schema.threads里找到对应的PROCESSLIST_ID再进一步在sys.schema_table_lock_waits或performance_schema.data_lock_waits里定位阻塞关系。快速止血的办法是找出还挂着的会话连接把自己确认无用的长事务会话杀掉。但要小心直接KILL连接可能让未提交的事务回滚操作前必须确认是不是业务上可以放弃的那个会话。真正的根治思路是查业务代码看是不是有人在事务里干了太多耗时间的活比如远程调用、循环更新、大批量导入这些都会拉长持锁时间。4.2 死锁分析information_schema 怎么用死锁是RR隔离级别下最容易遇到的事。经典场景是两个事务按相反顺序更新同一批记录。会话A执行START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; -- 然后再去更新 id2 UPDATE account SET balance balance 100 WHERE id 2;会话B同时执行START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 2; -- 然后再去更新 id1 UPDATE account SET balance balance 100 WHERE id 1;两个事务各自持有了一行的锁又都在等对方释放另一行的锁谁都不让死锁就形成了。InnoDB检测到死锁后会主动回滚其中一个事务让另一个继续执行所以你会看到某个事务报错Deadlock found when trying to get lock; try restarting transaction。查看死锁现场的方式SHOW ENGINE INNODB STATUS\G重点关注输出里的LATEST DETECTED DEADLOCK段里面会列出两个事务各自执行的SQL、持有的锁、等待的锁。我处理过的多数死锁都是业务代码里更新多行时顺序不固定造成的解决办法也简单所有事务都按同一个顺序更新比如先更新id小的再更新id大的或者把多行更新拆成更短的事务。4.3 长事务与主从延迟长事务的危害不是一时半会能看出来的。只要事务不结束InnoDB就要保留这个事务开始前的undo日志版本链历史版本清理不掉回滚段越撑越大。如果主从复制用的是从库并行复制主库上一个长时间未提交的事务还会阻塞binlog的某些清理动作甚至放大主从延迟。排查长事务可以直接看INNODB_TRX里的事务启动时间SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds, trx_state, trx_mysql_thread_id FROM information_schema.INNODB_TRX;如果run_seconds超过几十秒就要警惕了。我踩过最深的一个坑是业务代码在方法入口用注解开事务方法里异常被捕获后没有抛出事务一直挂到超时结果整个表的更新都被堵住。后来我们给事务方法加了个严格的try-catch-finally异常路径必须回滚finally里再确认一次事务状态这个坑才算真正堵上。4.4 隔离级别的正确切换姿势隔离级别可以不重启MySQL动态修改但理解生效范围很重要。语法分三种-- 只影响当前会话 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 影响之后新建的所有会话已存在的会话不变 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 只影响当前事务中尚未开始的下一事务 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;注意如果当前会话已经在一个事务里执行SET SESSION不会影响正在运行的事务只对后续事务生效。生产环境要改全局建议走配置文件比如my.cnf里加[mysqld] transaction-isolation READ-COMMITTED这样重启后保持稳定不会因为一个同事临时改了全局变量导致所有连接行为变化。5. 进阶思考与性能调优5.1 为什么很多项目改用READ COMMITTED我在一线团队的实战里看到过一个规律新项目落库时架构评审经常会把默认的REPEATABLE READ显式改成READ COMMITTED。背后原因不是RR不好而是RC在特定业务下更省心。首先RC只使用记录锁不使用间隙锁锁的范围更小高并发下等锁和死锁的概率明显下降。其次RC语义更简单直接每个语句都能读到已提交的最新数据排查问题时不用反复琢磨“这个事务的快照到底是什么时候建立的”。最后RC配合ROW格式的binlog在复制一致性上已经没问题等于没有历史包袱。但RC的代价也很明显事务内两次读可能结果不同业务代码里如果依赖“先查后算再落库”的模式就必须自己处理数据变化。我的建议是拿不准就先用默认的RR除非你能说出RC带来的具体收益否则不要为了“跟风”去改隔离级别。5.2 隔离级别与binlog、主从复制的关系隔离级别和复制的关系是很多人在生产环境栽跟头的地方。早期基于SQL语句的复制对隔离级别很敏感REPEATABLE READ能确保事务里的查询结果稳定基于语句复制才能得到与主库一致的结果。现在8.0默认的binlog格式是ROW记录的是每一行数据的前后镜像主从一致性不再依赖隔离级别这也是RC能被广泛使用的基础。想确认当前binlog格式可以执行SHOW VARIABLES LIKE binlog_format;如果业务用了基于语句的格式又想把隔离级别改成RC必须评估所有涉及读写的事务是否会产生复制不一致。稳妥的做法是先把binlog切到ROW再动隔离级别。顺序反了很容易出大事。5.3 高并发下的选型建议高并发场景下隔离级别的选择不是孤立的要跟整个事务设计打组合拳事务能短则短。事务时间越短持锁时间越短锁冲突概率越小这个收益比纠结用RR还是RC大得多。让UPDATE和DELETE尽量走索引。InnoDB锁的是索引记录如果更新语句没走索引扫描到多少行就锁多少行极端情况下会升级成大量行锁严重拖垮并发。控制热点行更新。像库存扣减这种高并发写同一行的场景无论什么隔离级别都会遇到锁竞争合理方案是异步排队、分批处理而不是让所有请求都卡在行锁上。读多写少且允许一定延迟的报表查询优先用RR级别的普通SELECT靠MVCC快照读避免加锁完全不影响写入。有人会问高并发是不是直接上SERIALIZABLE最保险我的看法是SERIALIZABLE对绝大多数业务是负优化它彻底牺牲了并发能力。数据库层面能接受的底线一般是RC或RR更强的约束应该在业务代码里实现而不是让数据库把所有操作串行化。6. 实践中的个人体会折腾了几年MySQL最大的感受是隔离级别这东西光背概念永远学不会一定要亲手把脏读、不可重复读、幻读在线上一一“造”出来你才会真正理解MVCC和锁为什么存在。我早期带新人的时候总会让他们先搭一个双会话环境把本文的实验完整跑一遍再回来看线上死锁日志效果比讲十页PPT都好。最后分享一个能救命的小习惯上线前把生产环境每个库的隔离级别、binlog格式、锁等待超时时间都列成清单跟业务负责人确认一遍。大多数线上事故都不是因为某个机制设计得不够好而是团队里根本没人知道当前环境是哪种隔离级别、事务里跑了多久的SQL。把这两件事搞清楚你已经能躲开不少并发大坑了。