先说我自己的经历。前阵子一个订单系统出了个诡异的线上问题两个支付请求几乎同时打进来一个用户在页面上看到的价格和最终记账金额差了十几块钱。我第一反应是SQL写错了翻了一圈代码没找到问题后来用两个终端窗口把事务场景复现了一遍这才发现根源在事务隔离级别的配置上——多个事务同时操作同一批数据彼此之间能看到对方什么状态这个规则没搞对代码写得再漂亮也没用。这篇文章不谈抽象的大道理就把MySQL事务隔离级别这件事从头到尾说清楚它到底在解决什么问题、四种隔离级别各自有什么特性、InnoDB底层是靠什么实现的、以及你在实际项目里怎么配置和排查。刚接触事务的开发者可以把它当入门资料看用了很多年RR隔离级别但始终说不清原理的老手也能在这里找到一些被忽略的细节。1. 先看三个并发问题再谈隔离级别1.1 ACID里的“隔离性”到底在管什么事务有四个核心特性原子性、一致性、隔离性、持久性也就是常说的ACID。前三个都好理解原子性保证一个事务要么全做要么全不做一致性保证数据从一种合法状态流转到另一种合法状态持久性保证提交后不会丢。唯独“隔离性”最微妙因为它不是绝对的而是分等级的。隔离性说的是多个事务同时执行时彼此之间不能互相干扰。但完全互不干扰理想吗理想。现实吗不现实。如果每个事务都严格串行执行数据绝对不会乱但并发性能会跌到惨不忍睹。所以数据库在一致性和性能之间做了一系列妥协每一个妥协档位就是一种隔离级别。你配置的隔离级别本质上就是你愿意接受多大程度的数据错乱来换取多高的并发处理能力。很多开发者在面试时能脱口而出四种隔离级别但到了实际调优时往往一脸懵就是因为没把“隔离性不是绝对的”这句话刻在脑子里。隔离级别不是越高越好也不是越低越快它是你在业务一致性需求和数据库吞吐量之间做的取舍。理解了这一点后面所有内容都顺了。1.2 脏读、不可重复读、幻读每个都要用一个能复现的例子说清楚先准备一张很简单的订单表模拟日常业务CREATE TABLE orders ( id int NOT NULL, status varchar(20) DEFAULT NULL, amount decimal(10,2) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO orders VALUES (1, 待支付, 100.00); INSERT INTO orders VALUES (2, 待支付, 200.00); INSERT INTO orders VALUES (3, 已支付, 300.00);基于这张表我把三个并发问题一个个演给你看。脏读Dirty Read事务A修改了一条数据但还没提交事务B这时候读到了修改后的值。后来事务A回滚了事务B刚才读到的就是个不存在的“脏数据”。具体过程是这样-- 事务A START TRANSACTION; UPDATE orders SET amount 50.00 WHERE id 1; -- 此时不提交事务B去查询 -- 事务B START TRANSACTION; SELECT amount FROM orders WHERE id 1; -- 读到 50.00但事务A尚未提交这个值随时可能消失我把脏读理解成“你听了一个没定论的传言就当成了事实”。日常业务里脏读非常危险比如统计报表读到一笔还没确认的退款就会产生错误的汇总数据。不可重复读Non-Repeatable Read同一个事务内两次相同的查询返回了不同的内容。事务A第一次查询时金额是100事务B把金额改成了200并提交事务A在同一个事务里第二次查询金额变成了200。前后不一致而且第二次读到的确实是别人已提交的合法数据没有脏读的问题但对你这个事务而言两次查询结果对不上。-- 事务A START TRANSACTION; SELECT amount FROM orders WHERE id 1; -- 第一次读100.00 -- 事务B同时执行 START TRANSACTION; UPDATE orders SET amount 200.00 WHERE id 1; COMMIT; -- 事务A再次查询 SELECT amount FROM orders WHERE id 1; -- 第二次读200.00这个问题的核心在于“同一个事务内的快照不稳定”。想象你在写一份报告第一次查了一个参考数据写到一半再查一次发现参考数据变了整份报告的论证逻辑就乱了。幻读Phantom Read同一个事务内两次相同的查询返回的记录数量不同。事务A第一次查询“待支付”状态的订单有2条事务B插入了一条新的“待支付”订单并提交事务A第二次查询变成了3条。多出来的那条记录就是“幻影行”。-- 事务A START TRANSACTION; SELECT * FROM orders WHERE status 待支付; -- 2条记录 -- 事务B同时执行 START TRANSACTION; INSERT INTO orders VALUES (4, 待支付, 400.00); COMMIT; -- 事务A再次查询 SELECT * FROM orders WHERE status 待支付; -- 3条记录幻读和不可重复读的区别很多人容易搞混。我用一句话区分不可重复读是同一行数据的“内容变了”幻读是整个查询结果集的“行数变了”。前者是修改问题后者是插入或删除问题。这三个问题就是理解隔离级别的钥匙。四种隔离级别本质上就是看你怎么处理这三种“读异常”。2. 四种隔离级别逐个拆解每个级别的特性、代价与适用场景2.1 读未提交能读到你没提交的数据READ UNCOMMITTED中文叫读未提交。这个级别下一个事务可以读到另一个事务未提交的数据也就是说脏读、不可重复读、幻读全都不设防。它连最基本的“数据稳定性”都不保证读到的内容完全取决于其他事务当时的执行状态。这个级别几乎不会在生产环境使用因为它的性能优势并没有比下一个级别高多少但数据正确性的代价高到离谱。偶尔会有人把它用在一些“只求速度、结果错了也无所谓”的场景比如统计一个不需要精确的在线人数、海量日志的粗略扫描。但说实话我见过真正在生产环境配置READ UNCOMMITTED的项目屈指可数绝大多数情况下的正确选择是从后三个级别里挑。值得一提的是即便在这个最宽松的级别下InnoDB的更新操作依然要加行锁否则连数据写入都会乱套。更多的锁细节后面讲这里你先记住一个结论隔离级别主要控制的是“读”的规则“写”的逻辑在InnoDB里始终是严格串行化的。2.2 读已提交每次读都是最新的已提交数据READ COMMITTED中文叫读已提交。这个级别解决了脏读问题——你只能读到已经提交的数据没提交的修改对你不可见。但它有一个明显短板不可重复读依然存在幻读也依然存在。很多从Oracle转过来的人会对这个级别特别亲切因为Oracle默认的隔离级别就是READ COMMITTED。它非常适合一套系统里“每次查询都希望能看到最新提交结果”的业务典型场景比如交易状态查询、库存余量显示。用户刷新一次页面就想看到最新的数据这个级别正好满足。READ COMMITTED有一个重要的实现特点事务里的每次普通SELECT都会生成一个新的快照来读取数据所以你在同一个事务里两次查询永远看到的是“上次查询时点最新已提交的数据”。这个特征在对比可重复读时非常关键后面实操篇我还会演示。很多高并发互联网团队会主动把隔离级别从默认的RR改成RC很大一部分原因是RC下InnoDB不需要维护那么多间隙锁死锁概率显著降低。代价就是你要接受事务内结果集“前后不一致”。2.3 可重复读MySQL默认级别的底气和隐患REPEATABLE READ中文叫可重复读。这是MySQL默认的隔离级别也是很多人“用了一辈子但没想过为什么”的配置。可重复读保证同一事务内你第一次执行普通SELECT之后后续所有普通SELECT读到的都是同一份快照不管其他事务在这期间提交了多少次修改你看到的数据始终不变。这就把不可重复读的问题解决掉了。那幻读呢在标准SQL的定义里RR级别是不解决幻读的但MySQL的InnoDB工程实现把这个“标准缺陷”补上了通过MVCC让快照读不再看到其他事务后来插入的数据。不过这事有前提也不能一概而论在3.3节我会单独讲清楚这里先不展开。MySQL为什么要把默认级别设为RR除了业务一致性考量还有一个历史原因早期的binlog基于statement格式复制在RC级别下同一个SQL在主库和从库执行时的数据上下文可能不同导致主从数据不一致而RR级别在绝大多数场景下可以规避这个问题。所以你可以这么理解RR是MySQL在综合考虑一致性和复制安全之后给出的默认答案。RR的隐患也很明确因为要实现“可重复”InnoDB需要在索引记录之间维护更多的间隙锁高并发写入场景下锁竞争加剧死锁概率变高。这个问题在4.3节我会用实际案例讲。2.4 串行化牺牲并发换来的绝对一致SERIALIZABLE中文叫串行化。这是隔离级别的天花板所有事务按顺序一个接一个执行彻底杜绝脏读、不可重复读、幻读代价是并发能力几乎归零。在这个级别下普通的SELECT也会被加上共享锁其他事务的更新操作会被阻塞整个数据库的并发处理能力被降到了最低点。生产环境很少会用这个级别更常见的做法是在应用层用悲观锁、分布式锁控制关键路径而不是把整个库拖进串行化的泥潭。如果某个操作真的强一致到必须串行我通常会优先选择在SQL语句层面处理比如用SELECT ... FOR UPDATE锁住必要的行或者引入分布式锁这比把全局隔离级别调到SERIALIZABLE灵活得多影响面也小得多。2.5 隔离级别对照表问题、性能、一致性一目了然四种隔离级别对付三个并发问题的能力我直接整理成表格隔离级别脏读不可重复读幻读并发性能READ UNCOMMITTED可能发生可能发生可能发生最高READ COMMITTED解决可能发生可能发生较高REPEATABLE READ解决解决大体解决有边界中等SERIALIZABLE解决解决解决最低从这张表可以看出隔离级别阶梯式地解决数据一致性问题也阶梯式地消耗并发性能。大多数生产系统在RC和RR之间选择不是没道理的。至于具体业务应该选什么我的建议写在最后一段这里先把“每种级别到底怎么实现的”这个问题搞清楚因为只有懂了原理配置时才有底气。3. InnoDB的事务隔离实现MVCC与锁的配合3.1 快照读和当前读先分清这两种读要理解InnoDB的隔离级别实现第一件事就是分清两种读模式快照读和当前读。快照读就是普通的SELECT语句它读的是历史版本数据不加锁。当前读则是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些操作它们必须读取最新版本的已提交数据并且对涉及的行加锁。为什么必须区分因为MySQL的RR隔离级别能“大体解决”幻读靠的就是这两套机制各管一摊快照读用MVCC保证你始终看到一致的历史快照当前读用间隙锁阻止其他事务往你的锁定范围里插入新数据。两者缺一不可。我举个容易翻车的例子。假设你在RR级别下开了一个事务先用普通SELECT查询“待支付”订单查到2条。与此同时另一个事务插入了一条新订单并提交。你在同一个事务里再次执行普通SELECT仍然只会看到2条这就是MVCC快照的功劳。但如果你第二次执行的是SELECT ... FOR UPDATE那它读取的是最新已提交数据大概率能查到3条而且这个过程中如果另一个事务还没提交插入操作它会被阻塞。这也就是我在2.3节卖关子的地方——RR不是物理上让幻读消失而是通过两种手段分别把快照读和当前读路径上的幻读概率压到了最低。很多线上事故的根源就是开发者没分清这两条路径以为RR就万事大吉结果在同一个事务里快照读和当前读混着用查出来的结果集数量不一致一通排查才发现是自己对隔离级别的理解有盲区。3.2 MVCC的版本链与Read View可见性判断MVCC全称是Multi-Version Concurrency Control多版本并发控制。它的核心思路是数据库里一行数据不只有一个版本每次被修改都会在undo log里留下一个旧版本记录新版本和旧版本通过版本链串起来形成一个链表。我在实际调试时喜欢把版本链想象成一行数据的历史档案袋当前值是档案袋最上面那一页之前的每一次修改都被压在里面。当一个事务发起快照读时它不是直接拿当前值而是根据一条“可见性规则”从这个版本链里挑一个它应当看得见的版本。这条规则就是Read View翻译成大家都懂的说法叫“读取视图”。生成Read View时InnoDB会记录下当前系统里活跃事务的ID列表等关键信息。判断一个数据版本是否可见大致按下面几步来如果版本的创建事务ID等于当前事务ID说明这个版本是自己改的必须可见。如果版本的创建事务ID小于Read View里最小的活跃事务ID说明这个版本在快照生成前就已经提交了可见。如果版本的创建事务ID大于或等于Read View里下一个待分配的事务ID说明这个版本是在快照生成之后才产生的不可见。如果版本的创建事务ID落在活跃事务列表区间内就去查它是否还在活跃事务列表里如果在说明还没提交不可见如果不在说明已经提交可见。READ COMMITTED和REPEATABLE READ之间的核心差异就在生成Read View的时机RC级别每次执行快照读都会生成一个新的Read View所以能读到别的事务最新提交的数据RR级别只在事务里第一次执行查询时生成Read View后续所有查询都复用这一个视图所以整个事务期间看到的数据都保持一致。我个人觉得把这一条彻底想明白比死记硬背概念有用得多。3.3 间隙锁与Next-Key LockRR级别为什么能挡住“幻觉行”MVCC解决了快照读路径上的幻读问题但当前读路径上还有漏洞。比如事务A执行SELECT ... FOR UPDATE锁定了一批记录事务B如果往这批记录的空隙里插入一条新数据就会破坏事务A的锁定语义。InnoDB的解决方案是间隙锁Gap Lock和它升级版的伴生锁Next-Key Lock。间隙锁锁的不是某一行而是两个索引记录之间的间隙确保这个范围内不能插入新的记录。Next-Key Lock则是记录锁和间隙锁的合体它既锁住记录本身也锁住记录前面的间隙这就堵死了“往锁定范围内插新数据”这条路。举一个非常典型的例子-- 事务A START TRANSACTION; SELECT * FROM orders WHERE id BETWEEN 1 AND 5 FOR UPDATE;如果表里现有id为1、2、3的记录这条语句在RR级别下不仅会锁住这三行还会把id3和id5之间的间隙也锁上。此时事务B想插入id4的记录就会被阻塞直到事务A提交或回滚。在RC级别下同样这条语句只锁已存在的三行记录间隙不设防id4的插入可以顺利进行。这也解释了为什么RR级别下删除、更新操作要尽量走索引。如果更新条件没有合适的索引InnoDB会对聚簇索引上的所有记录都加上Next-Key Lock相当于把整张表锁住并发性能瞬间塌方。在线上环境我见过不止一次因为UPDATE语句没走索引RR级别下直接把一张热点表锁出告警的情况。4. 实操篇查看、设置与常见问题排查4.1 查看与修改隔离级别动手之前先知道你的MySQL当前处于什么隔离级别。MySQL 5.7及以下用tx_isolation变量MySQL 8.0改名为transaction_isolation执行下面的SQL就能看到-- MySQL 8.0 SELECT transaction_isolation; -- MySQL 5.7及以下 SELECT tx_isolation;设置隔离级别有几种粒度我建议你区分清楚再动手。第一种是全局设置影响之后新建的所有连接SET GLOBAL transaction_isolation READ-COMMITTED;第二种是会话设置只影响当前连接SET SESSION transaction_isolation READ-COMMITTED;第三种是在事务内临时指定只对当前事务生效SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;注意MySQL的参数值写法中间是连字符READ-UNCOMMITTED、READ-COMMITTED、REPEATABLE-READ、SERIALIZABLE不是下划线。我最初调试时在这里踩过坑写成了下划线MySQL直接报语法错误。还有一个容易误判的点SET GLOBAL只对新连接生效已经存在的连接池连接不会自动刷新。你改完全局配置发现应用还是老行为十有八九是因为连接池里存的还是旧会话。4.2 用两个会话复现隔离级别差异理论知识说再多不如自己动手跑一遍。我建议你开两个MySQL终端窗口分别代表事务A和事务B按下面的步骤操作能直观看到不同隔离级别下的表现差异。先把两个会话都设为同一个隔离级别这里以READ COMMITTED为例-- 两个会话都执行 SET SESSION transaction_isolation READ-COMMITTED;窗口A执行START TRANSACTION; SELECT amount FROM orders WHERE id 1;此时窗口B执行START TRANSACTION; UPDATE orders SET amount 250.00 WHERE id 1; COMMIT;回到窗口A再执行一次查询SELECT amount FROM orders WHERE id 1;你会发现第二次查到的金额变了这就是READ COMMITTED下不可重复读的直观体现。然后你把两个会话都改成REPEATABLE READ重新执行一模一样的步骤第二次查询得到的还是第一次查到的值这就是RR的快照复用效果。这个实验我强烈建议你亲手做一遍因为它能帮你把“隔离级别配置”和“事务内查询结果变化”之间的关系建立成直觉。以后遇到数据不一致问题第一反应就是去验证隔离级别和事务边界而不是瞎改代码。4.3 我踩过的几个隔离级别相关的坑第一个坑连接池连接复用导致“隔离级别不生效”。我曾经把全局隔离级别改成RC应用层代码确认没设置过会话级别但线上查询行为还是RR。查了半天最后一想连接池在初始化时建立了一堆连接这些连接的隔离级别还是老值应用重启后才统一生效。所以改完配置记得滚动重启应用或者让连接池主动刷新连接。第二个坑RR级别下高并发更新导致死锁。有一回压测一个积分兑换接口并发一上来就频繁报死锁错误。排查的时候先SHOW ENGINE INNODB STATUS\G在输出里找到LATEST DETECTED DEADLOCK这段能清楚看到两个事务各自持有哪把锁、又在等待哪把锁。根因是两个事务以不同顺序更新同一批商家记录RR下间隙锁互相咬死。后来统一了更新顺序并把隔离级别降到RC死锁基本消失。第三个坑长事务把undo log撑爆。MVCC要求数据版本链保留老版本以便RR级别下的老快照还能读取。如果一个事务长时间不提交它持有的Read View会阻止InnoDB清理旧版本数据undo log会一直膨胀。我在一个报表系统里见过某条定时任务的事务跑了半个多小时直接把undo表空间撑到几十GB。解决办法是拆分长事务尽量做到事务短平快提交完毕再处理其他逻辑。第四个坑隔离级别和主从复制格式的配合。RC级别虽然香但binlog如果是statement格式主从复制有可能因为执行上下文不同产生不一致。我自己的惯例是使用RC时把binlog_format设置为ROW或MIXED这样复制安全性和排查粒度都有保障。再补充一个排查技巧如果怀疑某个SQL执行时的锁等待太久可以直接看performance_schema.data_lock_waits表它能反映当前锁等待关系比死等应用超时日志高效得多。这个表在MySQL 8.0里很好用值得列入你的排查工具箱。隔离级别不是越高越保险也不是越低越快。它本质上是一次取舍而取舍的前提是你能看清每种级别背后的行为边界和实现代价。MySQL默认给你REPEATABLE READ是有历史渊源的多数场景下不用动它但如果你业务并发上来了被间隙锁折磨得苦不堪言业务又能接受“每次查询看到最新已提交数据”那转到READ COMMITTED也是完全合理的方案。关键不在于抄别人用什么而是你自己能判断出问题出在锁、快照还是事务边界。把这套逻辑吃透下次再遇到诡异的数据不一致你就不会急着改配置了而是会自然地去看版本链、锁等待和事务边界。