ElIauk

Spring 事务传播行为与失效场景

传播行为到底在解决什么 @Transactional 默认的传播行为是 REQUIRED:当前有事务就加入,没有就新建。大部分业务这么写没问题,但一旦出现“主流程回滚,日志不能丢”“批量任务里单条失败不影响其他条”这类需求,就必须显式选传播行为。 传播行为 行为 常见用途 REQUIRED 加入当前

评论
Spring 事务传播行为与失效场景

传播行为到底在解决什么

@Transactional 默认的传播行为是 REQUIRED:当前有事务就加入,没有就新建。大部分业务这么写没问题,但一旦出现“主流程回滚,日志不能丢”“批量任务里单条失败不影响其他条”这类需求,就必须显式选传播行为。

传播行为 行为 常见用途
REQUIRED 加入当前事务,没有则新建 默认,绝大多数业务
REQUIRES_NEW 挂起当前事务,新建独立事务 审计日志、失败也要留痕
NESTED 在当前事务里开 savepoint 子步骤失败只回滚子步骤
SUPPORTS 有事务就加入,没有就非事务执行 查询方法
NOT_SUPPORTED 挂起当前事务,非事务执行 发消息、调远程接口
MANDATORY 必须在事务中,否则抛异常 强制内部方法
NEVER 必须不在事务中,否则抛异常 少数校验场景

需要提前知道代价:REQUIRES_NEW 会从连接池再拿一条连接,外层事务没提交前,两条连接同时被占用。并发一高,连接池很容易被这种“嵌套新事务”耗光。NESTED 依赖 savepoint,对 JDBC 友好,JPA 场景下行为并不完全等价。

代码:主流程回滚,审计日志保留

把需要独立提交的逻辑放到另一个 Bean 里,这是关键。同一个类里自调用不会经过代理,传播行为写了也不生效。

@Service
public class AuditService {

    private final JdbcTemplate jdbcTemplate;

    public AuditService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void record(Long orderId, String action) {
        jdbcTemplate.update(
                "INSERT INTO audit_log (order_id, action, created_at) VALUES (?, ?, ?)",
                orderId, action, LocalDateTime.now());
    }
}
@Service
public class OrderService {

    private final AuditService auditService;
    private final JdbcTemplate jdbcTemplate;

    public OrderService(AuditService auditService, JdbcTemplate jdbcTemplate) {
        this.auditService = auditService;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(rollbackFor = Exception.class)
    public void create(Long orderId) {
        jdbcTemplate.update("INSERT INTO orders (id, status) VALUES (?, ?)", orderId, "NEW");
        auditService.record(orderId, "CREATE");
        if (orderId < 0) {
            throw new IllegalArgumentException("orderId 非法");
        }
    }
}

建表语句可以先用这段,字段类型和索引用得上:

CREATE TABLE audit_log (
    id BIGINT NOT NULL AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    action VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (id),
    KEY idx_order_id (order_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

启动后调用一次 create,再查 audit_log,能看到订单回滚而审计记录留下。测试可以直接跑:

mvn -q -Dtest=OrderServiceTest test

失效场景远比想象中多

第一类是代理没生效:自调用、方法 private、类 final、方法 final,这几种情况下事务注解都不会被拦截。尤其是 final 类,CGLIB 生成不了子类,启动时不一定报错,运行时才发现事务根本没开。第二类是异常没触发回滚:Spring 默认只对 RuntimeException 和 Error 回滚,抛的是受检异常时必须写 rollbackFor = Exception.class。第三类是异常被吞掉:try-catch 之后没有继续抛,事务拦截器认为方法正常结束,自然提交。

还有两个容易被忽略的点。@Transactional 和 @Async 同时标在一个方法上,异步线程里没有原事务上下文,实际是两段独立逻辑;多数据源项目如果只配了一个 DataSourceTransactionManager,另一个库的写操作根本不在事务里。

排查步骤

先确认方法有没有被代理:

@Autowired
private OrderService orderService;

@PostConstruct
public void checkProxy() {
    log.info("isAopProxy={}", AopUtils.isAopProxy(orderService));
}

再打开事务日志:

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

日志里能看到 “Creating new transaction”“Participating in existing transaction”“Initiating transaction rollback”,传播行为是否符合预期一目了然。运行期还可以用 TransactionSynchronizationManager.isActualTransactionActive() 判断当前线程有没有活动事务。同时观察连接池的活跃连接数,长事务往往伴随连接迟迟不归还。

如果是数据库层面的锁等待或长事务,直接查 InnoDB:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

常见坑

很多人会把 REQUIRES_NEW 当成“让这个方法不受外层影响”的万能开关,结果内层抛异常时没处理,异常继续往外冒,外层也一起回滚,审计记录同样没留下。正确做法是在调用方决定要不要吞掉内层异常,并且明确是否需要补偿。

另一个典型现象是接口压测时连接池告警,业务代码里却找不到明显泄漏。最后发现是循环里调了大量 REQUIRES_NEW 方法,每个都占一条新连接。能合并的批量操作尽量合并,不能合并的要给连接池留余量并限制并发。

最后提醒一句:传播行为是“事务边界”的设计,不是注解参数调优。边界没想清楚,换哪个传播行为都会在下一个场景出问题。

小结

传播行为的选择,取决于事务边界和失败后的补偿方式。REQUIRED 覆盖大多数场景,REQUIRES_NEW 用于必须独立留痕的操作,NESTED 适合可回滚的子步骤。先画清楚边界,再决定用哪个,别等线上出了不一致才回头补。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者