MySQL事务隔离级别实战:用实验复现脏读、不可重复读与幻读 MySQL 的事务隔离级别凡是干过后端开发的估计都在面试题里“背过”八百遍了——读未提交会有脏读读已提交会不可重复读可重复读和串行化各是什么表现……但真到生产环境里数据对不上账的时候能靠这几个概念迅速定位“是不是隔离级别惹的祸、该往哪一级调、副作用是什么”的人其实不多。我自己就吃过亏。年初排查一个库存扣减对不上订单的线上问题折腾了两天最后发现是两套服务连的还是同一个 MySQL 实例一个用默认的 REPEATABLE READ另一个显式改成了 READ COMMITTED两条事务交错执行时快照读和当前读的语义完全不同直接把问题引向了错误的方向。所以说隔离性这个事不能光背概念要动手让它“现形”过才算是真懂。这篇文章我干脆把四个隔离级别全部做成实验用两个终端、几十行 SQL把脏读、不可重复读、幻读、锁等待这些现象一个一个复现出来看。每个实验都有完整的 SQL 执行顺序和实际结果你可以直接照着在自己的 MySQL 上跑。文章不堆理论重点放在可复现的操作上。适合谁看刚接触 MySQL 事务的工程师、要把隔离级别讲出细节的求职者、以及线上出现过“诡异数据不一致”想排查原因的后端开发都可以从头到尾过一遍。你手头有任何一个版本 MySQL最好是 8.0 以上现在就能开始。1. 为什么单独把隔离性拎出来做实验1.1 ACID 四个属性里最需要“人肉验证”的就是隔离性ACID 是老生常谈原子性、一致性、隔离性、持久性。原子性靠 undo log 保证持久性靠 redo log 保证这两个对使用者基本是透明的要么成功、要么回滚不用你去配置什么档位。一致性更像是业务层的约束数据库只负责在事务出错时回滚到事务前的状态最终业务数据是否符合逻辑得靠代码和约束设计。唯独隔离性不一样。它是 ACID 里唯一一个可以按等级配置的维度同一套业务代码放在不同的隔离级别下运行结果完全不同。开发人员如果不清楚当前连接的隔离级别很容易对“事务内读到的数据到底是不是最新的”产生误判。更麻烦的是隔离级别的影响不是“单条 SQL 出错”那种显性问题而是“数据好像不太对”的隐性矛盾排查成本很高。所以我一直觉得隔离性是四个属性里最值得动手做实验的。看一百遍理论介绍不如跑一次脏读复现来得直观。1.2 隔离性要解决的是三类“读异常”在展开实验前先把三个读异常的定义对齐后面反复用到脏读事务 A 读到了事务 B 尚未提交的数据。B 一旦回滚A 拿到的就是根本不存在的假数据。不可重复读事务 A 内第一次查询某条记录得到一个值之后再次查询同一条记录发现值变了。原因是事务 B 在这期间修改并提交了该记录。幻读事务 A 内第一次查询某个范围得到 N 条记录之后再次查询同一范围发现多了或少了记录。原因是事务 B 在这期间插入或删除了符合条件的记录。用生活里的场景类比脏读就像偷看同桌还没写完的答题卡他可能马上改答案不可重复读就像你在同一页账本上对同一笔金额看了两次中间有人涂改了数字幻读就像你数货架上一排箱子第一次数 10 箱回头再数变成了 12 箱凭空多出来两箱。1.3 实验方案总览这次实验围绕一张简单的账户表展开两个终端分别模拟两个并发事务。四个隔离级别依次配置再逐个复现对应的异常现场。实验顺序上从“管得最松”的 READ UNCOMMITTED 开始一路升级到“管得最严”的 SERIALIZABLE越往后读异常越少但并发代价越大这个梯度会让你对隔离级别的设计动机有很直观的体感。2. 实验环境与准备2.1 MySQL 版本和连接工具我用的环境是 MySQL 8.0.28InnoDB 引擎。5.7 和 8.0 在隔离级别这个层面的行为基本一致唯一的差别是参数名5.7 里tx_isolation和transaction_isolation都可用8.0 里只剩transaction_isolation。实验时我统一用SELECT transaction_isolation来确认当前级别这个写法在 5.7.20 以上版本都兼容。如果你想用 Docker 快速起一个环境命令大概是docker run --name mysql-lab -e MYSQL_ROOT_PASSWORD123456 -p 3306:3306 -d mysql:8.0注意宿主机端口如果被占用就换一个映射连接时用对应端口即可。2.2 建表与初始数据实验表设计得很简单DROP TABLE IF EXISTS account; CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account(name, balance) VALUES (张三, 1000.00), (李四, 2000.00); COMMIT;这里用 DECIMAL 存金额是 MySQL 上的基本素养别用 FLOAT/DOUBLE 存钱。InnoDB 是实验的前提MyISAM 不支持事务、不支持行级锁不在讨论范围。两张测试数据就够了多了一会让现象不清晰少了又体现不出“范围查询”的感觉。2.3 两个会话怎么区分实验必须在两个独立的数据库连接里交替执行最简单的方式是开两个终端窗口每个窗口分别连接 MySQL并且把提示符改成能区分的名字mysql -h127.0.0.1 -uroot -p --prompt终端A mysql -h127.0.0.1 -uroot -p --prompt终端B 这样看到终端A还是终端B就知道当前在哪个会话里操作不会串台。另外我建议每个会话在实验前先执行SET autocommit 0;然后所有事务操作都用显式的START TRANSACTION和COMMIT/ROLLBACK控制。否则 mysql 客户端默认 autocommit1单条UPDATE或SELECT很可能在你没察觉时已经自动提交实验现象会被“自动提交”吃掉。3. 实验一READ UNCOMMITTED——现场抓一次脏读3.1 操作步骤和 SQLREAD UNCOMMITTED 是四个级别里最“奔放”的隔离程度最低连未提交的数据都敢直接读出来。我现在就把脏读复现给你看。终端 A 先设置隔离级别并开启事务SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT * FROM account WHERE id 1;此时结果一切正常张三 balance 还是 1000.00。终端 B 也切换到同样的隔离级别开启事务后修改张三的余额但不提交SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1;这时关键一步来了。回到终端 A再次查询同一行SELECT * FROM account WHERE id 1;注意终端 B 的COMMIT根本没执行但终端 A 已经读到了 900.00。这就是脏读——你读到的是别人“修改中”的中间状态。接下来让终端 B 回滚ROLLBACK;终端 A 再查一次余额发现又变回 1000.00。整个事务 A 里第一次读时余额是 1000第二次读变成 900第三次又变回 1000每一次都跟着别人的未提交动作跳舞。3.2 为什么会出现脏读在 READ UNCOMMITTED 级别下普通SELECT既不会去校验行上有没有排他锁也不需要维护一致性快照它就那么“裸读”当前最新版本。如果某行的最新版本是一个尚未提交事务留下的修改MVCC 的可见性判断也不会拦着它。放在事务机制里看READ UNCOMMITTED 仿佛直接把“提交”这个门槛取消了。脏读的危害在于它可能让业务基于根本不存在的“假数据”做决策。比如库存只剩 10 件另一个事务扣了 8 件但还没提交你这边读到 2 件判定缺货拒绝下单实际上那个扣减后面可能回滚了库存还是 10 件。这种误判是隐性的线上排查时特别难发现。3.3 这个级别在生产里能用吗我的建议是业务代码基本不要用。只有在做临时性粗统计、调试问题、或者要观测并发修改中间态的时候短时间开一下还可以接受。它没有提供任何“读的保障”砍掉了事务隔离性里最重要的保护层。4. 实验二READ COMMITTED——不可重复读演示4.1 操作步骤和 SQLREAD COMMITTED 解决了脏读但引入了不可重复读同一个事务内同一条记录两次查询结果可以不同。终端 A 设置 READ COMMITTED 并开启事务SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1;此时读到的是 1000.00。终端 B 同样在 READ COMMITTED 下把张三余额改成 800 并提交SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; UPDATE account SET balance 800 WHERE id 1; COMMIT;回到终端 A在同一个尚未结束的事务里再次查询SELECT balance FROM account WHERE id 1;第二次读到 800.00。同一个事务 A 内两次查询同一条记录结果一个 1000、一个 800这就是不可重复读。4.2 现象解读为什么“只读已提交”还不够READ COMMITTED 的核心规则是“每次普通 SELECT 都重新生成一个新的快照Read View”。这句话是理解问题的钥匙。终端 A 第一次查询时生成一个快照当时提交过的余额是 1000终端 B 提交 800 之后终端 A 第二次查询重新生成快照这时快照里能看到已经提交的 800所以两次结果自然不同。刚才 READ UNCOMMITTED 里能读到未提交数据的问题在 READ COMMITTED 下消失了因为新版快照会过滤掉未提交事务的修改。但它不保障“事务内一致性”只保障“每条语句都看到最近已提交的数据”。这种特性的代价就是不可重复读。不可重复读在真实业务里意味着什么典型场景是对账程序事务里先汇总某个账户余额再判断是否需要调整两次取值不一样计算结果就是错的。所以很多需要多次读取校验的业务不能直接接受 READ COMMITTED 下的不可重复读。5. 实验三REPEATABLE READ——快照读不幻读但当前读会5.1 操作步骤和 SQLREPEATABLE READ 是 MySQL 的默认隔离级别它的表现要分成两条线看一条是快照读另一条是当前读。我把两个现象都做给你看。终端 A 设置 REPEATABLE READ 并开启事务SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM account WHERE balance 0; SELECT COUNT(*) FROM account;此时看到两条记录表里总行数是 2。终端 B 插入一条新记录并提交SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; INSERT INTO account(name, balance) VALUES (王五, 3000); COMMIT;回到终端 A再次执行快照读查询SELECT * FROM account WHERE balance 0; SELECT COUNT(*) FROM account;结果还是两条记录完全没有“王五”。这说明在 REPEATABLE READ 下普通 SELECT 这种快照读不会发生幻读——因为终端 A 事务开始后第一次 SELECT 就生成了一份固定的 Read View之后整个事务内反复复用这份快照外面提交了什么新事务它都看不见。5.2 关键实验当前读立刻“现出原形”但如果你把普通 SELECT 换成“当前读”事情就变了。继续在终端 A 未提交的事务里执行SELECT * FROM account WHERE balance 0 FOR UPDATE;这次结果出现了三条记录王五 3000 赫然在列。为什么同一个事务里普通查询看不到新数据加了FOR UPDATE就能看到原因在于普通SELECT走的是快照读读的是事务启动那一刻的一致性快照不上锁、也不读最新版本FOR UPDATE走的是当前读它读取的是当前已提交的最新版本并且会给命中的记录加排他锁。所以在 REPEATABLE READ 级别下快照读层面幻读确实不存在了但当前读层面依然能看到新插入的行。再把实验往深了做一步。终端 B 尝试在balance 0这个范围里插入一条新记录SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; INSERT INTO account(name, balance) VALUES (赵六, 4000);你会发现这条 INSERT 会阻塞住直到终端 A 回滚或提交才能继续。这就是 InnoDB 在 REPEATABLE READ 下“防幻读”的另一个手段——间隙锁Gap Lock/ 临键锁Next-Key Lock在起作用。它锁住了范围查询涉及的间隙阻止其他事务在间隙里插入数据。5.3 幻读的真相不同读模式要分开看我之前在多个技术群里见人争论“MySQL 的 REPEATABLE READ 到底能不能彻底避免幻读”实际上这是个伪命题要分模式看快照读在 REPEATABLE READ 下同一个事务里全程复用同一份 Read View任何其他事务提交的 INSERT 都不可见因此不会出现幻读。当前读SELECT ... FOR UPDATE、UPDATE、DELETE这类操作读取最新版本需要依赖 next-key lock 来阻止其他事务插入。如果查询命中的范围能被锁覆盖就不会幻读但如果你的查询条件设计不当比如走了非唯一索引、或者没有合适的索引锁范围可能覆盖不全依然可能产生幻读现象。所以最准确的说法是InnoDB 在 REPEATABLE READ 下利用 MVCC Next-Key Lock在绝大多数场景下消除了幻读这也是它能作为默认隔离级别的重要原因之一。6. 实验四SERIALIZABLE——锁等待现场6.1 操作步骤和 SQLSERIALIZABLE 是隔离级别金字塔的塔尖代价是并发能力被大幅削弱。在这种级别下普通 SELECT 也会被转成加锁读取相当于所有读操作都变成当前读。终端 A 设置 SERIALIZABLE 并开启事务SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; SELECT * FROM account WHERE id 1;这条查询正常返回。因为事务 A 还没提交按照 SERIALIZABLE 的规则这条 SELECT 会持有 id1 这行上的共享锁S 锁。终端 B 设置同样的隔离级别后尝试修改同一行SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; UPDATE account SET balance balance - 50 WHERE id 1;这条 UPDATE 会卡住因为共享锁和排他锁互斥事务 B 拿不到排他锁只能等待。执行后终端会一直停在那里直到终端 A 提交COMMIT;终端 B 的 UPDATE 才“唰”地一下执行完成。6.2 解读用锁把并发彻底串行化SERIALIZABLE 的隔离思路很粗暴读加共享锁写加排他锁读写互斥后到的事务必须等先到的事务提交才能继续。这种做法把并发事务变成了排队执行自然不存在脏读、不可重复读、幻读中的任何一个。但代价非常明显并发吞吐量断崖式下跌。所以生产线上极少把数据库整体设置为 SERIALIZABLE基本只在资金对账、库存强校验这类“正确性远高于性能”的场景里局部使用更多时候是用 SELECT ... FOR UPDATE 在局部模拟类似效果。做这个实验时有个细节容易翻车如果会话开着 autocommit1单条 SELECT 执行完自己就提交了锁根本留不下来UPDATE 不会阻塞。所以实验前一定要先执行SET autocommit0或者用显式START TRANSACTION包住读取动作。6.3 四档隔离级别现象对比到这里四个级别的实验全部做完汇总如下隔离级别脏读不可重复读幻读快照读幻读当前读普通 SELECT 行为READ UNCOMMITTED会会会会裸读最新版本不加锁READ COMMITTED不会会会会每条语句生成新快照REPEATABLE READ不会不会不会通常由 Next-Key Lock 避免事务首条 SELECT 生成快照复用SERIALIZABLE不会不会不会不会转成加锁当前读这张表建议收藏面试时被问到“MySQL 里幻读到底存不存在”直接拿出这表讲比背定义强太多。7. 隔离级别背后的两个核心机制锁和 MVCC7.1 锁机制从记录锁到临键锁实验里反复出现的“阻塞”全部来自 InnoDB 的锁机制。行级锁主要有三种形态记录锁锁定单条记录。间隙锁锁定记录之间的一段“空隙”防止其他事务在间隙里插入新记录。临键锁记录锁和间隙锁的组合锁住当前记录以及它前面的间隙。共享锁S 锁和排他锁X 锁的兼容规则很简单S 和 S 兼容意味着多个事务可以同时持有共享锁读同一行S 和 X 互斥、X 和 X 互斥意味着一旦有人加了排他锁其他读写操作都进不来。为什么 READ COMMITTED 下加锁范围明显变小因为在这个级别InnoDB 不使用间隙锁外键检查、duplicate key 检查等少数场景除外只锁定实际命中的行并发插入的碰撞概率降低死锁也更少。这也是很多高并发业务从默认 REPEATABLE READ 切到 READ COMMITTED 的原因之一。7.2 MVCC 版本链与 Read ViewMVCC 是“多版本并发控制”的英文缩写理解它才能从根上看懂 REPEATABLE READ 的快照复用。InnoDB 会在每行记录上维护两个隐藏列事务 ID最近一次修改这行的事务编号和回滚指针指向该行在 undo log 里的历史版本。每次 UPDATE 不是直接覆盖旧值而是生成一个新版本并串联到版本链上。读者在读取时通过“事务启动时的 Read View”来判断版本链中的哪个版本对当前事务可见。Read View 的可见性判断大致是被访问版本的事务 ID 小于当前活跃事务列表里最小 ID 的说明早已提交可见大于等于已分配最大 ID 的不可见在活跃列表内部的不可见不在活跃列表但 ID 介于两者之间的说明已提交可见。REPEATABLE READ 和 READ COMMITTED 的最大差异就是 Read View 的生成时机READ COMMITTED每次快照读都新建 Read View所以能看到其他事务在两次查询之间新提交的数据。REPEATABLE READ事务内第一次快照读时生成 Read View之后一直复用所以整个事务的读结果稳定一致。这就是“可重复读”这个名字的底层来源——保证同一个事务里看到的是同一份视角。7.3 为什么 MySQL 默认选 REPEATABLE READMySQL 默认 REPEATABLE READ有它的历史原因。早期主从复制使用基于语句的 binlogSTATEMENT 格式如果主库用 READ COMMITTED事务里通过“当前读”加锁再更新数据时可能因为并发插入导致从库重放日志时效果对不上主从数据一致性容易出问题。而 REPEATABLE READ 配合临键锁能更严格地保证当前读范围不变让基于语句的复制更安全。后来基于行的 binlogROW 格式普及这种约束已经弱化但默认级别并没有改变。给业务上的参考如果还在纠结要不要换隔离级别大多数场景保持默认就行不要为了“更正确”直接上 SERIALIZABLE那样并发能力会非常难看。真遇到死锁或锁等待频繁优先排查 SQL 是否缺少索引、锁顺序是否一致再考虑切 READ COMMITTED。8. 工作中怎么选隔离级别、常见问题与排查手段8.1 业务选型与代码层面的注意事项结合实验现象我给你几个务实的选型建议绝大多数 CRUD 业务保持默认 REPEATABLE READ不要动。快照读让你事务内数据一致当前读再靠索引锁兜底足够应付日常需求。高并发写入场景如果死锁报警频发尤其集中在批量 UPDATE 或者 INSERT ... ON DUPLICATE KEY UPDATE 上可以调研切换到 READ COMMITTED。它减少间隙锁能明显降低死锁概率代价是接受“事务内不可重复读”。严苛对账、资金类业务局部使用SELECT ... FOR UPDATE或者索性用 SERIALIZABLE 包裹关键步骤但一定要评估吞吐量和锁等待时间。代码里用了TransactionalJava/Spring的注意默认情况下Spring 事务的隔离级别是使用底层数据库的默认级别。如果你需要显式指定可以用Transactional(isolation Isolation.REPEATABLE_READ)不要想当然以为注解里有级别就自动生效程度更高。另外提醒一句本地数据库的隔离级别解决不了跨服务、跨数据库的一致性问题。订单系统和库存系统各连一个库哪怕各自都开了最高隔离级别两个服务之间也没有“全局事务视图”这种情况要引入分布式事务方案不要把 ACID 的“隔离性”硬套到分布式场景里。8.2 隔离级别相关常见问题速查表症状可能原因排查手段事务里读到了不存在的数据隔离级别是 READ UNCOMMITTED用SELECT transaction_isolation看当前会话隔离级别同一事务内两次读结果不一致隔离级别是 READ COMMITTED确认代码和连接串有没有显式改级别尽量保持默认 RRUPDATE/INSERT 卡住不返回行锁或间隙锁等待SHOW PROCESSLIST看 State结合information_schema.innodb_trx查持锁事务报错 ERROR 1205 锁等待超时持有锁的事务长时间未提交查看锁等待链5.7 看innodb_lock_waits8.0 看performance_schema.data_lock_waits报错 ERROR 1213 死锁两个事务加锁顺序不一致SHOW ENGINE INNODB STATUS\G看 LATEST DETECTED DEADLOCK 段落改了SET GLOBAL但当前连接不变全局级别只影响后续新连接新开一个连接测试8.0 可用SET PERSIST持久化8.3 排查隔离级别和锁的利器系统表做隔离性实验时除了肉眼观察 SQL 返回结果我强烈建议你学会用 InnoDB 的系统表来“透视”事务状态。先用这条 SQL 确认当前会话建了几个事务SELECT * FROM information_schema.innodb_trx\G它会把事务 ID、开始时间、执行状态、锁等待时长、持有锁的线程 ID 全部列出来。配合trx_mysql_thread_id和SHOW PROCESSLIST一一对应就能知道哪个连接在持有锁、哪个连接在等锁、等多久了。8.0 里看锁更细SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;这张表能看到每个锁的 ENGINE_LOCK_ID、锁的类型RECORD/GAP、锁的模式X/S、锁在哪个表哪个索引哪一行。很多人在网上问“为什么我的 UPDATE 会被一个 SELECT 卡住”用这个表一眼就能定位到共享锁没释放。实验过程中如果 SQL 一直不返回先用SHOW PROCESSLIST看看是不是自己在当前终端里等锁结果把整个连接搞“假死”了。这种情况我至少遇见过三次最后都是把innodb_lock_wait_timeout调小到 3 秒以内。9. 我踩过的几个实验坑最后分享给你做这组实验的过程中我自己踩了不少坑说几个有代表性的避免你在复现时浪费时间。第一两个终端一定不要用同一个 sql 文件去执行也别用复制粘贴到同一个窗口。我把终端 A 和终端 B 的窗口搞混过结果以为自己在复现脏读其实两个 SQL 都在同一个会话里执行后半程完全失控。后来就老老实实用--prompt终端A 这种区分方式实验效率高了很多。第二每次切换隔离级别前先把当前这个会话有没有未提交的SELECT或UPDATE处理掉最好全部ROLLBACK。如果带着一个开启中的事务去切换级别某些情况下会报错或者新级别不生效排查半天。第三做 REPEATABLE READ 实验时一定先把“快照读”和“当前读”两条路径分开测。我一开始直接上来就SELECT ... FOR UPDATE拿到三条记录反而没看懂快照读那条“看不见王五”的关键现象。你按我第 5 节的顺序做——先普通查询确认没有新数据再加FOR UPDATE看到新数据理解上的“反差感”才出得来。第四我在实验里用DECIMAL(10,2)存余额如果你跟着练习时不小心用了 FLOAT最后余额的加减会随着小数位出现精度误差可能干扰你对事务结果的判断。这本身就是生产环境一个重要的避坑点金额永远用定点数。最后再说一个小技巧如果你觉得 MVCC 的 Read View 概念太抽象可以把事务 A 开启后执行多条 SELECT 的时间点记录下来再对比information_schema.innodb_trx里trx_started字段的变化——REPEATABLE READ 下事务开始时间是不变的READ COMMITTED 下事务里“每次快照”的视角在持续刷新。自己做一次这种观察比看十遍原理分析都管用。隔离性这个东西说难不难说简单也不简单。你跟着把这四个级别的实验跑完以后再有同事跟你聊“事务隔离”你不光能说出脏读、不可重复读、幻读的概念还能告诉他具体什么 SQL、什么锁、什么条件下会出现。这种能落到手里的确定性就是做实验最大的回报。