MySQL 面试题十有八九会绕着 InnoDB 的事务隔离级别打转尤其是当你被问到“REPEATABLE READ 下到底会不会出现幻读”的时候很多人当场就卡住了。我讲 MySQL 内核系列讲到第9讲干脆把 InnoDB 实现四种隔离级别的底层机制完整拆一遍结合 MVCC、undo log、行锁、间隙锁和可复现实操案例保证你看完能跟面试官掰扯清楚。这篇内容适合正在背 MySQL 八股、写 Java 事务注解总踩坑、以及线上遇到并发数据不一致问题的开发者我尽量说人话把底层原理和实操经验一起给你。1. 走进事务隔离级别解决的是什么问题1.1 事务 ACID 与隔离性的关系任何支持事务的数据库核心都绕不开 ACID 四个特性原子性、一致性、隔离性、持久性。原子性靠 undo log 实现持久性靠 redo log 实现一致性是终极目标而隔离性则需要一套完整的并发控制机制来兜底。很多人把隔离性理解成“事务之间完全看不见”这是不对的。隔离性实际上是一把可以调节的尺子尺子上的刻度就是事务隔离级别。InnoDB 的默认隔离级别是 REPEATABLE READ也就是 MySQL 官网常说的可重复读。这个默认值不是拍脑袋定的而是 InnoDB 团队为了平衡一致性、并发能力和实现复杂度之后选出来的折中方案。Oracle 的默认隔离级别是 READ COMMITTED很多从 Oracle 迁移到 MySQL 的同学刚上手时会对默认隔离级别的差异很不适应这背后牵扯到加锁策略和主从复制格式后面第4章、第6章会分别展开。关键要建立的第一层认知是隔离级别不是一个纯理论概念它最终会表现为“读操作看到什么版本的数据”以及“写操作会锁住哪些范围”。换句话说隔离级别选得不同InnoDB 在底层干活的方式就完全不同。1.2 并发异常脏读、不可重复读、幻读在讨论四种隔离级别之前先得把三个并发异常讲透因为隔离级别就是围绕这三个问题设计的。脏读是最严重的指一个事务读到了另一个事务尚未提交的数据。这里面有个隐含陷阱未提交的数据随时可能被回滚一旦对方回滚当前事务读到的东西就是凭空捏造出来的基于这种数据做业务决策会出大问题。打个比方你盯着收银台的小票另一个顾客还没结完账你就把小票上的金额记进账本结果人家最后取消付款你的账本自然就错了。不可重复读稍微轻一点它针对的是同一行数据。同一个事务里第一次查询读到金额是100元第二次查询却变成120元两次之间并没有修改这条记录纯粹是因为另一个事务在这期间提交了更新。问题在于你在一个事务里执行两次相同查询却得到不同结果如果第一笔读到的数据已经参与计算后面再用第二笔数据对账就会对不上。幻读针对的是结果集行数的变化。同一个事务内执行同一条范围查询第一次返回3行第二次返回4行多出来的那一行是另一个事务在这期间插入的。这类异常更隐蔽因为它不是修改已有数据而是往你正在统计的区间里塞新数据。InnoDB 在 REPEATABLE READ 下通过快照读解决了大部分幻读但并没有完全消灭幻读这一点是面试重灾区我会在第 6.1 节详细展开。2. 四种隔离级别定义与场景选择2.1 READ UNCOMMITTED 读未提交READ UNCOMMITTED 是最低级别事务之间几乎零隔离。在这种级别下一个事务可以读取另一个事务还没提交的数据脏读、不可重复读、幻读全都可能发生。InnoDB 在 READ UNCOMMITTED 下并不是完全没有机制兜底实际读取时仍然会走索引但不会做可见性过滤直接把版本链上最新的一条数据捞出来给你不管那条数据是否已提交。这个级别在实际业务中我基本不建议使用。它唯一的优势是减少判断可见性的开销但收益极低因为只要并发正常数据库的 CPU 消耗大头根本不在那点可见性判断上。偶尔会有人把它用在临时分析、日志统计这类“读错也无所谓”的场景但我也见过因为在这个级别下接错数据导致报表差异的线上事故。如果你在面试中聊到这个级别可以主动说出“生产环境基本不会用因为脏读风险不可接受”这是加分项。2.2 READ COMMITTED 读已提交READ COMMITTED 是很多关系型数据库的默认级别。它解决了脏读问题一个事务只能读取到其他事务已经提交的数据。实现上有个关键点每次 SELECT 都会重新生成一个一致性视图然后基于这个视图判断版本可见性。这意味着同一个事务里两次相同的查询可能会看到不同的结果因为两次查询之间如果有其他事务提交了更新第二次查询会立刻看到新版本。所以 READ COMMITTED 仍然会出现不可重复读和幻读。很多业务场景其实能容忍这两个问题比如纯粹的流水统计、排行榜展示只要不拿同一事务内的两次结果去做一致性对账READ COMMITTED 完全够用。它对锁的依赖也比 REPEATABLE READ 轻很多不会轻易使用间隙锁在高并发写入场景下冲突概率更低。如果你在做高并发订单、库存类的系统Redis 或 MQ 里已经做了幂等处理业务数据允许最终一致那么 READ COMMITTED 往往是更合适的选择。InnoDB 在 READ COMMITTED 下也会把 binlog 格式强制要求为 ROW否则主从复制可能不一致这个坑下文会再提。2.3 REPEATABLE READ 可重复读REPEATABLE READ 是 InnoDB 的默认隔离级别它的核心承诺是在同一个事务里快照读的结果始终一致。解决不可重复读的方式很巧妙事务第一次执行 SELECT 时生成一个一致性视图之后的所有普通 SELECT 都复用这个视图不去看后面新提交的数据。这里容易产生一个误解很多人以为 InnoDB 靠“锁住读过的行”来保证可重复读。实际上对于普通 SELECTInnoDB 基本不加锁而是通过 MVCC 多版本控制来实现只有 UPDATE、DELETE、SELECT FOR UPDATE 这类当前读才依赖锁。MVCC 的具体机制放到第3章这里先记住结论REPEATABLE READ 下的一致性视图一旦生成整个事务内快照读看到的数据就冻结在那个时间点。为了解决幻读REPEATABLE READ 还引入了间隙锁。InnoDB 在范围扫描时不仅锁住命中的记录还会锁住记录之间的间隙让其他事务无法向这个间隙插入新行。注意这个设计是为了配合 binlog 格式和主从一致性才加上的标准 SQL 里的 REPEATABLE READ 本不要求解决幻读。2.4 SERIALIZABLE 可串行化SERIALIZABLE 是最严格的隔离级别它把事务之间的并发程度降到了最低。InnoDB 在这个级别下会直接把普通的 SELECT 隐式升级为SELECT ... FOR SHARE也就是读写都加锁两个事务只要操作同一批数据就天然串行化。这个级别的代价非常明显并发能力急剧下降锁等待、死锁的概率升高吞吐量可能比 READ COMMITTED 低一个数量级。但它的优势也很纯粹业务代码里不需要再操心脏读、不可重复读、幻读数据库在极端情况下也能保证一致性。生产环境中我很少见到把全局级别设为 SERIALIZABLE 的更多是在极小范围的热点操作里通过SELECT ... FOR UPDATE手动实现类似串行化的效果。如果面试官问你“什么场景下用 SERIALIZABLE”你可以回答资金对账、数据迁移校验、或者必须要求强一致的短事务并且要意识到这通常是牺牲性能换正确性的极端手段。2.5 隔离级别与并发异常对照表把四种级别和三个并发异常放一起看是最容易记住的梳理方式隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能InnoDB 快照读下不会当前读下仍可能SERIALIZABLE不可能不可能不可能注意表格里 REPEATABLE READ 那一栏的括号内容这是 InnoDB 与标准 SQL 的差异点。标准 SQL 中 REPEATABLE READ 是允许幻读的但 InnoDB 靠间隙锁和 MVCC 把快照读场景下的幻读堵住了这也是很多人争论“RR 到底有没有幻读”的原因所在。3. InnoDB 的底层“巧妙”实现MVCC 一致性视图3.1 什么是一致性视图 ReadView听过很多次 MVCC但真要自己说出来“MVCC 怎么做到不锁读也能隔离”大部分人讲不透。核心工具叫 ReadView中文通常翻译成一致性视图。它不是一张物理表而是一个内存里的数据结构用来记录“当前有哪些事务正在活跃”。ReadView 内部主要维护了四个关键信息创建这个视图的事务自己的事务 ID、当前活跃事务 ID 列表 m_ids、活跃事务列表中的最小事务 ID、以及当前系统已经分配过的最大事务 ID 加一。判断一行数据能否被看到就是拿这行数据上的事务 ID 去比对这四个信息。具体规则可以简化成三条如果这行的事务 ID 比活跃事务列表中的最小值还小说明它是在这个视图创建之前就已经提交的可以看到如果这行的事务 ID 比系统最大事务 ID 还大说明它是在视图创建之后才开启事务写入的当前事务看不见如果这行的事务 ID 落在活跃事务列表里说明写这行数据的那个事务还没提交当前事务也必须看不见。这个机制在隔离级别上的差别就体现在 ReadView 的生成时机上。READ COMMITTED 是每条 SELECT 都生成一个新的 ReadView而 REPEATABLE READ 是事务里第一条 SELECT 生成 ReadView 之后后面一直复用这一个。这就是两种隔离级别在快照读场景下表现不同的根本原因。3.2 隐藏列与 undo log多版本链条InnoDB 的每一行记录除了业务字段还会附加几个隐藏列。其中最重要的两个是 DB_TRX_ID 和 DB_ROLL_PTR。DB_TRX_ID 记录最近一次修改这行数据的事务 IDDB_ROLL_PTR 则指向这条记录在 undo log 里的上一个版本。当你给一行数据做 UPDATE 时InnoDB 不会原地覆盖掉旧值而是先把旧值链到 undo log 里再生成一个新版本的行记录。新记录上的 DB_TRX_ID 被改成当前事务 IDDB_ROLL_PTR 指向上一个旧版本。这样一来同一行逻辑数据在物理存储上就形成了一条版本链最新版本在最上面历史版本沿着回滚指针往下找。读取数据时InnoDB 会沿着版本链逐条拿 DB_TRX_ID 去和当前事务的 ReadView 比对一旦找到对当前事务可见的版本读取过程就结束。这里要注意这个版本链不是无限长的提交事务之后如果没有其他事务还需要读这些旧版本后台的 purge 线程会慢慢清理掉它们。这也是为什么长事务会导致 undo log 膨胀的直接原因后面第 6.4 节会讲怎么排查。用一个生活化的例子来理解就好比人事档案每次员工改工资系统不是把旧的工资单扔掉而是把旧工资单存档再写一张新的。你现在要查某个历史时点的工资就顺着档案链往回翻直到翻到那个时点还生效的档案。MVCC 干的就是这个事。3.3 快照读在当前读面前的真实工作方式MVCC 解决的是快照读的隔离问题。快照读就是我们平时写的普通SELECT它不加任何锁靠版本链和 ReadView 实现“读历史版本”。快照读永远不会阻塞其他事务的写入这也是 InnoDB 在高并发下依然有一席之地的关键原因。但 InnoDB 里还有一种读叫当前读包括UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... FOR SHARE。当前读必须拿到最新已提交的版本并且对所操作的行加锁。因为如果一个事务要修改某条数据却基于一个过期版本去改那后边一旦提交就会覆盖别人已提交的修改引发丢失更新。理解“快照读”和“当前读”的分界线是理解隔离级别和 MVCC 的最重要一步。很多问题比如 REPEATABLE READ 下为什么还会看到新插入的数据根源就在于你的 SELECT 看起来是普通查询但实际执行计划走了当前读逻辑或者后边跟的写操作触发了当前读。我建议你在分析任何事务异常前第一件事就是把 SQL 分成快照读和当前读两类再做判断。4. 从悲观锁到乐观锁锁机制与隔离级别的联动4.1 行锁、间隙锁、next-key lockMVCC 管住了读但管不住写冲突写冲突必须靠锁。InnoDB 的锁体系里最核心的是三种记录锁、间隙锁、临键锁。记录锁就是锁住索引树上的一条具体记录比如WHERE id 5命中了 id5 这行记录锁就锁住这行。间隙锁锁的是两个索引记录之间的“空档”目的是禁止其他事务在这个空档里插入新记录。临键锁是记录锁加间隙锁的组合锁的是一个左开右闭的区间比如索引值区间(3, 5]既锁住 id5 的记录也锁住 3 和 5 之间允许插入新值的空档。为什么要用间隙锁来锁“空档”因为并发插入是幻读的头号嫌疑。A 事务执行SELECT * FROM t_order WHERE id BETWEEN 3 AND 5 FOR UPDATE如果不锁间隙B 事务可以立刻插入一个 id4 的新记录那 A 事务的这个范围就多了一行幻读就出现了。加了间隙锁后B 的插入会被阻塞直到 A 提交或回滚才解除。注意一个细节间隙锁和记录锁不同它锁的是“不允许插入”但它不阻塞另一个事务对已有记录的修改因为它本身不锁定任何具体记录。这也是为什么间隙锁容易让人产生“明明锁了范围为什么这行还能被 UPDATE”的困惑。4.2 RC 与 RR 在加锁范围上的差异READ COMMITTED 和 REPEATABLE READ 在加锁上的差异是面试里最容易考细节的点。在 RC 下InnoDB 只使用记录锁不使用间隙锁。也就是说如果 A 事务对 id BETWEEN 3 AND 5 执行当前读最后只会锁住 id3 和 id5 这两条已存在记录中间的 id4 空档是可以被别的事务插入的。在 RR 下同样的当前读会升级为临键锁不仅锁住命中的记录还会把扫描过程中经过的间隙都锁住。这样外部事务既改不了锁定的记录也无法在相关间隙插入新行。用大白话讲RC 是在“点”上做限制RR 是在“区间”上做隔离。但是 RR 也不是永远把临键锁用满。比如用唯一索引做等值查询并且命中了一条存在的记录InnoDB 会做优化把临键锁退化成记录锁因为它能确定这个唯一值已经存在不需要锁间隙来防插入。等值查询没有命中任何记录时情况反过来会退化成纯间隙锁。范围查询则基本会用到临键锁。初学者如果把这些优化记成“RR 全加间隙锁”面试官再追问两句就会露馅。4.3 SERIALIZABLE 退化为锁读到了 SERIALIZABLE数据库干脆放弃“快照读”这条路直接把所有普通 SELECT 隐式转成SELECT ... FOR SHARE。FOR SHARE 是共享锁多个事务可以同时读一行但只要有一个事务持有了这一行的排他锁共享锁就必须等待。这意味着 SERIALIZABLE 下普通的读和写之间完全互斥事务的并发度被压到最低。你会感觉到这个级别下数据库的行为变得特别“老实”事务 A 读了某个范围事务 B 想往这个范围插入数据会一直阻塞到 A 结束。好处是隔离性绝对可靠坏处是任何长事务都会变成一把巨大的锁拖垮整个系统。所以在设计高并发系统时SERIALIZABLE 属于需要谨慎触碰的开关它更像是一把“保底锁”用来处理业务实在无法容忍并发异常的场景。正常业务里我更推荐用 REPEATABLE READ 当前读手动加锁或者 READ COMMITTED 幂等设计来替代全局 SERIALIZABLE。5. 实操演示用两个会话验证隔离级别5.1 环境准备与查看默认隔离级别纸上谈兵没意思我建议你直接开两个 MySQL 客户端窗口跟着下面的操作走一遍。先建一张极简订单表插入三条看起来有点“间隙”的数据为了演示间隙锁id 特意留空位CREATE TABLE t_order ( id INT NOT NULL, user_id INT NOT NULL, amount DECIMAL(10,2), PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO t_order VALUES (1, 101, 50.00), (2, 102, 80.00), (5, 105, 120.00);然后查看当前隔离级别。MySQL 8.0 用transaction_isolation变量5.7 及更早版本是tx_isolationSHOW VARIABLES LIKE transaction_isolation; SELECT transaction_isolation; SELECT global.transaction_isolation, session.transaction_isolation;通常你会看到REPEATABLE-READ这个默认值。注意变量值之间用英文连字符而不是下划线写错了会查不到。接下来把所有实验都固化到显式事务里避免客户端自动提交干扰。建议先执行SET autocommit 0;再用BEGIN开启事务。很多人在验证隔离级别时发现现象和预期不符八成就是自动提交开着事务根本没真正持续到下一次查询。5.2 复现脏读、不可重复读、幻读的操作步骤先复现脏读把会话 A 的隔离级别临时改为 READ UNCOMMITTED-- Session A SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; SELECT * FROM t_order WHERE id 1;此时依然能读到 50.00。现在到会话 B 开事务修改但先不提交-- Session B BEGIN; UPDATE t_order SET amount 99.00 WHERE id 1;回到会话 A 再查一次-- Session A SELECT * FROM t_order WHERE id 1;你会发现读到了 99.00而这个值来自尚未提交的事务。接着在会话 B 执行ROLLBACK;这个 99.00 就成了凭空出现的脏数据。这就是脏读的完整证据链。再复现不可重复读把会话 A 改成 READ COMMITTED-- Session A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; SELECT * FROM t_order WHERE id 1; -- 读到 50.00会话 B 修改并提交-- Session B UPDATE t_order SET amount 80.00 WHERE id 1; COMMIT;回会话 A 再次查询会看到 80.00。同一个事务里两次查询结果不一样不可重复读复现完成。复现幻读还是在会话 A 用 READ COMMITTED-- Session A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; SELECT COUNT(*) FROM t_order WHERE id BETWEEN 1 AND 5; -- 3会话 B 插入一条 id3 的数据后提交-- Session B INSERT INTO t_order VALUES (3, 103, 66.00); COMMIT;回会话 A 重新执行SELECT COUNT(*)数字变成 4。三次同样的条件行数完全不同幻读实锤。现在换到 REPEATABLE READ先验证可重复读-- Session A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; SELECT * FROM t_order WHERE id 1; -- 读到 80.00会话 B 再把它改成 120.00 并提交回会话 A 重新查你会发现结果还是 80.00。这就是 REPEATABLE READ 的快照读效果。但幻读在这里藏着一个反转同样用 RR再做一次范围查询-- Session A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; SELECT * FROM t_order WHERE id BETWEEN 1 AND 5; -- 3 行会话 B 插入 id4 后提交-- Session B INSERT INTO t_order VALUES (4, 104, 90.00); COMMIT;回会话 A 再执行普通 SELECT还是只能看到 3 行多了 id4 也看不到。到此为止RR 确实阻止了快照读层面的幻读。但如果会话 A 现在执行一条 UPDATE-- Session A UPDATE t_order SET amount amount 1; SELECT * FROM t_order WHERE id BETWEEN 1 AND 5;这条 UPDATE 会先做当前读读取最新已提交版本于是刚才被 B 插入的 id4 也进入了更新范围最终 SELECT 出来会变成 4 行。这就是很多人争论的“RR 下还有没有幻读”的答案快照读没有当前读依然有。5.3 修改隔离级别的正确姿势实际工作中改隔离级别一般有三种方式全局、会话、事务级。-- 全局需要 SUPER 权限只影响新连接 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 会话只影响当前连接 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 仅下一个事务 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;如果想让数据库重启后依然生效需要在配置文件里写死。MySQL 8.0 配置文件 my.cnf 中加[mysqld] transaction-isolation READ-COMMITTED注意GLOBAL级别的修改不会影响已经存在的连接所以线上热变更后业务连接池里老连接仍然沿用旧配置需要重连才能拿到新值。如果是 Java 项目Spring 的Transactional注解里也能指定事务隔离级别Transactional(isolation Isolation.REPEATABLE_READ) public void updateOrder() { // ... }这里有个容易踩的坑注解里配的隔离级别只对进入代理方法时开启的新事务生效如果事务因为传播行为被合并到了外层事务内层方法指定的隔离级别会被忽略。所以想在服务里用不同隔离级别要特别注意事务传播机制否则你写了半天注解实际跑的还是外层的默认级别。6. 常见问题与避坑经验6.1 为什么 RR 下还能看到“幽灵数据”这个问题是我在线下分享时被问得最多的。先说结论REPEATABLE READ 并不是彻底消灭幻读它只是让“普通快照读”看不到新插入的数据。一旦事务里出现当前读比如SELECT ... FOR UPDATE、UPDATE、DELETEInnoDB 就会读最新已提交版本这时其他事务已经提交的新插入数据就会暴露出来。我用一个真实场景说明。有次一个同事反馈账务系统在 RR 下对账第一批查出来 1000 笔对账过程中有别的服务插入了新流水这批新流水在后续 UPDATE 中竟然被一起处理了导致重复入账。代码里的 UPDATE 就是当前读它没有沿用之前的快照自然看到了“新数据”。应对办法有两个方向要么在业务层禁止对同一范围先快照读再当前读保证读的方式统一要么对核心范围查询直接使用SELECT ... FOR UPDATE在当前读阶段就加间隙锁把插入挡在外面。如果场景实在复杂直接升级到 SERIALIZABLE 更省心。6.2 RC 与 RR 怎么选选隔离级别不是越严越好而是要匹配业务的一致性和并发压力。如果业务里大量存在“先查后写”的对账、统计、库存操作并且对数据一致性要求很高我建议保留默认的 REPEATABLE READ。它的快照读能让事务内的查询结果稳定间隙锁能挡掉大部分范围插入带来的幻读问题。如果系统已经用消息队列做了削峰核心数据做了幂等写入冲突可以靠业务补偿那 READ COMMITTED 往往会带来更好的并发表现。RC 少了很多间隙锁冲突死锁概率明显降低很多高并发交易系统的实际选择都是 RC 搭配 ROW 格式的 binlog。还要注意如果使用 READ COMMITTED 或 READ UNCOMMITTEDbinlog 格式必须设置为 ROW否则主从复制可能因为语句执行顺序不同而产生不一致。MySQL 会在binlog_formatSTATEMENT下把这些隔离级别直接拒绝掉这也是很多人在改隔离级别后遇到报错的原因。6.3 事务注解不生效排查点很多 Spring Boot 项目里事务隔离级别配置好了实际却完全不生效问题往往不在 MySQL而在应用层。最常见的三个坑第一是同类内部调用。orderService.update()里直接写this.otherMethod()这个调用没有经过 Spring 代理Transactional 自然失效。必须从外部 Bean 调用或者自己注入代理对象。第二是异常被吞掉。事务方法里try-catch把异常捕获后没有重新抛出Spring 只能看到“方法正常返回”从而执行提交而不是回滚。建议对需要回滚的异常原样抛出。第三是数据库连接已经脱离了当前事务。比如方法里手动调用了DataSourceUtils.releaseConnection或者连接被线程池拿走没归还都会让后续操作不在同一事务里。排查这类问题不要急着改代码先看日志里事务管理器是否打印了创建事务、提交回滚的日志再结合断点观察TransactionSynchronizationManager里的事务状态往往很快就能定位到是哪一层配置出了问题。6.4 长事务和 undo 膨胀的坑MVCC 的版本链是好东西但它有一个隐藏成本只要还有旧事务需要读取历史版本undo log 里的旧版本就不能被 purge 线程清理。一个事务只要一直不提交它持有的 ReadView 就会一直以为自己是事务开始那一刻导致后续所有更新操作的旧版本都要保留回滚段持续膨胀。我在一个订单系统里遇到过这种问题一个定时任务因为外部接口超时事务一直挂着没回滚也没提交半小时后整个库的 undo 表空间涨了 10 倍普通查询都开始变慢。排查手段很直接用 information_schema 看当前事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;如果发现trx_started离当前时间已经很久就可以定位到对应线程评估是回滚还是提交。生产环境一定要给事务设超时比如 JDBC 层的setQueryTimeout或者代码里避免在事务内做远程调用发消息、调外部 HTTP、等待锁这些操作都要尽量放到事务外。从我实际排查的经验来看隔离级别本身并不难理解难的是把它放进真实场景里判断“这条 SQL 究竟是快照读还是当前读”。很多线上诡异问题最后都绕回到这一条分界线上。如果以后你遇到事务表现不符合预期别急着怀疑数据库 bug先开两个会话手动复现一遍再顺着 ReadView 的生成时机和当前读的加锁范围去查思路会清晰得多。还有一个小技巧客户端工具里如果默认开了自动提交你看到的事务现象会和业务代码里完全不同做实验前一定要先SET autocommit0再手动BEGIN这样实验结论才可信。