
Spring事务嵌套场景下的隔离级别与传播行为实验:一次深度的踩坑与解惑之旅
在基于Spring框架开发企业级应用时,事务管理是绕不开的核心话题。我们常常自信地使用 @Transactional 注解,直到在复杂的业务逻辑中遇到了嵌套方法调用——这时,事务的传播行为(Propagation)和隔离级别(Isolation)才真正开始“教我们做人”。我曾在一个批量处理订单和库存更新的模块中栽了跟头,内层事务的回滚意外影响了外层事务,导致数据状态混乱。今天,我就通过一个完整的实验,带你亲手揭开Spring嵌套事务的神秘面纱,理解那些“反直觉”现象背后的原理。
一、实验环境搭建与基础概念回顾
首先,我们快速搭建一个实验环境。使用Spring Boot 2.7+,依赖Spring Data JPA和H2内存数据库,这样我们可以专注于事务行为本身。核心是理解两个关键属性:
- 传播行为(Propagation):定义了当前事务方法被另一个事务方法调用时,该如何进行。例如,
REQUIRED(默认)表示如果当前存在事务,则加入该事务;如果不存在,则创建一个新事务。 - 隔离级别(Isolation):定义了事务在并发环境下,数据的可见性规则。例如,
READ_COMMITTED(默认)可以防止脏读,但可能发生不可重复读和幻读。
我们创建一个简单的实体 User 和对应的 UserRepository。实验的核心是一个服务类,其中包含两个方法,模拟嵌套调用。
// 实体类
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer balance; // 账户余额,用于模拟更新
// 省略 getter/setter
}
// 服务类 - 核心实验场地
@Service
public class TransactionExperimentService {
@Autowired
private UserRepository userRepository;
// 外层方法
@Transactional(propagation = Propagation.REQUIRED)
public void outerMethod(Long userId1, Long userId2) {
User user1 = userRepository.findById(userId1).orElseThrow();
user1.setBalance(user1.getBalance() - 100);
userRepository.save(user1);
System.out.println("外层方法: 用户1扣款100完成");
try {
// 调用内层方法
innerMethod(userId2);
} catch (Exception e) {
System.out.println("外层方法捕获到内层异常: " + e.getMessage());
// 关键点:这里是否继续抛出异常,会影响外层事务
}
// 外层方法其他操作...
System.out.println("外层方法继续执行...");
}
// 内层方法 - 我们将在这里调整注解,观察不同传播行为
@Transactional(propagation = Propagation.REQUIRED) // 默认,我们将修改这里
public void innerMethod(Long userId2) {
User user2 = userRepository.findById(userId2).orElseThrow();
user2.setBalance(user2.getBalance() + 100);
userRepository.save(user2);
System.out.println("内层方法: 用户2加款100完成");
// 模拟内层方法发生异常
throw new RuntimeException("内层方法故意抛出的异常!");
}
}
二、经典踩坑场景:REQUIRED + 默认隔离级别
让我们运行第一个实验。内外层方法都使用默认的 Propagation.REQUIRED。启动Spring Boot应用,调用 outerMethod。
你看到了什么? 控制台输出显示,内层方法抛出异常后,被外层方法的try-catch块捕获。但是,当你检查数据库时,会发现用户1的扣款操作也被回滚了!尽管异常被捕获,外层方法看似“平静”地执行完了。
原理剖析: 在默认的REQUIRED传播行为下,当outerMethod启动一个事务后,innerMethod会加入到这个已有的事务中。这意味着它们共享同一个物理事务连接。当innerMethod抛出RuntimeException时,Spring会标记当前事务为rollback-only。即使外层方法捕获了异常,在事务最终提交时,Spring检测到事务状态已是rollback-only,会强制回滚整个事务。这就是那个经典的坑:“你以为你抓住了异常,其实事务早已注定失败。”
实战提示: 在业务编码中,如果你不希望内层事务的失败影响外层,就不能简单地使用默认的REQUIRED并捕获异常。必须考虑改变传播行为。
三、拯救方案:REQUIRES_NEW 传播行为
如何让内层事务的失败独立回滚,不影响外层事务?答案是使用 Propagation.REQUIRES_NEW。
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod(Long userId2) {
// ... 方法体不变
}
再次运行实验。这次,奇迹发生了:数据库显示用户1扣款成功(未回滚),而用户2的加款操作因为内层事务回滚而没有生效。控制台显示内层异常被捕获,外层方法继续执行完毕。
原理剖析: REQUIRES_NEW会暂停当前存在的事务(如果有),创建一个全新的、独立的事务。这个新事务拥有自己独立的连接和提交/回滚生命周期。因此,它的回滚不会污染外层事务。注意,这里涉及事务的挂起和恢复,是有性能开销的。
踩坑提示: 使用REQUIRES_NEW时,务必确保内层方法不需要外层事务中的某些尚未提交的数据(因为外层事务被挂起了)。同时,要小心数据库死锁的风险,因为现在可能同时存在多个活跃的事务在操作相同的数据行。
四、隔离级别的干扰实验:READ_UNCOMMITTED 与 READ_COMMITTED
传播行为搞清楚了,我们再把隔离级别加进来“搅局”。修改外层方法,模拟一个长时间运行的事务,并在其中读取内层REQUIRES_NEW事务未提交的数据。
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_COMMITTED)
public void outerMethod(Long userId1, Long userId2) throws InterruptedException {
User user1 = userRepository.findById(userId1).orElseThrow();
user1.setBalance(user1.getBalance() - 100);
userRepository.save(user1);
System.out.println("外层方法(READ_COMMITTED): 用户1扣款100");
// 启动一个异步线程,模拟另一个内层事务
new Thread(() -> {
// 这里需要从Spring容器获取代理对象调用,以确保事务生效,为简化省略
// innerMethodWithRequiresNew(userId2);
}).start();
Thread.sleep(500); // 等待内层事务执行更新但未提交
// 外层事务再次读取用户2
User user2BeforeCommit = userRepository.findById(userId2).orElseThrow();
System.out.println("外层事务在内层事务提交前读取用户2余额: " + user2BeforeCommit.getBalance());
}
我们设计两个实验:
- 实验A(默认 READ_COMMITTED):外层事务在等待后读取到的用户2余额,是内层事务更新前的旧值。因为它看不到其他未提交事务的修改。
- 实验B(改为 READ_UNCOMMITTED):将外层事务隔离级别改为
Isolation.READ_UNCOMMITTED。这时,外层事务可能会读取到内层事务已修改但未提交的余额(+100后的值)。这就是脏读!如果内层事务最终回滚,那么外层事务读到的就是一个“幽灵数据”。
实战感悟: 在嵌套事务中,尤其是使用REQUIRES_NEW时,隔离级别的选择变得异常重要。默认的READ_COMMITTED在大多数场景下是安全的底线。盲目降低隔离级别以求性能,在嵌套事务的复杂交互下,很可能引发难以追踪的数据一致性问题。
五、其他传播行为速览与总结
限于篇幅,我们无法逐一实验所有7种传播行为,但可以快速总结其核心区别:
- SUPPORTS:有事务就加入,没有就以非事务运行。适合查询方法。
- MANDATORY:强制要求存在事务,否则抛异常。用于严格依赖事务上下文的场景。
- NOT_SUPPORTED:以非事务方式执行,并挂起当前事务。用于不需要事务的批量日志记录等。
- NEVER:必须在非事务环境下执行,否则抛异常。
- NESTED:创建一个嵌套的“子事务”。它是一个非常重要的替代方案。与
REQUIRES_NEW不同,NESTED使用保存点(Savepoint)机制。内层事务回滚不会导致外层事务回滚,但外层事务回滚会连带内层一起回滚。注意:需要JDBC驱动和底层数据库支持保存点(如MySQL的InnoDB支持)。
最终建议:
- 明确需求:在设计嵌套事务时,首先要问“内层事务的失败,是否应该导致外层事务失败?”
- 默认谨慎:无特殊需求,坚持使用默认的
REQUIRED和READ_COMMITTED。在捕获异常时,必须意识到事务可能已标记为回滚。 - 考虑 NESTED:如果希望内层失败不影响外层,且数据库支持,
NESTED是比REQUIRES_NEW更轻量、关联性更强的选择。 - 务必测试:事务行为与数据库、驱动版本有关。任何涉及嵌套事务和隔离级别变更的代码,都必须编写集成测试,模拟并发和异常场景。
希望这次深入的实验能帮助你构建起对Spring嵌套事务的直观理解。事务无小事,理解其行为,方能写出健壮可靠的业务代码。纸上得来终觉浅,绝知此事要躬行,赶紧打开IDE,动手实验一下吧!

评论(0)