传播行为到底在解决什么
@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 适合可回滚的子步骤。先画清楚边界,再决定用哪个,别等线上出了不一致才回头补。
评论