
很多做后端开发的同学简历里都写着“熟悉MySQL事务隔离级别”可真被问到“可重复读到底解决了什么问题”“为什么MySQL默认不用读已提交”“幻读到底在什么条件下才会出现”时能一次讲清楚的人其实不多。我自己刚工作那两年也一样面试前把这些概念背得滚瓜烂熟一到线上排查数据不一致、死锁这种问题还是靠搜。后来我专门抽了几周时间把MySQL事务隔离级别的原理、官方文档和源码逻辑串起来又在测试库里用两个会话把脏读、不可重复读、幻读挨个复现了一遍才算真正把这块知识点焊死在脑子里。这篇文章就是我自己的完整复盘不只有概念对比还有可复现的实操步骤、改隔离级别的正确姿势以及我在实际项目中踩过的一些坑希望能帮你把这块内容真正补扎实。1. 先搞清楚隔离级别到底在解决什么问题1.1 隔离性是事务ACID里最容易被忽略的一环大家都知道事务有四大特性原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。前三个特性大家都能说上几句但“隔离性”具体指什么很多人只有一个模糊的概念——多个事务同时执行时互不干扰。MySQL里用InnoDB引擎事务在执行过程中会持有各种锁、生成数据快照。如果完全不设隔离两个事务同时操作同一行数据你改你的、我改我的最后结果可能完全不可控。就好比两个人同时在同一块白板上写字谁都不等谁最后白板上的内容乱成一团谁也说不清哪一笔是谁先写上去的。隔离级别做的事情就是给这种“并发混乱”设一道闸门允许哪些干扰出现、禁止哪些干扰出现每个级别对应不同的并发控制强度。强度越高数据越安全但并发性能越差强度越低并发越好但数据错乱的风险越大。所以隔离级别本质上是一个“一致性”和“性能”之间的权衡旋钮理解它的前提是先把并发事务可能引发的问题全列出来。1.2 并发事务的四个经典问题谈到隔离级别绕不开的就是下面这四个问题。问题定义直观表现脏读Dirty Read读到其他事务未提交的数据事务A改了数据还没提交事务B就看到了A最后回滚B读到的数据是无效的不可重复读Non-Repeatable Read同一事务内同一条查询语句读到不同结果事务A两次查询同一条记录中间事务B修改并提交A第二次读到的值变了幻读Phantom Read同一事务内同一个范围查询返回了不同的行数事务A两次查询范围数据中间事务B插入了一条新记录并提交A第二次多读出一行丢失更新Lost Update多个事务基于同一版本的数据做更新后提交的覆盖先提交的A和B都读到余额100A改成90B也改成90A的更新被覆盖前三个问题主要发生在“读”的层面第四个发生在“写”的层面。标准SQL规范定义了四种隔离级别分别解决不同数量的问题——级别越高解决的问题越多但并发能力也越弱。实际使用中最常见的选择是在“读已提交”和“可重复读”之间做权衡这也是后面要重点讲的内容。2. MySQL的四种隔离级别概念、对比与底层实现2.1 四种隔离级别逐一拆解MySQL支持SQL标准定义的四种隔离级别InnoDB引擎下的行为表现和标准定义有一些细微差异先看总览表格。隔离级别脏读不可重复读幻读并发性能读未提交READ UNCOMMITTED可能可能可能最好读已提交READ COMMITTED不会可能可能较好可重复读REPEATABLE READ不会不会基本不会InnoDB实现一般串行化SERIALIZABLE不会不会不会最差读未提交事务中未提交的修改对其他事务可见。表面上并发能力最强实际上在真实业务里几乎没有使用场景因为读到未提交数据导致的错误判断远比并发带来的性能收益严重。我曾经在一个老项目里看到某段报表查询被设置成这个级别跑出来的数字经常对不上账查了半天才发现是隔离级别的问题。读已提交一个事务只能读到其他事务已提交的数据。Oracle的默认隔离级别就是它大部分互联网公司的生产环境在简化锁冲突之后也会设成它。但它解决不了不可重复读同一事务里前后两次相同查询可能因为其他事务的提交而读到不同结果。可重复读MySQL InnoDB的默认隔离级别。这个级别下事务在第一次读取时建立一个快照之后整个事务内读到的都是这个快照里的数据所以不仅避免了脏读也避免了不可重复读。至于幻读标准SQL定义里它仍然可能出现但InnoDB通过间隙锁把这个问题也基本堵死了这点后面单独展开讲。串行化最严格的隔离级别事务之间完全串行执行。InnoDB在这个级别下会对所有普通SELECT都加共享锁读和写互相阻塞并发能力断崖式下降。我在实际工作中只在两种场景见过它极少数强一致的核心账务操作以及临时做数据修复时为了绝对安全临时开启。2.2 InnoDB实现可重复读的钥匙MVCC可重复读之所以能做到靠的是MVCC多版本并发控制。它是InnoDB实现隔离级别的核心机制理解它能直接把面试里面的概念题变成原理题。简单说InnoDB在每行记录后面隐藏了几个列最关键的是DB_TRX_ID最近一次修改该行的事务ID和DB_ROLL_PTR回滚指针指向undo log里的旧版本数据。事务对数据做修改时不会直接覆盖旧值而是在undo log里保留一份旧版本再把新版本的行头指向旧版本。这样一来同一个数据行就存在多个历史版本像一条时间线。每个事务在开始读数据时会生成一个ReadView读视图里面记录了当前活跃事务的ID列表等信息。判断一行数据能不能被当前事务看到就用这行的DB_TRX_ID去和ReadView对比如果修改该行的事务ID在活跃列表里、或者比当前事务ID大说明这个版本是“未来”或“正在进行中”的应该去看更早的版本反之说明是已提交的历史版本可以显示。生活化一点理解ReadView相当于某个时刻拍下来的一张“照片”。可重复读级别下事务第一次读取时拍了一张照片之后整个事务内都反复看这张老照片所以不管别人提交了多少新数据你看到的永远是照片里的旧世界。读已提交级别下每次读取前都重新拍一张新照片所以你每次能看到的最新状态都不一样。这里要特别注意一个概念普通SELECT走的是快照读不加锁靠MVCC实现一致性读UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE这些语句走的是当前读读取最新已提交版本并加锁锁机制和MVCC配合使用。搞清楚这两类读的区别是理解后面幻读问题的关键。2.3 MySQL的RR到底存不存在幻读这是网上争论最多的问题。按SQL标准的定义可重复读级别下是允许幻读的但MySQL的InnoDB在可重复读级别下通过当前读的间隙锁机制把幻读问题也基本解决了所以表现上比标准更严格。具体怎么回事呢InnoDB在可重复读级别下执行当前读的时候不光对命中的记录加记录锁还会对查询条件覆盖的范围加间隙锁或临键锁next-key lock。比如执行SELECT * FROM orders WHERE amount 100 FOR UPDATE会把amount大于100的所有区间都锁住其他事务想在这个区间内插入新记录会被阻塞到当前事务结束。这就从源头上阻止了“新行出现”幻读自然也就不会发生。但是有两个场景仍可能出现幻读这也是我见过很多人踩坑的地方第一种事务先执行了普通快照读然后其他事务插入并提交了新数据此时事务再执行当前读因为当前读读的是最新版本可能把新插入的数据读进来。第二种在极低概率下如果插入的行落在间隙锁范围之外也会突破封锁。所以准确说法是InnoDB在默认隔离级别下绝大多数场景都能避免幻读但要说100%杜绝还得结合具体的SQL访问方式来分析。3. 动手实操在两个会话里把四种问题“做”出来3.1 准备工作确认环境与建表纸上谈兵永远不如亲手复现一遍。我建议你准备一台本地MySQL环境版本不限5.7以上都可以8.0更佳然后用两个客户端连接同一个实例模拟两个并发事务。先建一张最简单的测试表CREATE DATABASE IF NOT EXISTS isolation_demo DEFAULT CHARSETutf8mb4; USE isolation_demo; CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(20), balance DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO account VALUES (1, 张三, 1000.00), (2, 李四, 2000.00);再创建一张订单表方便演示范围查询和幻读USE isolation_demo; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(20), amount DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO orders (order_no, amount) VALUES (A001, 100.00), (A002, 200.00);开始之前先确认当前隔离级别。MySQL 8.0里用下面这三个语句查看SELECT global.transaction_isolation; SELECT session.transaction_isolation; SELECT transaction_isolation;MySQL 5.7及更早版本里变量名是tx_isolation用法一样。默认情况下这三个值都是REPEATABLE-READ。设置会话级隔离级别的语法SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;以“会话”为作用域设置只影响当前连接用GLOBAL关键字设置会作用于之后新建的连接但已经存在的连接不受影响。这一点很重要我后面还会提到。3.2 实操一脏读复现脏读只能在读未提交级别下发生。把两个会话的隔离级别都改成READ UNCOMMITTED。会话A执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; SELECT balance FROM account WHERE id 1;此时会话A能看到自己修改后的余额900但还没提交事务。接着到会话B执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT balance FROM account WHERE id 1;会话B会直接读到900而这笔修改此时并没有提交。回到会话A执行ROLLBACK余额回到1000会话B刚才读到的900就是一次标准的脏读。你再把两个会话切到READ COMMITTED重新走一遍同样的流程会话B在会话A未提交时查询读到的仍然是1000只有等A提交后才会看到新值。这一步能直观感受到“隔离”的作用边界。3.3 实操二不可重复读复现先把测试数据恢复成初始状态保证余额是1000。把两个会话设置成REPEATABLE READ。会话A开启事务后查询一次SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT balance FROM account WHERE id 1;结果自然是1000。此时会话B单独执行一条更新并提交UPDATE account SET balance 500 WHERE id 1; COMMIT;会话A再次执行相同的查询SELECT balance FROM account WHERE id 1;在REPEATABLE READ下结果仍然是1000。因为在事务第一次读取时A已经生成了ReadView后续所有快照读都基于这张老照片。然后我们把会话A改成READ COMMITTED重开一个事务再走一遍同样的流程第二次查询就会读出500这就是不可重复读。两个级别的对比在一组操作里看得清清楚楚一个看到的永远是“第一次读到的世界”一个看到的是“最近一次提交后的世界”。3.4 实操三幻读复现和间隙锁验证幻读复现比前两个稍微麻烦一点因为InnoDB默认级别下普通查询根本不出现幻读。所以我们先在两个会话都设置READ COMMITTED让幻读暴露出来。会话A开启事务执行范围查询SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM orders WHERE amount 100;结果只有A002。会话B插入一条新订单并提交INSERT INTO orders (order_no, amount) VALUES (A003, 300.00); COMMIT;会话A再次执行同样的查询这次会多出一条A003。同一事务内同一范围查询得到了不同的行数这就是幻读。接下来验证REPEATABLE READ下的间隙锁效果。把两个会话都改回REPEATABLE READ重新开事务会话A执行当前读加锁SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM orders WHERE amount 100 FOR UPDATE;然后在会话B尝试插入一条amount250的记录INSERT INTO orders (order_no, amount) VALUES (A004, 250.00);你会发现这条INSERT语句卡住了一直处于锁等待状态直到会话A执行COMMIT或ROLLBACK后才完成。这就是InnoDB的间隙锁在起作用——它把amount大于100的取值范围锁住从物理上阻止了新行插入幻读在绝大多数场景下就是这样被消灭的。4. 线上实战隔离级别如何选、怎么改、有哪些坑4.1 修改隔离级别的正确姿势工作中改隔离级别主要通过两种方式会话级设置和全局配置文件。临时修改比如排查问题时只影响当前会话SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;全局修改比如让实例里所有新连接都默认使用某个级别SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;注意SET GLOBAL只对之后新建的连接生效已经存在的连接包括你当前正在执行这条SQL的连接不会改变。很多线上环境改完之后发现不生效就是因为应用连接池里的连接都是早就建立好的必须重启应用或重建连接池才能让新配置生效。持久化配置在my.cnf或my.ini的[mysqld]段里加上一行transaction-isolation READ-COMMITTED然后重启MySQL服务。这是最稳妥的线上变更方式因为配置文件会在MySQL启动时统一读取不会出现会话级和全局级不一致的混乱。我自己在线上变更隔离级别时习惯的顺序是先在灰度实例上改my.cnf重启观察一段时间确认无误后再在集群其他节点重复操作。不建议直接用SET GLOBAL在生产环境里临时改因为一旦设置后遇到问题回滚的成本比改配置文件高得多。4.2 为什么MySQL默认RR但很多团队改成了RC这是很多人的困惑既然默认是REPEATABLE READ为什么很多团队一上来就改成READ COMMITTEDMySQL把默认级别设为REPEATABLE READ有历史原因核心和主从复制相关。早年间binlog的默认格式是STATEMENT也就是记录SQL原文。这种格式下如果主库用READ COMMITTED执行DELETE或UPDATE这类语句时两次执行因为并发环境不同可能在从库上产生不同的结果导致主从数据不一致。REPEATABLE READ配合间隙锁能让语句在主库和从库产生同样的锁定范围从而保证复制的一致性。MySQL 5.1.5之后支持了ROW格式的binlog才让READ COMMITTED在复制环境下变得安全。那为什么还有大量团队改成RC因为REPEATABLE READ的间隙锁在特定业务场景下会带来明显的性能代价。高并发插入场景下间隙锁容易造成锁范围过大引发频繁的锁等待和死锁而READ COMMITTED只加记录锁不加间隙锁并发度更高死锁概率也明显下降。很多中等规模的互联网业务在确认binlog已经是ROW格式后会选择改成RC来换取更高的吞吐量。如果你在考虑这个调整建议先确认三件事binlog是否已经设置为ROW格式业务对一致性读的需求是否允许同一事务内读到不同时间点的数据应用层是否有依赖可重复读语义的SQL逻辑。确认无误后再改改完要重点观察死锁和锁等待指标有问题马上回滚。4.3 我遇到过的高频问题与排查记录实战中最常见的几个问题我整理成了速查表。问题现象背后原因排查与处理一条UPDATE长时间阻塞目标行被其他事务锁定最常见是RR级别下间隙锁范围过大SHOW ENGINE INNODB STATUS查看锁等待结合当前事务列表kill掉长时间持有锁的事务死锁报错Deadlock found两个事务持有对方需要的锁等待形成环路分析死锁日志通常需要调整SQL执行顺序或缩窄事务范围长事务导致undo log膨胀事务长时间不提交MVCC历史版本无法清理查询information_schema.innodb_trx定位长事务尽早提交或kill应用连接池设置与全局不一致连接池可能在建立连接时指定了特定隔离级别排查连接池配置如HikariCP的connection-init-sql改了隔离级别后部分查询结果异常业务SQL对可重复读有隐性依赖在测试环境完整回归业务查询确认无依赖后再切换举一个我亲身经历过的例子某个订单系统压测时频繁报死锁日志里锁等待总是集中在同一张订单明细表上。排查下来发现隔离级别是REPEATABLE READ业务里有个批量UPDATE语句的WHERE条件是一个范围查询间隙锁把一段很大的区间锁住了另一个事务想在这个区间插入新订单两边互相等。当时没有直接改全局隔离级别而是先让这个批量操作改成先查主键ID再逐条更新缩小锁范围随后确认binlog是ROW格式才把整个业务的隔离级别切到READ COMMITTED。死锁直接清零吞吐量还涨了一截。这个经历让我后来养成了一个习惯遇到锁问题先不急着优化SQL而是把隔离级别、锁类型、SQL访问方式三者结合起来看通常才是真正的问题根源。5. 我的实操心得我带团队时经常让新人用两个终端把上面几个实操完整走一遍效果比背十遍面试题都强。我自己在这个过程里最大的感受是隔离级别不是一个孤立的知识点它和MVCC、锁、binlog复制、连接池配置是连在一起的任何一个环节理解不到位线上排查就会卡壳。现在被问到“MySQL为什么默认可重复读”我会先回答标准定义和InnoDB实现的差异再说历史原因和复制格式的关系最后补充一句“如果你的业务并发插入量很大且binlog已经是ROW格式考虑RC是有道理的”。面试官要的不只是结论而是你能不能讲出结论背后的权衡逻辑。最后分享一个小技巧在测试环境复现并发问题的时候给两个连接窗口设置不同的背景色或明显的标题防止操作搞混。每个演示做完都记得提交或回滚事务把数据恢复干净不然下一轮验证很容易被残留数据干扰。这些细节看上去不起眼却能省掉大量排查时间。