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 靠“锁住读过的行”来保证可重复读。实际上,对于普通 SELECT,InnoDB 基本不加锁,而是通过 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 记录最近一次修改这行数据的事务 ID,DB_ROLL_PTR 则指向这条记录在 undo log 里的上一个版本。
当你给一行数据做 UPDATE 时,InnoDB 不会原地覆盖掉旧值,而是先把旧值链到 undo log 里,再生成一个新版本的行记录。新记录上的 DB_TRX_ID 被改成当前事务 ID,DB_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 lock
MVCC 管住了读,但管不住写冲突,写冲突必须靠锁。InnoDB 的锁体系里最核心的是三种:记录锁、间隙锁、临键锁。
记录锁就是锁住索引树上的一条具体记录,比如WHERE id = 5命中了 id=5 这行,记录锁就锁住这行。间隙锁锁的是两个索引记录之间的“空档”,目的是禁止其他事务在这个空档里插入新记录。临键锁是记录锁加间隙锁的组合,锁的是一个左开右闭的区间,比如索引值区间(3, 5],既锁住 id=5 的记录,也锁住 3 和 5 之间允许插入新值的空档。
为什么要用间隙锁来锁“空档”?因为并发插入是幻读的头号嫌疑。A 事务执行SELECT * FROM t_order WHERE id BETWEEN 3 AND 5 FOR UPDATE,如果不锁间隙,B 事务可以立刻插入一个 id=4 的新记录,那 A 事务的这个范围就多了一行,幻读就出现了。加了间隙锁后,B 的插入会被阻塞,直到 A 提交或回滚才解除。
注意一个细节:间隙锁和记录锁不同,它锁的是“不允许插入”,但它不阻塞另一个事务对已有记录的修改,因为它本身不锁定任何具体记录。这也是为什么间隙锁容易让人产生“明明锁了范围,为什么这行还能被 UPDATE”的困惑。
4.2 RC 与 RR 在加锁范围上的差异
READ COMMITTED 和 REPEATABLE READ 在加锁上的差异,是面试里最容易考细节的点。在 RC 下,InnoDB 只使用记录锁,不使用间隙锁。也就是说,如果 A 事务对 id BETWEEN 3 AND 5 执行当前读,最后只会锁住 id=3 和 id=5 这两条已存在记录,中间的 id=4 空档是可以被别的事务插入的。
在 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) ) ENGINE=InnoDB; 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_isolation:
SHOW 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 插入一条 id=3 的数据后提交:
-- 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 插入 id=4 后提交:
-- Session B INSERT INTO t_order VALUES (4, 104, 90.00); COMMIT;回会话 A 再执行普通 SELECT,还是只能看到 3 行,多了 id=4 也看不到。到此为止,RR 确实阻止了快照读层面的幻读。但如果会话 A 现在执行一条 UPDATE:
-- Session A UPDATE t_order SET amount = amount + 1; SELECT * FROM t_order WHERE id BETWEEN 1 AND 5;这条 UPDATE 会先做当前读,读取最新已提交版本,于是刚才被 B 插入的 id=4 也进入了更新范围,最终 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、DELETE,InnoDB 就会读最新已提交版本,这时其他事务已经提交的新插入数据就会暴露出来。
我用一个真实场景说明。有次一个同事反馈,账务系统在 RR 下对账,第一批查出来 1000 笔,对账过程中有别的服务插入了新流水,这批新流水在后续 UPDATE 中竟然被一起处理了,导致重复入账。代码里的 UPDATE 就是当前读,它没有沿用之前的快照,自然看到了“新数据”。
应对办法有两个方向:要么在业务层禁止对同一范围先快照读再当前读,保证读的方式统一;要么对核心范围查询直接使用SELECT ... FOR UPDATE在当前读阶段就加间隙锁,把插入挡在外面。如果场景实在复杂,直接升级到 SERIALIZABLE 更省心。
6.2 RC 与 RR 怎么选
选隔离级别不是越严越好,而是要匹配业务的一致性和并发压力。
如果业务里大量存在“先查后写”的对账、统计、库存操作,并且对数据一致性要求很高,我建议保留默认的 REPEATABLE READ。它的快照读能让事务内的查询结果稳定,间隙锁能挡掉大部分范围插入带来的幻读问题。
如果系统已经用消息队列做了削峰,核心数据做了幂等,写入冲突可以靠业务补偿,那 READ COMMITTED 往往会带来更好的并发表现。RC 少了很多间隙锁冲突,死锁概率明显降低,很多高并发交易系统的实际选择都是 RC 搭配 ROW 格式的 binlog。
还要注意:如果使用 READ COMMITTED 或 READ UNCOMMITTED,binlog 格式必须设置为 ROW,否则主从复制可能因为语句执行顺序不同而产生不一致。MySQL 会在binlog_format=STATEMENT下把这些隔离级别直接拒绝掉,这也是很多人在改隔离级别后遇到报错的原因。
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 autocommit=0,再手动BEGIN,这样实验结论才可信。