
MySQL的事务隔离级别我直接说结论这是一个值得每个人都花时间亲手验证一遍的知识点而不是靠背八股就能真正掌握的。我见过太多简历上写着熟悉MySQL事务隔离级别的人一问到实际案例就支支吾吾——脏读和不可重复读分不清幻读到底谁负责解决也说不明白。也见过线上因为隔离级别配置不当导致对账数据漂移、库存扣成负数、定时任务结果忽多忽少这类真实事故。这篇文章不打算按教科书顺序罗列概念我直接带你用两个MySQL会话去复现这三种并发问题然后顺着现象把每种隔离级别的行为、底层实现、实战选型全部串起来。无论你是刚入门数据库的开发还是已经需要维护生产环境的同学跟着做一遍你会得到一个非常清晰的坐标系隔离级别是怎么控制读什么和锁什么的。1. 事务隔离级别到底在管哪件事1.1 从ACID里最特殊的那一个说起事务有四大特性原子性、一致性、隔离性、持久性。原子性保证一个事务要么全做要么全不做一致性保证业务规则不被破坏持久性保证提交之后数据不丢。这三个都是硬指标要么满足要么不满足没有中间态。唯独隔离性是特殊的它不是一个非黑即白的开关而是一根可以上下滑动的档杆。为什么需要档杆因为多个事务同时跑的时候彼此之间到底允许看到多少对方的中间状态这是一个程度问题要求严并发就低性能就差要求松并发就高但可能要容忍读到奇怪的数据。事务隔离级别就是我们在数据一致性和并发性能之间拉出来的那条基准线。我经常用一个比喻几个人同时去接一桶水如果完全不管每个人的杯子都会互相溅到水如果要求排队绝对干净但非常慢。隔离级别就是大家怎么抢水的规则。MySQL提供了四档从基本不管到一个个来全都有。1.2 三种并发问题脏读、不可重复读、幻读要理解隔离级别先得搞清楚它要防的是哪几种脏数据现象。第一种是脏读。事务A读到了事务B还没有提交的修改而事务B后面回滚了。A拿着这个值去做业务判断等于踩在了一块冰面上冰随时会碎。第二种是不可重复读。同一个事务里前后两条一样的SELECT语句读出来的值不一样。原因是中间有另一个事务修改并提交了这条记录。注意这里针对的是已有记录的值发生了变化是UPDATE造成的。第三种是幻读。同一个事务里前后两条一样的查询结果集的行数不一样了。原因是中间有另一个事务插入了新行并提交。幻读针对的是结果集里冒出新的行是INSERT造成的。很多人把不可重复读和幻读混为一谈这是致命的。一个改值一个增行对应的解决方案完全不同——前者靠的是MVCC的快照机制后者靠的是锁尤其是InnoDB的间隙锁和Next-Key Lock。这个区别后面会重点展开。1.3 一张表看懂隔离级别的档位关系SQL标准定义了四档隔离级别对应三种并发问题的处置能力如下隔离级别脏读不可重复读幻读READ UNCOMMITTED读未提交可能可能可能READ COMMITTED读已提交不会可能可能REPEATABLE READ可重复读不会不会可能InnoDB可避免SERIALIZABLE串行化不会不会不会这是标准SQL的定义。但我要特别提醒一句MySQL InnoDB引擎下的REPEATABLE READ实际表现比标准SQL定义要强它通过MVCC和Next-Key Lock把幻读也基本堵死了。这一点很多资料讲得模糊实际操作中特别容易让人困惑下文会专门验证。2. 四种隔离级别逐个拆开讲2.1 READ UNCOMMITTED裸奔的读级别最低隔离性最弱。在这个级别下一个事务还没提交的修改其他事务就能直接读到。它不对读操作做任何锁定也不做版本隔离性能理论上是最好的但脏读、不可重复读、幻读全部可能出现。说实话我在生产环境里几乎没见人用这个级别。它唯一合理的场景可能是明确知道读到别人改了一半的数据也无所谓比如某些非核心的监控探测、数据采样丢了准确性也不会造成业务事故。但这类场景实在太少了正常人都会觉得不踏实所以我给的建议很直接了解原理就行别在生产上用。2.2 READ COMMITTED每次读都重新看这个级别的规则是事务内只能读到其他事务已提交的数据未提交的改动一概不可见。脏读被堵住了但又多了一个新现象——不可重复读。为什么因为每次执行SELECT都会基于当前这一刻生成一个新的快照。其他事务提交了新值下一次SELECT就会读到新值。同一个事务里二十五秒前的SELECT和二十五秒后的SELECT看到的东西可能完全不同。对该不该用它业界吵得不少。很多互联网业务最终会选择READ COMMITTED原因后面选型章节会细说核心其实就是并发度和锁范围更友好。但如果你要跑定时统计、对账、汇总这类对一致性敏感的SQLRC的每次都看最新提交值会让统计结果产生跳跃感需要提前评估。2.3 REPEATABLE READ事务内看固定快照REPEATABLE READ可重复读MySQL InnoDB的默认隔离级别。核心行为是事务开始后第一次SELECT时会生成一个快照之后整个事务内所有普通的SELECT都基于这一个快照来读。其他事务提交了修改没关系我看的还是我那个旧快照。这就从机制上解决了不可重复读。那幻读呢标准SQL认为RR级别下幻读可能发生。但InnoDB的实际表现是普通快照读不带FOR UPDATE的SELECT通过MVCC直接看不到别的事务新插入的行所以幻读不会出现。当前读SELECT FOR UPDATE、UPDATE、DELETE通过Next-Key Lock把相关范围锁住别的事务想往这个范围里插新行会被阻塞所以幻读也被挡住了。所以实践上InnoDB的RR是超规格完成的——它连标准SQL允许存在的幻读都基本解决了。这也是MySQL的RR和你去读Oracle、PostgreSQL的RR定义时感觉对不上的根本原因。2.4 SERIALIZABLE把并发退化成排队最高隔离级别最强一致性但也最慢。在这个级别下所有读操作都会加共享锁写操作加排他锁事务与事务之间基本就是完全排队执行。连普通的SELECT都可能因为别人的锁而阻塞并发度断崖式下降。它适合什么样的场景一致性要求极高、并发量极低的场景比如某些资金清算、参数配置表数据量小、读写都不频繁用SERIALIZABLE图个省心。如果某张表的QPS很高千万别碰这一档不然你的数据库会直接变成一条单车道后面的车全部堵死。3. 手把手实操把三种并发问题一个个复现理论说完了下面进入我最喜欢的环节——用两个MySQL会话亲手把脏读、不可重复读、幻读都给我变出来。眼见为实比十张理论图都管用。3.1 准备环境建表、插入数据、确认隔离级别先建一张最简单的账户表用于演示。CREATE DATABASE IF NOT EXISTS isolation_demo; USE isolation_demo; DROP TABLE IF EXISTS account; CREATE TABLE account ( id INT NOT NULL AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL, balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account (user_name, balance) VALUES (张三, 1000.00), (李四, 2000.00);打开两个MySQL终端窗口左边叫会话A右边叫会话B。先确认一下当前隔离级别MySQL 8.0用这个SELECT transaction_isolation;默认结果一般就是REPEATABLE-READ。3.2 复现脏读READ UNCOMMITTED的翻车现场会话A把当前会话的隔离级别调到最低的READ UNCOMMITTED并开启事务SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1;此时张三的余额是1000.00。会话B也开启一个事务但只做更新不提交START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1;此刻事务B还没COMMIT数据在理论上是不应该对外可见的。但因为我们把会话A设成了READ UNCOMMITTED会话A此刻再查一次SELECT balance FROM account WHERE id 1;结果直接变成了900.00。这个900就是脏读——它来自一个还没提交的事务。紧接着会话B执行ROLLBACKROLLBACK;会话A再查又变回1000.00。你刚看到的900彻底蒸发。如果应用拿这个900去发了货、扣了库存那就是一次完整的事故。脏读就是这么回事。3.3 复现不可重复读READ COMMITTED下的前后反差先把数据复位UPDATE account SET balance 1000.00 WHERE id 1;会话A把隔离级别切换到READ COMMITTED开启事务查一次SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1;读到1000.00。会话B执行一个独立的更新事务并提交START TRANSACTION; UPDATE account SET balance balance 500 WHERE id 1; COMMIT;现在会话A在同一个未结束的事务里再次执行一模一样的SELECTSELECT balance FROM account WHERE id 1;结果变成了1500.00。同一事务里同一条SQL前后两次读到的值不一样——这就是不可重复读。在READ COMMITTED下每次SELECT都基于最新已提交数据生成新快照所以B一提交A的下一秒查询就会立刻看到新值。3.4 复现幻读REPEATABLE READ下的一次深入测试先把数据复位同时确保会话A切回REPEATABLE READUPDATE account SET balance 1000.00 WHERE id 1; DELETE FROM account WHERE id 2; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT COUNT(*) FROM account WHERE balance 1000;此时有两行计数是2。会话B插入一条新记录并提交START TRANSACTION; INSERT INTO account (user_name, balance) VALUES (王五, 3000.00); COMMIT;会话A再执行一遍一模一样的查询SELECT COUNT(*) FROM account WHERE balance 1000;计数还是2。因为这是快照读基于事务开始时的版本看不到新插入的行。这说明在InnoDB的RR下普通的SELECT确实不会出现幻读。但接下来是关键。会话A在RR下执行一个当前读SELECT * FROM account WHERE id 1 FOR UPDATE;先看它能读到多少行然后在新开的会话C里执行插入动作START TRANSACTION; INSERT INTO account (user_name, balance) VALUES (赵六, 4000.00);你会发现这条INSERT卡住了一直停在执行状态下。为什么因为会话A的FOR UPDATE已经通过Next-Key Lock把id 1这个范围锁死了包含未来满足条件的空隙新行插不进去只能等待。等会话A执行COMMIT之后会话C的插入才会瞬间成功。这就证明了一个结论InnoDB的REPEATABLE READ不仅让快照读看不到幻读还能通过锁让当前读也阻止幻读的产生。3.5 隔离级别切换命令速查实操过程中你需要用到的命令我整理成一个速查清单操作项命令查看会话级隔离级别SELECT transaction_isolation;查看全局隔离级别SELECT global.transaction_isolation;设置会话级隔离级别SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;设置全局隔离级别SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;开启事务START TRANSACTION;提交事务COMMIT;回滚事务ROLLBACK;注意SET GLOBAL只对新建立的会话生效已经存在的旧会话不会受影响。生产环境直接改全局值要格外谨慎后面选型章节会再强调。4. 底层原理MVCC和锁怎么配合工作理解了现象你多半会问MySQL是怎么做到可重复读的为什么不同级别下的读会有这么大差别这就要聊到InnoDB的两套核心机制——MVCC和锁。4.1 MVCC同一行数据保留多个版本MVCC多版本并发控制。名字听着高大上思路其实很朴素同一行数据在内存里不是一个孤立的值而是一个版本链。当某个事务修改一行数据时InnoDB不会直接覆盖旧值而是先把旧值连同相关信息写进undo log然后更新数据行并在行上记录这次修改的事务ID。每个数据行还有隐藏字段比如trx_id、roll_pointerroll_pointer指向undo log里上一版本的地址。这样一行数据就变成了一条由新旧版本串起来的链。你可以把这条版本链理解成一篇多人协作的在线文档。作者修改内容时不会把你正在看的那个版本原地涂掉而是基于上一版再生成一版你按自己的浏览位置仍然可以看到打开文档那一刻的内容。读一个事务在执行SELECT时Read View就决定了它站在版本链的哪个位置看数据。4.2 Read View的判定规则Read View是InnoDB在某个时刻生成的快照视图里面记录了生成时刻的关键信息比如正在活跃的事务ID列表、最小活跃事务ID、下一个要分配的事务ID等。一行数据要不要对当前事务可见核心看行上的事务ID与Read View的关系。简化的判定规则是这样如果数据的修改事务ID小于Read View里所有活跃事务的最小ID说明修改发生在快照生成前已经提交可见如果修改事务ID落在活跃事务列表里说明对方还没提交不可见要顺着undo链继续往上找更早的版本如果修改事务ID大于快照的下一个分配ID说明修改发生在快照之后也不可见。关键在于Read View的生成时机在READ COMMITTED下每一次SELECT都重新生成一个Read View所以总能读到最新已提交的数据于是有了不可重复读。在REPEATABLE READ下Read View只在事务第一次SELECT时生成之后整个事务复用同一个。别人的修改即使提交了也一律不认于是可重复读就有了保障。这个时机差异就是从RC到RR的分水岭。4.3 快照读与当前读MVCC解决的是读哪一版的问题但还有另一条线就是读操作怎么加锁。InnoDB把读分成了两类快照读就是普通的SELECT语句不加任何锁走MVCC直接读版本链里的某个快照版本。RC下的快照是每条SELECT临时生成RR下的快照是事务内复用同一个。当前读指的是SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE这类操作。它们必须读最新已提交的数据所以不能靠快照要直接走真实的当前数据并且一定要加锁。为什么当前读必须加锁因为要防止别人在你读之后、你改之前把数据改了。比如update先锁定目标行再读、再改、再释放。理解快照读和当前读的差异特别重要因为很多线上诡异现象都源于此用普通SELECT看数据一切正常一旦用FOR UPDATE去查却发现结果和SELECT不一样。这就是因为一个走MVCC快照一个走最新数据加锁。4.4 Next-Key Lock把新插入的行也锁死当前读为了防止幻读就不能只锁已经存在的数据行还得防住未来可能插进来的行。这就要讲到InnoDB针对索引的三种锁Record Lock记录锁只锁某一条索引记录Gap Lock间隙锁锁一段区间禁止其他事务在这个区间里插入新记录但区间里本身没有实际数据Next-Key Lock记录锁加间隙锁的组合体锁的是左开右闭的区间比如(2, 6]。回到前面的实操演示。会话A执行SELECT * FROM account WHERE id 1 FOR UPDATE时InnoDB不仅锁住了id1、id2这两条记录还把它们之间的空隙以及右边直到正无穷的区间也上了间隙锁。会话C企图插入id3的新行实际上就是想往这个被锁的区间里插自然被挡住只能乖乖等待。这就是Next-Key Lock防止幻读的机制不仅锁已有行还把有人趁你读的间隙往里塞新行这条路堵死。顺便说一个很多人关心的点为什么READ COMMITTED下InnoDB不加间隙锁因为RC允许不可重复读对结果集可能变化持容忍态度没必要付出间隙锁带来的巨大锁冲突成本。所以RC的加锁范围小、并发能力高这也是它被互联网业务偏爱的原因之一。5. 隔离级别选型不是默认值就永远正确5.1 为什么MySQL默认是REPEATABLE READ一个很常见的问题Oracle、PostgreSQL默认的是READ COMMITTED怎么MySQL偏偏默认REPEATABLE READ这背后有历史包袱。很多年前主流主从复制用的binlog格式还是STATEMENT也就是记录SQL语句本身。在这种格式下如果主库使用READ COMMITTED复制到备库执行同样的SQL因为备库的数据状态和锁行为存在差异可能产生主从不一致的数据结果。REPEATABLE READ配合Next-Key Lock能把事务执行的语义锁得更牢避免这类问题。MySQL为了兼容这种场景就把默认隔离级别设成了REPEATABLE READ。后来binlog的ROW格式逐渐成为主流它能记录每行数据变更前后的内容对隔离级别的依赖大幅降低。这也是很多团队敢于把隔离级别切到READ COMMITTED的现实基础。5.2 不同业务场景怎么选这么多年实践下来我的选型心得大致是这样对资金类、库存、秒杀这类对一致性要求极高的业务尽量保留REPEATABLE READ。它给当前读提供的Next-Key Lock能把幻读挡在外面业务逻辑写起来会省心非常多。有些极限场景甚至直接上SERIALIZABLE不过这种情况极少因为并发量承受不住。对高并发互联网业务如果你的数据模型简单、主键定位准确READ COMMITTED是常见选择。它的锁冲突更少配合应用层的幂等、补偿和重试机制整体并发能力更优。但前提是你得清楚它允许不可重复读别在同一个事务里做查一次、统计一次、再查一次的操作。对定时统计、报表、对账这类批任务不建议用SERIALIZABLE会造成大量锁等待。如果业务接受跑批期间看到的是事务开始时的快照RR反而是最优解因为它的快照读不锁数据跑批可以读取一个一致的历史视图。5.3 切换隔离级别的正确姿势我不会建议你在生产环境上直接执行SET GLOBAL TRANSACTION ISOLATION LEVEL ...因为它只影响新会话已有长连接仍然保持旧级别。这种新旧混杂的状态在排查问题时容易把人绕晕。更稳妥的方式是改配置文件统一重启或者至少要在变更窗口里分批次把旧连接释放掉。而且切换之后要重点观察两个指标锁等待耗时以及死锁频率。RR到RC切换后锁范围变小死锁理论上会变少但如果业务SQL本身写得有问题反而可能暴露出更多并发写冲突。还有一点别在事务中途切换隔离级别。事务一旦开始隔离级别就被固定住了。会话级设置要在START TRANSACTION之前做。6. 常见问题与排查技巧实录6.1 老是搞混的变量名transaction_isolation还是tx_isolation这是版本差异最容易坑人的地方。MySQL 5.7及更早的版本查看会话变量用的是SHOW VARIABLES LIKE tx_isolation。MySQL 8.0开始这个变量改名为transaction_isolationtx_isolation已经成了过时别名甚至在部分版本里直接查不到。所以你在8.0上执行SELECT tx_isolation;可能会报错或者返回空正确姿势是SELECT transaction_isolation; SHOW VARIABLES LIKE transaction_isolation;看历史博客或者跑老命令时脑中先过一下变量名。这个坑不深但每次踩都特别窝火。6.2 锁等待和死锁怎么排查如果线上出现锁等待超时快速定位手段是查三张系统表。先看当前有哪些事务处于未结束状态SELECT * FROM information_schema.innodb_trx\G再看锁等待情况MySQL 8.0可以这样查SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks;如果已经报了死锁官方建议先看最近一次死锁信息SHOW ENGINE INNODB STATUS;重点查看LATEST DETECTED DEADLOCK段里面会记录死锁的两个事务各自执行的SQL、持有和等待的锁。排查死锁的核心不是只看表名而是要落到具体索引死锁大多发生在并发事务按不同顺序更新同一组记录时或者间隙锁与插入意向锁互相等待时。统一事务内的操作顺序缩小事务范围尽量使用索引精准定位行是减少死锁的三板斧。6.3 长事务为什么是隐形杀手很多同学没意识到隔离级别和长事务两个因素叠加会产生很严重的次生灾害。一个RR级别下的长事务会一直持有自己创建的那个Read View。这意味着这期间所有已经提交的修改在它眼里都不可见相关旧版本数据不能被清理。随着时间推移undo log越积越大版本链越来越长最终导致undo表空间膨胀甚至拖慢其他事务的读取和purge线程的清理工作。所以只要是使用RR的库我会特别关注长事务。排查手段就是上面提到的information_schema.innodb_trx看trx_started字段就能知道一个事务跑了多久。对超过设定阈值的事务要告警处理。写业务代码时也要养成习惯事务里别做远程调用、别发消息、别等用户输入凡是可能卡住几分钟的操作一律挪到事务外面。6.4 实战排查时我踩过的坑最后分享一个我实际遇到过的案例很有代表性。有次线上一个统计任务偶发性出现结果不一致查了很久都找不到原因。最后发现某台从库的会话级隔离级别被之前的同事在调试时改成了READ COMMITTED而程序走的是同一套连接池上线新连接默认继承了全局配置但某些复用连接还是带着修改过的会话级别。也就是说同一个统计脚本在一半连接上以RR执行在另一半连接上以RC执行两次跑批结果自然可能不同。排查过程绕了很大的弯子。后来我的习惯就变成了任何线上问题排查第一件事先确认会话级和全局级的隔离级别是否一致SELECT global.transaction_isolation AS global_iso, session.transaction_isolation AS session_iso;两条值如果不相等先别急着看业务SQL很可能是隔离级别本身被谁悄悄地动过了。还有一个我反复提醒自己的点演示是理解了但线上切级别之前一定要先确认binlog格式。如果业务还在用STATEMENT格式把隔离级别从RR切到RC很可能带来主从数据不一致。这类问题不像死锁那样能一眼看出来往往要等到数据对比才发现成本极高。我个人在实际使用中的体会是隔离级别是一把很好的尺子——它衡量的是你对读到过期数据和牺牲并发效率的容忍度。读完这篇文章如果你能记住一句话我希望是这句话RC和RR最大的差别不在锁而在Read View的生成时机一个是每次SELECT都生成一个是事务复用一个而幻读的彻底解决是靠Next-Key Lock把未来可能插进来的行也一起锁住。把这条主逻辑理清剩下的就是实操经验的事了。