news 2026/9/9 3:01:31

深入Spring事务:隔离级别、传播机制与事务失效排查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入Spring事务:隔离级别、传播机制与事务失效排查实战

先声明一句,这篇不是拿着概念给你念一遍,而是把我过去几年在实际项目里和Spring事务打交道时踩过的坑、理清的思路、能用上的代码全部捋一遍。如果你正准备做订单、库存、支付、对账这类强一致性的业务,或者正在准备大厂面试,这篇文章应该能帮你把“事务隔离级别”和“事务传播机制”这块拼图彻底拼完整。

开发的都知道,Spring事务用起来很简单,无非一个@Transactional注解,但很多人就是在这上面翻车:事务没生效、隔离级别配了没效果、内层事务异常结果外层也回滚了、明明提交了但锁还被占住。要搞明白这些问题,光记住定义不够,得从底层来看这两个机制到底在干什么。

1. 事务的“地基”:为什么先讲ACID和后端并发控制

1.1 ACID到底约束了什么

事务的基石是四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。很多人背得滚瓜烂熟,但真要解释清楚“为什么有了原子性还不够”,还是会卡壳。

我习惯用一个转账例子把四个特性串起来:A账户给B账户转1000元,这个操作在数据库里至少拆成两条SQL——A账户扣1000,B账户加1000。原子性保证的是这两条SQL要么都成功,要么都失败,不存在只扣不加的情况。持久性保证的是事务一旦提交,即使数据库宕机,数据也不会丢,这块靠的是重做日志。

一致性在事务模型里有点抽象,它指的是事务执行前后,数据库的完整性约束不被破坏。比如账户余额不能为负数、库存不能超卖、总账必须相等。数据库底层其实不负责你的业务一致性,它只提供机制(约束、触发器、事务),业务一致性要靠程序员在SQL和代码里保证。

而隔离性,恰恰是这四个特性里最容易出问题的。因为并发情况下,多个事务同时在跑,如果完全不隔离,A事务读到了B事务没提交的数据,那账就算错了。

1.2 数据库并发控制的两条路线

说到隔离性,就绕不开数据库并发控制的实现方式。业界主要两条路线:悲观锁和MVCC。

悲观锁的思路很直接:你读或写这条数据之前,先把它锁住,别的操作只能等着。SELECT ... FOR UPDATE就是典型的悲观锁,它适合并发冲突激烈的场景,比如扣减库存。

MVCC(多版本并发控制)是另一条路,它不在读操作上互相阻塞,而是通过保存数据的多个历史版本,让读操作去读“快照”,从而实现读写不互斥。这也是MySQL InnoDB在性能上能扛住高并发读的原因。

Spring事务本身不实现这些并发控制,它只是把事务边界和配置翻译成底层数据库的行为。所以理解隔离级别,本质上是在理解“数据库愿意容忍哪些并发异常”。

2. 事务隔离级别:四个档位与三类异常

2.1 三类典型的并发异常

先讲三个异常,这是判断隔离级别的基础,也是面试官最爱连环问的点。

脏读:事务A修改了一条数据但还没提交,事务B就读到了修改后的值。如果事务A回滚了,事务B前面读到的就是一个数据库中从未真实存在过的“脏数据”。这种情况只出现在读未提交级别,危害最大。

不可重复读:事务A先读了一次某行记录,事务B在这期间修改并提交了这行记录,事务A再读一次,发现值变了。重点在于同一行数据,两次读到的结果不一致。

幻读:事务A按某个条件查出一批数据,事务B插入了一条满足条件的新记录并提交,事务A再用同样的条件查询,发现多了一行“凭空出现”的记录。重点在于记录数量变了。之前有同行问:“不可重复读和幻读怎么区分?”答案很朴素:一个是改(UPDATE),一个是增(INSERT),对应行数和内容的变化。

2.2 四种隔离级别怎么选

SQL标准定义了四个隔离级别,从宽松到严格依次是:

  • 读未提交(READ UNCOMMITTED):允许读取未提交数据,脏读、不可重复读、幻读都可能发生。
  • 读已提交(READ COMMITTED):只能读已提交数据,可以避免脏读,但不可重复读和幻读仍可能发生。
  • 可重复读(REPEATABLE READ):同一事务内多次读同一数据结果一致,避免脏读和不可重复读,但理论上仍可能幻读。
  • 串行化(SERIALIZABLE):事务之间完全串行执行,所有异常都被杜绝,但并发性能最低。

这里必须点名MySQL的“特例”:MySQL默认隔离级别是可重复读。可按照SQL标准,可重复读级别下幻读问题并没有被解决,但InnoDB存储引擎通过间隙锁(Gap Lock)和MVCC,在绝大多数场景下把幻读也解决了。

Oracle和PostgreSQL默认用的是读已提交,原因在于可重复读对某些复杂查询场景不够灵活,读已提交能在性能和一致性之间取得更好的平衡。

2.3 MySQL里的“快照读”和“当前读”

聊到可重复读,必须说清楚“快照读”和“当前读”。理解了这个,很多关于事务的怪问题都会迎刃而解。

快照读就是我们平时执行的普通SELECT语句。在可重复读隔离级别下,事务第一次快照读时生成一个ReadView,后续读都基于这个快照。不管别的事务提交了什么,你看到的就是事务开始时的那个“历史世界”。这也是MySQL可重复读能防住幻读的核心手段。

当前读则不一样,SELECT ... FOR UPDATEUPDATEDELETEINSERT操作都会读当前已提交的最新数据,并对涉及的行加锁。为了防幻读,InnoDB在可重复读下还会对扫描范围加上间隙锁,锁住一个区间,保证别的事务无法插入新记录。

这也引出一个重要结论:如果你在可重复读级别下先SELECT出来一批数据,再在代码里判断数量、然后INSERT新记录,判断逻辑可能基于旧快照,但写入瞬间另一个事务可能已经插入并通过了当前读加锁,你得依赖唯一索引或锁来兜底。

3. Spring事务隔离级别的配置姿势

3.1 @Transactional注解怎么控制隔离级别

Spring里通过@Transactionalisolation属性指定隔离级别:

@Transactional(isolation = Isolation.READ_COMMITTED) public void updateOrderAmount(Long orderId, BigDecimal amount) { // 业务逻辑 }

Isolation枚举对应关系如下:

Spring枚举数据库实现
DEFAULT使用底层数据库默认隔离级别
READ_UNCOMMITTED读未提交
READ_COMMITTED读已提交
REPEATABLE_READ可重复读
SERIALIZABLE串行化

单独设置isolation = Isolation.DEFAULT是最常见的,因为MySQL默认可重复读,Oracle默认读已提交,大多数场景下交给数据库默认值就够。

需要自己要改级别时,建议优先考虑读已提交。尤其在高并发统计、报表、多步查询场景里,可重复读的快照虽然稳定,但如果事务时间较长,会导致undo log不能及时清理,MVCC版本链变得很长,访问性能反而下降。读已提交每次读拿最新快照,版本链短,空间回收更及时。

3.2 实操演示:配置隔离级别后的效果差异

拿一个线上真实场景来演示:结算系统需要对订单金额做二次校验,流程是先读金额,做一系列计算,再读一次金额。

在可重复读级别下,两次读到的金额一定相同,哪怕中间订单金额被另一个已提交事务修改,这里计算结果依然基于旧值。这在“读取期间数据必须保持一致”的场景下是特性;但在“需要实时感知最新数据”的场景下,可能就是安全漏洞。

要模拟不可重复读,把事务隔离级别降到读已提交,然后开两个会话:

-- 事务A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT amount FROM t_order WHERE order_id = 1001; -- 此时事务B修改金额为999并提交 -- 事务A再次查询 SELECT amount FROM t_order WHERE order_id = 1001;

第二个SELECT会看到999,这就是不可重复读。Spring层面配置Isolation.READ_COMMITTED后,@Transactional会把这个会话级设置下发给数据库连接,效果与手写SQL一致。

有一个实操心得值得单独说:隔离级别的配置粒度是“事务”,不是“数据库配置”。同一个连接池里的连接在每次获取时都会根据事务注解重置隔离级别,所以你不用担心一个事务设置了读未提交会影响别的线程。

4. 事务传播机制:七种行为逐一拆解

隔离级别解决的是“一个事务内部多次读怎么写”,传播机制解决的是“多个方法之间怎么共享事务边界”。这是两个维度,千万别混淆。

4.1 REQUIRED:默认的“同生共死”

REQUIRED是Spring默认的传播行为:如果当前没有事务,就新建一个事务;如果当前已有事务,就加入当前事务。绝大多数业务用这个就够了。

@Service public class OrderService { @Transactional public void createOrder(OrderDO order) { orderMapper.insert(order); inventoryService.deduct(order.getProductId(), order.getQuantity()); } } @Service public class InventoryService { @Transactional(propagation = Propagation.REQUIRED) public void deduct(Long productId, Integer quantity) { inventoryMapper.deduct(productId, quantity); } }

createOrder开启事务后,deduct方法检测到已经存在事务,就选择加入。两个方法在同一个物理事务里,任何地方抛异常,整个业务都会回滚。这也是下单和扣库存场景里最标准的写法。

这里有个细节容易忽略:加入同一个事务意味着两个方法使用的是同一个数据库连接,锁也是同一个事务持有的。如果deduct执行的SQL耗时较长,那订单表所在的连接也一直被占用,连接池和锁的占用时间会比较长。

4.2 REQUIRES_NEW:独立事务的“保护舱”

REQUIRES_NEW的作用是挂起当前事务,然后新开一个完全独立的事务。新事务提交或回滚,完全不影响外部事务。

这个机制最常见的应用场景是写业务日志和操作记录。比如订单创建失败要回滚,但失败原因必须记录下来。如果日志写入在同一个事务里,回滚会把日志一起回滚,等于什么都没留下。我用REQUIRES_NEW把日志写入隔离到独立事务里,外层怎么回滚,日志都能保存。

@Transactional public void handleOrder() { try { // 核心业务,可能抛出异常 } catch (Exception e) { operationLogService.saveLog(orderId, e.getMessage()); throw e; } } @Service public class OperationLogService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveLog(Long orderId, String message) { logMapper.insert(...); } }

需要留意的是,REQUIRES_NEW会短暂挂起外层事务,如果外层事务已经持有行锁,内层事务去操作同一行数据,就会有锁等待甚至死锁。我之前在一个定时任务里给大量订单做状态流转,内部调了一个REQUIRES_NEW的方法去更新同一条订单记录,结果偶发性死锁。后来把更新操作收敛到内层事务里一次完成,才彻底解决。

4.3 NESTED:嵌套事务与Savepoint

NESTED是“有保存点的嵌套事务”。如果当前没有事务,它就和REQUIRED一样新建事务;如果有事务,就设置一个保存点(Savepoint),内层方法的回滚只回滚到保存点位置,不会把外部事务整个回滚。

听起来和REQUIRES_NEW很像,本质区别在于:REQUIRES_NEW是物理上的独立连接、独立事务;NESTED还是同一个物理事务,只是多了保存点。外层如果最终回滚,内层即使已经“提交”了保存点,也会跟着一起回滚。

典型场景是批量处理数据。循环处理一批任务,单个任务失败不应该影响其他任务,但主事务又希望统一控制最终是否整体提交。Nested模式下,每个子任务失败只回滚本任务的保存点。

需要知道一个限制:NESTED是依赖数据库保存点能力的,MySQL和PostgreSQL都支持,但某些场景下连接池代理或者特殊数据库实现可能有问题。选型时要确认底层数据库真的支持SAVEPOINT。

4.4 SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER

四种不太常用的传播行为,我直接结合业务场景说明:

SUPPORTS:有事务就加入,没有事务就以非事务方式执行。适合那些“有事务更好,没事务也行”的辅助查询方法。比如一个查询方法,如果被事务内调用就参与事务,独立调用也无所谓。

MANDATORY:强制要求当前必须存在事务,否则抛异常。我一般用在那些“不能脱离事务独立执行”的内部处理逻辑上,比如数据一致性校验方法。如果测试代码忘了开事务,一调就抛IllegalTransactionStateException,能最早暴露问题。

NOT_SUPPORTED:如果当前有事务,先把事务挂起,然后以非事务方式执行。适合那些“事务里执行反而危险”的操作,比如在一段很耗时的外部接口调用、或者一些基础数据清理操作。不过要谨慎,挂起事务本身也是有代价的,连接还是会占着。

NEVER:当前存在事务就抛异常。用的比较少,适合在绝对不允许事务的操作上兜底,比如某些需要立即感知DDL结果或AUTO COMMIT的脚本方法。

5. 从理论落到业务:下单、扣库存与事务提交后释放锁

5.1 经典场景:订单和库存的事务边界

“订单与库存分布式事务”是热搜词,也是面试高频题。先说单体应用下的标准做法:订单表、库存表在同一个库里,直接用一个@Transactional方法把“插入订单”和“扣减库存”包起来。

@Transactional public void createOrderWithDeduct(OrderCreateRequest request) { OrderDO order = new OrderDO(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setAmount(request.getAmount()); orderMapper.insert(order); int rows = inventoryMapper.deduct(request.getProductId(), request.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足"); } }

这段代码的关键在于:deduct方法用带条件的UPDATE来实现原子扣减:

UPDATE t_inventory SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity};

返回影响行数来判断是否扣减成功。如果单纯先SELECTUPDATE,并发下肯定超卖。先查再改这个方案在事务里看起来安全,但不是真正的原子操作,必须靠条件更新或FOR UPDATE,而条件更新是性能最好的。

在单体架构下,这个方案已经能满足一致性要求。问题更多出在微服务拆分开以后:订单服务和库存服务不在一个库,一个本地事务无论如何也覆盖不了两个库。这就要谈分布式事务了。

5.2 事务提交后释放锁的正确姿势

还有一个业务上很容易踩的坑:在事务里给订单加锁,然后在事务内释放锁。

@Transactional public void payOrder(Long orderId) { OrderDO order = orderMapper.selectByIdForUpdate(orderId); // 业务处理 redisLock.unlock(orderId); // 错误示范 }

问题在于,unlock执行时事务还没提交。其他线程在提交前看到的还是未提交数据,如果这个线程又抢到锁去处理同一订单,很可能会读到旧状态,产生重复处理或数据错乱。

正确做法是把锁释放挪到事务提交之后:

@Transactional public void payOrder(Long orderId) { OrderDO order = orderMapper.selectByIdForUpdate(orderId); // 业务处理 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { redisLock.unlock(orderId); } } ); }

更优雅的方式是用@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)监听事务提交事件。这个方案能保证:事务回滚时不释放锁(因为afterCommit不会执行),事务提交后立刻释放锁,业务逻辑和锁管理完全解耦。这个细节在面试中很加分,实际开发中也能避免一类很难排查的并发BUG。

5.3 分布式事务简析:从本地事务到Seata

如果业务真的拆分成了订单服务和库存服务两个独立应用,各自连各自的库,本地事务已经无能为力。这时候业界有几种主流方案:

  • 两阶段提交(2PC):数据库层面协调多个资源,强一致但性能差,高并发场景基本不用。
  • TCC(Try-Confirm-Cancel):业务层面实现补偿逻辑,性能好,但侵入性强,每个操作都要写Try、Confirm、Cancel三个方法。
  • 本地消息表:在一个本地事务里写业务数据和消息表,然后通过MQ消费消息驱动下游操作,属于最终一致性方案。
  • Seata AT模式:对业务代码侵入极小,核心原理是拦截SQL,解析出前镜像和后镜像,在全局事务提交时对比数据,冲突则回滚,类似对所有分支事务实现自动补偿。

拿Seata的AT模式举例,一个分布式事务包含了全局事务ID(XID)的分发传递。调用链上每个服务都通过拦截器把XID传到下一个服务,当全局事务需要回滚时,TC(事务协调器)通知所有分支事务,AT模式根据undo_log里的镜像数据反向补偿。

分布式事务的本质是CAP理论里的取舍:你要强一致,就要接受性能损失;你要高可用和高性能,就必须走最终一致性。Spring的传播机制在这种场景下仍然有用,但它只是本地事务的“一种编写约定”,管不到别的服务。

6. 事务失效排查实录:这些坑我都踩过

6.1 自调用为什么会让事务失效

这是事务领域第一大坑:同类内部方法调用,@Transactional完全失效。

@Service public class UserService { public void outerMethod() { // 事务不生效 this.innerMethod(); } @Transactional public void innerMethod() { // 数据库操作 } }

原因在于Spring事务默认走的是AOP代理。@Transactional生效的前提是调用方拿到的是代理对象,代理对象会在方法前后开启、提交、回滚事务。但this.innerMethod()调用的是当前对象自身,也就是原始对象,根本没有代理参与,事务自然不生效。

解决思路有三个:第一,把innerMethod拆到另一个Bean里,通过注入依赖调用;第二,注入ApplicationContext,显式获取代理Bean;第三,把事务逻辑放到TransactionTemplate里,手动控制事务边界,代码直白还不容易出错。

我个人的倾向是拆Bean。把“带事务的原子操作”放到独立的Service组件里,调用方自然就用了代理,语义也清楚。

6.2 事务失效原因的完整清单

我整理了一张排查清单,每一行都是真实项目里碰到过的:

场景原因解决方式
方法不是publicSpring默认用CGLIB或JDK动态代理,非public方法不经过代理改成public
同类自调用this调用绕过代理拆Bean或使用AopContext.currentProxy()
异常被catch事务感知不到异常,自然不回滚catch里重新抛出RuntimeException,或使用TransactionTemplate
抛出检查异常RuntimeException以外的异常默认不回滚rollbackFor = Exception.class
数据库引擎不支持事务比如MyISAM表改用InnoDB
方法通过this调用静态方法等代理失效确保经过Spring容器Bean调用
finally里释放资源异常可能掩盖原始异常导致事务结果异常谨慎处理finally中的资源释放

6.3 快速定位事务是否生效的手段

排查事务问题,我一般按以下顺序来:

第一步,检查日志级别。在application.yml里加一行配置:

logging: level: org.springframework.transaction.interceptor: TRACE

启动后看日志中是否出现“Getting transaction for [方法名]”和“Completing transaction”这类关键信息。没有就说明事务压根没被代理。

第二步,代码里确认当前是否存在活动事务:

boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();

在方法入口打点,输出这个值,能立刻判断当前代码是否运行在事务中。

第三步,确认异常类型。如果是FileNotFoundExceptionIOException这类检查异常,默认不会触发回滚。解决方式是在注解上加上rollbackFor = Exception.class,或者直接抛出RuntimeException

7. 给新手的几个实操原则与我的体会

7.1 尽量避免在事务里做远程调用

事务内做HTTP调用、RPC调用、MQ发送,是性能与死锁的大杀器。事务期间数据库连接和行锁一直被占着,如果被调用的服务响应慢、甚至调用了当前服务,会造成连接池耗尽,极端情况下两个服务互相等锁,形成分布式死锁。

建议的方式是:本地事务里只写业务数据和消息表,事务提交后再发送MQ或调用远程接口。这也是“本地消息表”这种最终一致性方案的核心思路,既保证了数据落地,又把连接占用时间压到了最短。

我在实际项目里的要求只有一条:事务方法里禁止任何网络IO。如果确实要在事务后通知下游,就用@TransactionalEventListener加MQ。

7.2 事务粒度不是越小越好,要匹配业务边界

有人觉得事务越小越好,于是把一次业务操作拆成多个短事务。但事务粒度过小,中间状态会暴露给其他线程,比如“订单已创建但金额未计算完成”的中间状态,如果被定时任务扫描到,可能被误处理。

我的经验是:按“业务不变量”切事务。需要一起成功或一起失败的数据变更,放一个事务;不要求强一致的辅助信息,拆出去。比如创建订单和扣库存必须同生共死,那就放一个事务;但发送站内信这种丢了也无伤大雅的,没必要参与进来。

7.3 事务隔离级别和传播机制选型建议

最后给一个可以直接当模板的选型清单:

  • 普通增删改:REQUIRED,默认隔离级别。
  • 查询类服务:SUPPORTS,让调用方决定是否参与事务。
  • 操作日志、账单流水:REQUIRES_NEW,防止核心事务回滚时连累日志。
  • 强制校验类方法:MANDATORY,防止被无事务调用方误用。
  • 长耗时外部交互:NOT_SUPPORTED,尽量别让数据库事务等外部IO。
  • 批量处理有独立失败点:NESTED,用保存点隔离失败任务。

7.4 一个让我记忆深刻的复盘

前两年做一个结算改造,一个方法内有十几个数据库操作,当时为了图快,把几个子方法全部标了REQUIRES_NEW。上线后出了一次数据异常:主事务回滚了,但某个子事务已经提交,导致“主单被回滚,流水却留在库里”。

那次复盘后,我把所有REQUIRES_NEW都重新审视了一遍,能改REQUIRED的全都改了,只在真正需要独立提交的日志表和异步消息表上保留独立事务。从那以后,这个模块再也没有出现过数据不一致。

事务这个东西,表面上是个注解,背后其实是数据库和业务架构的取舍。多花点时间把隔离级别、传播机制和它们与锁的关系弄明白,省下的排查时间绝对翻倍。上面这些经验和方法,都是我从一个个线上故障里抠出来的,建议你在项目里也用最小成本先复盘一遍自己的事务写法,该清理的清理,该补配置的补配置。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 3:00:46

架构设计实战:从约束识别到平滑演进的五步方法论

架构设计大概是技术圈里被讨论最多、但真正做明白的人最少的话题之一。几乎每个团队都有一堆架构设计文档,但大多数都停留在“画了几张框图”的层面,从来没有真正指导过代码的落地。我自己带过多个从零到一的项目,也接手过不少“历史遗留系统…

作者头像 李华
网站建设 2026/9/9 3:00:43

PyTorch性能调优实战:从Profiling到torch.compile与分布式扩展

干我们这行的都清楚,模型“能跑”和“能打”完全是两码事。同样的训练脚本,A 同学一卡一天跑完,B 同学三卡三天还在等,差距往往不在算法而在工程细节。PyTorch 性能调优这件事,很多同学是等项目卡住了才想起来做&#…

作者头像 李华
网站建设 2026/9/9 3:00:19

会议室门牌选型指南:工业级硬件的高耐磨与防潮设计

1. 会议室门牌到底难在哪:被低估的使用场景 1.1 你以为是块屏,其实是台“常年不关机的户外设备” 会议室门牌这东西,乍一看就是个挂在墙上的小屏幕,显示一下“空闲”“使用中”“请勿打扰”之类的状态,看起来非常简单…

作者头像 李华
网站建设 2026/9/9 2:59:47

全栈工程师的真正定义:不止前端+后端,而是端到端交付能力

先别急着往下翻,我问你一个问题:你现在脑子里对“全栈工程师”的定义,是不是还停留在“能写前端页面,也能写后端接口,一个人把活全干了”这个层面?如果你点头了,那这篇文章你应该好好看下去。我…

作者头像 李华