某电商系统在大促期间出现了一个诡异的问题:订单创建失败后,审计日志也没有记录。开发者明明在catch块里调用了审计服务的save方法,为什么数据还是消失了?排查日志后发现了这行异常:
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
这个异常揭示了一个被无数开发者误解的核心概念:事务传播行为(Transaction Propagation)。Spring框架提供了七种传播行为,每一种都有其特定的设计目的和适用场景。但正是这种灵活性,让很多开发者在选择时感到困惑——为什么会有七种?什么时候该用REQUIRES_NEW?NESTED和REQUIRES_NEW有什么区别?
物理事务与逻辑事务:理解传播行为的基础
在深入七种传播行为之前,必须先理解Spring对事务的一个关键抽象:物理事务与逻辑事务的分离。
物理事务是数据库层面的事务——通过JDBC的connection.setAutoCommit(false)开启,通过commit()或rollback()结束。这是真正存在于数据库中的事务边界。
逻辑事务则是Spring框架层面的概念。每一个被@Transactional注解的方法都代表一个逻辑事务。当一个标记为@Transactional的方法调用另一个同样被注解的方法时,Spring需要决定:这两个逻辑事务是共享同一个物理事务,还是各自独立?

图片来源: Spring Framework Documentation - Transaction Propagation
上图展示了REQUIRED传播行为的典型场景:outer方法开启一个物理事务,inner方法的逻辑事务"加入"这个已存在的物理事务。从数据库的角度看,只有一个事务在运行。
这种抽象带来的问题是:如果inner方法抛出异常并标记了rollback-only,outer方法捕获异常后想要继续执行其他操作,整个物理事务已经被标记为回滚状态——任何后续的数据库操作都会失败。这正是开头那个审计日志消失的根本原因。
七种传播行为的设计哲学
Spring的事务传播行为定义在Propagation枚举中:
public enum Propagation {
REQUIRED(0),
SUPPORTS(1),
MANDATORY(2),
REQUIRES_NEW(3),
NOT_SUPPORTED(4),
NEVER(5),
NESTED(6);
}
REQUIRED:默认选择的深层原因
REQUIRED是默认传播行为,其语义是"如果当前存在事务,则加入该事务;如果不存在,则创建一个新事务"。这个设计并非随意选择。
从历史角度看,Spring的事务管理深受EJB(Enterprise JavaBeans)的影响。EJB 2.x时代,事务管理是容器强制的,开发者需要通过复杂的XML配置来声明事务属性。EJB 3.0简化了这一过程,提供了@TransactionAttribute注解,其中REQUIRED同样是默认值。
Rod Johnson在2002年出版的《Expert One-on-One J2EE Design and Development》中,对EJB的复杂性和重量级提出了尖锐批评。他在书中写道:“EJB提供的事务管理功能强大,但配置繁琐,学习曲线陡峭。“Spring的设计哲学是"让简单的事情保持简单”——因此,最常见的场景(加入现有事务或创建新事务)成为默认行为。
从实现层面看,REQUIRED行为对应的核心逻辑在AbstractPlatformTransactionManager.getTransaction()方法中:
if (isExistingTransaction(transaction)) {
// 存在事务,根据传播行为处理
return handleExistingTransaction(definition, transaction, debugEnabled);
}
// 不存在事务,创建新事务
return startTransaction(definition, transaction, debugEnabled, suspendedResources);
REQUIRES_NEW:独立事务的隐藏代价
REQUIRES_NEW的语义是"始终创建一个新事务,如果当前存在事务,则将其挂起”。这个行为看似简单,实则隐藏着严重的性能和资源问题。

图片来源: Spring Framework Documentation - Transaction Propagation
当使用REQUIRES_NEW时,Spring需要"挂起"外层事务。这里的"挂起"在JDBC层面意味着:保留当前数据库连接的引用,但从ThreadLocal中清除,然后获取一个新的连接。这带来了两个问题:
连接池耗尽风险。假设连接池大小为10,当前有10个线程各自持有一个连接(外层事务)。如果这10个线程同时调用一个REQUIRES_NEW的方法,每个线程都需要再获取一个新连接——但池中已经没有可用连接了。结果:所有线程都在等待连接,系统陷入死锁。
Spring官方文档对此有明确警告:
“This may lead to exhaustion of the connection pool and potentially to a deadlock if several threads have an active outer transaction and wait to acquire a new connection for their inner transaction, with the pool not being able to hand out any such inner connection anymore.”
数据库锁冲突。外层事务可能已经持有某些行的锁,内层事务(新连接)如果尝试操作相同的表或相关表,可能在数据库层面产生死锁。这不是Spring能控制的,而是数据库层面的锁机制导致的。
因此,REQUIRES_NEW的使用应该极其谨慎。其典型的适用场景是:审计日志、通知发送、指标记录等与主业务逻辑无关的"副作用"操作。这些操作即使主事务回滚,也应该持久化。
NESTED:JDBC Savepoint的精妙实现
NESTED是Spring相对于EJB的一个重要扩展。EJB 3.0只定义了六种事务属性(MANDATORY、REQUIRED、REQUIRES_NEW、SUPPORTS、NOT_SUPPORTED、NEVER),Spring在此基础上增加了NESTED。
NESTED的语义是"如果当前存在事务,则在嵌套事务中执行;否则,行为与REQUIRED相同"。这里的"嵌套事务"并非真正的事务嵌套(数据库层面不存在这种概念),而是通过JDBC Savepoint实现的。
JDBC 3.0规范(JSR-54)在2002年5月正式发布,引入了Savepoint接口。Savepoint允许在一个事务中创建标记点,之后可以回滚到这个标记点而非整个事务:
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
Statement stmt = conn.createStatement();
stmt.executeUpdate("INSERT INTO orders (id, status) VALUES (1, 'PENDING')");
// 创建保存点
Savepoint savepoint = conn.setSavepoint("before_order_items");
try {
stmt.executeUpdate("INSERT INTO order_items (order_id, product_id) VALUES (1, 100)");
} catch (SQLException e) {
// 只回滚到保存点,保留orders表的插入
conn.rollback(savepoint);
}
conn.commit();
Spring的NESTED传播行为正是对这一机制的封装。当进入一个NESTED方法时,Spring会在当前连接上创建一个Savepoint;当方法正常返回时,释放Savepoint;当方法抛出异常时,回滚到Savepoint。
NESTED与REQUIRES_NEW的关键区别在于:
| 特性 | NESTED | REQUIRES_NEW |
|---|---|---|
| 物理事务数量 | 1个 | 2个(外层+内层独立) |
| 数据库连接数量 | 1个 | 2个 |
| 外层回滚的影响 | 内层操作一起回滚 | 内层操作不受影响 |
| 内层回滚的影响 | 外层可继续 | 外层不受影响 |
| 连接池压力 | 无额外压力 | 双倍压力 |
NESTED的局限性在于它依赖于数据库对Savepoint的支持。虽然大多数现代数据库(MySQL、PostgreSQL、Oracle、SQL Server)都支持Savepoint,但在某些特殊场景下(如Oracle的XA事务)可能会有限制。
边界控制的艺术:SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER
这四种传播行为的设计目的并非定义事务边界,而是声明对事务边界的期望。
SUPPORTS:如果存在事务则加入,否则以非事务方式执行。这适用于查询操作,它既可以在事务内执行(读取一致性视图),也可以独立执行。
MANDATORY:必须在事务内执行,否则抛出异常。这是一个强约束,用于确保某个方法永远不会被错误地以非事务方式调用。典型的场景是核心业务操作——如果有人直接调用而不在事务上下文中,应该立即失败而非默默执行。
NOT_SUPPORTED:以非事务方式执行,如果存在事务则将其挂起。这适用于某些不适合在事务内执行的操作,如长时间的文件处理、外部API调用等。
NEVER:以非事务方式执行,如果存在事务则抛出异常。这是一个防御性设计,确保某些操作永远不会意外地在事务上下文中执行。
从EJB到Spring:事务传播的演进
理解Spring事务传播行为的设计,离不开对EJB历史的了解。1998年,EJB 1.0作为J2EE(Java 2 Platform, Enterprise Edition)的一部分发布,旨在为 Java企业级开发提供一套标准化的架构。事务管理是EJB的核心功能之一。
EJB 1.x和2.x时代,事务管理通过部署描述符(ejb-jar.xml)配置:
<assembly-descriptor>
<container-transaction>
<method>
<ejb-name>OrderService</ejb-name>
<method-name>createOrder</method-name>
</method>
<trans-attribute>Required</trans-attribute>
</container-transaction>
</assembly-descriptor>
这种配置方式虽然功能强大,但极其繁琐。一个中型项目可能有上百个EJB,每个都需要配置事务属性。更糟糕的是,EJB要求所有企业级功能(事务、安全、远程访问)都必须通过容器提供,这导致了大量的样板代码和复杂的部署流程。
Rod Johnson在开发他的咨询项目时,深刻体会到了EJB的这些问题。他在《Expert One-on-One J2EE Development without EJB》(2004年出版)中详细描述了Spring的设计理念:通过POJO(Plain Old Java Object)和依赖注入来实现企业级功能,而非依赖重型容器。
Spring 1.0在2004年3月发布,其中@Transactional注解的设计直接借鉴了EJB的@TransactionAttribute,但简化了配置——开发者只需要在方法或类上添加注解即可,无需XML配置。更重要的是,Spring的事务管理不需要应用服务器,可以在任何Java环境中运行。
关于NESTED传播行为的加入,一个有趣的细节是:EJB规范明确禁止嵌套事务。Oracle官方文档中明确写道:“Each method can be associated with a single transaction. Nested or multiple transactions are not allowed within a method.“Spring添加NESTED的支持,正是基于JDBC 3.0引入的Savepoint机制,为开发者提供了更细粒度的事务控制能力。
实现原理:AOP代理与ThreadLocal
Spring事务管理的核心是两个组件:AOP代理和TransactionSynchronizationManager。
AOP代理机制
当Spring检测到一个Bean被@Transactional注解时(无论是类级别还是方法级别),它会为这个Bean创建一个代理对象。代理对象的工作流程可以简化为:
public Object invoke(MethodInvocation invocation) throws Throwable {
TransactionInfo txInfo = createTransactionIfNecessary(method, clazz);
try {
Object retVal = invocation.proceedWithInvocation(); // 调用实际方法
commitTransactionAfterReturning(txInfo);
return retVal;
} catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
}
这个代理机制带来了一个常见的陷阱:自调用问题。当一个被@Transactional注解的方法调用同一个类中的另一个@Transactional方法时,内部方法的传播行为会被忽略:
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
// 这个调用不会触发代理!
// saveAuditLog的事务传播行为会被忽略
saveAuditLog(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(Order order) {
// 即使标记为REQUIRES_NEW,实际仍在placeOrder的事务中执行
}
}
原因是代理只拦截外部调用,内部方法调用是直接的Java方法调用,不经过代理。解决方案是将需要独立事务的方法提取到另一个Bean中。
ThreadLocal与资源绑定
TransactionSynchronizationManager是Spring事务管理的另一个核心组件。它使用ThreadLocal来存储当前线程的事务资源和同步回调:
public abstract class TransactionSynchronizationManager {
// 当前线程绑定的资源(如数据库连接)
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
// 当前线程的事务同步回调
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
// 当前事务的名称
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
// 当前事务是否只读
private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<>("Current transaction read-only status");
// 当前事务的隔离级别
private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<>("Current transaction isolation level");
}
这种设计确保了事务资源与线程的绑定,使得同一事务中的所有操作都能访问同一个数据库连接。同时,这也是为什么Spring事务不能跨线程传播的原因——每个线程有自己独立的ThreadLocal存储。
常见陷阱与解决方案
UnexpectedRollbackException的根源
回到文章开头的问题,UnexpectedRollbackException的产生机制是:
- 外层方法开启事务
- 内层方法抛出异常,Spring捕获后标记事务为rollback-only
- 外层方法捕获异常,尝试继续执行其他操作
- 外层方法正常结束,尝试提交事务
- Spring检测到rollback-only标记,抛出UnexpectedRollbackException
解决方案有两种:
方案一:使用REQUIRES_NEW或NESTED
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
try {
orderRepository.save(order);
} catch (Exception e) {
auditService.saveAudit(order, "FAILED", e.getMessage());
throw e;
}
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit(Order order, String status, String message) {
// 独立事务,不受外层事务回滚影响
}
}
方案二:使用TransactionTemplate进行编程式控制
@Transactional
public void placeOrder(Order order) {
try {
transactionTemplate.execute(status -> {
orderRepository.save(order);
return null;
});
} catch (Exception e) {
// 此时外层事务已回滚结束
auditRepository.save(new Audit(order, "FAILED", e.getMessage()));
}
}
连接池死锁的预防
使用REQUIRES_NEW时,必须确保连接池大小足够。一个经验公式是:
$$N_{pool} \geq N_{threads} \times (1 + N_{requires\_new})$$其中$N_{requires\_new}$是单个请求链路中REQUIRES_NEW方法的最大嵌套层数。
更重要的是,应该优先考虑使用NESTED替代REQUIRES_NEW。NESTED只使用一个连接,不存在连接池耗尽的风险。
传播行为选择指南
| 场景 | 推荐传播行为 | 原因 |
|---|---|---|
| 核心业务操作(创建订单、转账) | REQUIRED(默认) | 所有操作需要原子性 |
| 审计日志、操作记录 | REQUIRES_NEW | 无论业务成功失败都需要记录 |
| 可重试的子操作 | NESTED | 失败后外层可以重试 |
| 查询操作 | SUPPORTS | 不强制事务,灵活适应 |
| 必须在事务内执行的核心方法 | MANDATORY | 防止错误调用 |
| 外部API调用、文件操作 | NOT_SUPPORTED | 避免长时间持有连接 |
| 明确不应在事务内执行的操作 | NEVER | 防御性设计 |
Spring的七种事务传播行为,本质上是在回答一个问题:当多个事务方法相互调用时,事务边界应该如何划分?REQUIRED是最常见的选择,但REQUIRES_NEW和NESTED提供了处理"副作用"和"可重试子操作"的能力。理解这些传播行为的设计哲学和实现原理,才能在面对复杂业务场景时做出正确的选择。
最后需要强调的是,事务传播行为只是工具,业务逻辑的正确性才是核心。过度使用REQUIRES_NEW可能带来连接池耗尽的风险;盲目依赖NESTED可能在某些数据库上遇到兼容性问题。在实际开发中,应该从业务需求出发,选择最简单可靠的方案。
参考资料
- Spring Framework Documentation - Transaction Propagation. https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html
- Baeldung - Transaction Propagation and Isolation in Spring @Transactional. https://www.baeldung.com/spring-transactional-propagation-isolation
- Marco Behler - Spring Transaction Management: @Transactional In-Depth. https://www.marcobehler.com/guides/spring-transaction-management-transactional-in-depth
- Nicolas Fränkel - Transaction management: EJB3 vs Spring. https://blog.frankel.ch/transaction-management-ejb3-vs-spring/
- Paul Klingelhuber - Transactional REQUIRES_NEW considered harmful. https://medium.com/@paul.klingelhuber/transactional-requires-new-considered-harmful-spring-java-transaction-handling-pitfalls-3ed109b3f4f5
- Rod Johnson - Expert One-on-One J2EE Design and Development. Wrox Press, 2002.
- JDBC 3.0 Specification (JSR-54). https://jcp.org/en/jsr/detail?id=54
- Spring Framework GitHub Repository - AbstractPlatformTransactionManager. https://github.com/spring-projects/spring-framework/blob/master/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java