某电商系统在大促期间出现了一个诡异的问题:订单创建失败后,审计日志也没有记录。开发者明明在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需要决定:这两个逻辑事务是共享同一个物理事务,还是各自独立?

REQUIRED传播行为示意图

图片来源: 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的语义是"始终创建一个新事务,如果当前存在事务,则将其挂起”。这个行为看似简单,实则隐藏着严重的性能和资源问题。

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的产生机制是:

  1. 外层方法开启事务
  2. 内层方法抛出异常,Spring捕获后标记事务为rollback-only
  3. 外层方法捕获异常,尝试继续执行其他操作
  4. 外层方法正常结束,尝试提交事务
  5. 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可能在某些数据库上遇到兼容性问题。在实际开发中,应该从业务需求出发,选择最简单可靠的方案。

参考资料

  1. Spring Framework Documentation - Transaction Propagation. https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html
  2. Baeldung - Transaction Propagation and Isolation in Spring @Transactional. https://www.baeldung.com/spring-transactional-propagation-isolation
  3. Marco Behler - Spring Transaction Management: @Transactional In-Depth. https://www.marcobehler.com/guides/spring-transaction-management-transactional-in-depth
  4. Nicolas Fränkel - Transaction management: EJB3 vs Spring. https://blog.frankel.ch/transaction-management-ejb3-vs-spring/
  5. Paul Klingelhuber - Transactional REQUIRES_NEW considered harmful. https://medium.com/@paul.klingelhuber/transactional-requires-new-considered-harmful-spring-java-transaction-handling-pitfalls-3ed109b3f4f5
  6. Rod Johnson - Expert One-on-One J2EE Design and Development. Wrox Press, 2002.
  7. JDBC 3.0 Specification (JSR-54). https://jcp.org/en/jsr/detail?id=54
  8. 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