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,原因后面选型章节会细说,核心其实就是并发度和锁范围更友好。但如果你要跑定时统计、对账、汇总这类对一致性敏感的SQL,RC的"每次都看最新提交值"会让统计结果产生跳跃感,需要提前评估。
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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执行ROLLBACK:
ROLLBACK;会话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在同一个未结束的事务里,再次执行一模一样的SELECT:
SELECT balance FROM account WHERE id = 1;结果变成了1500.00。同一事务里,同一条SQL,前后两次读到的值不一样——这就是不可重复读。
在READ COMMITTED下,每次SELECT都基于最新已提交数据生成新快照,所以B一提交,A的下一秒查询就会立刻看到新值。
3.4 复现幻读:REPEATABLE READ下的一次深入测试
先把数据复位,同时确保会话A切回REPEATABLE READ:
UPDATE 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_pointer,roll_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不仅锁住了id=1、id=2这两条记录,还把它们之间的空隙以及右边直到正无穷的区间也上了间隙锁。会话C企图插入id=3的新行,实际上就是想往这个被锁的区间里插,自然被挡住,只能乖乖等待。
这就是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_isolation,tx_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把"未来可能插进来的行"也一起锁住。把这条主逻辑理清,剩下的就是实操经验的事了。