Spring 事务传播行为与失效场景排查
@Transactional 是 Spring 里最容易"看起来生效、实际没生效"的注解:加上了注解、方法也抛了异常,数据却写进了库。这类问题的根因几乎都落在传播行为和代理机制上。本文用可复现的错误代码,逐个拆解事务失效的场景并给出正确写法。适合负责订单、支付、库存等强一致业务的后端开发阅读。
一、事务的底层抽象
Spring 事务不自己实现事务,而是对 JDBC、JTA、JPA 做统一封装。核心接口有三个:
PlatformTransactionManager:事务管理器,负责getTransaction、commit、rollback。TransactionDefinition:事务定义,包含传播行为、隔离级别、超时、只读标记。TransactionStatus:事务运行状态,记录是否新事务、是否已完成、是否标记回滚。
以 DataSourceTransactionManager 为例,它通过 TransactionSynchronizationManager 把 Connection 绑定到当前线程的 ThreadLocal,从而让同一线程内的多个 DAO 复用同一个连接。事务边界靠 setAutoCommit(false) + commit() / rollback() 实现,ThreadLocal 在 cleanupAfterCompletion 中清理。
这解释了事务的第一个隐含前提:同一个线程。跨线程、跨 CompletableFuture 的操作不在同一个事务里。
二、七种传播行为
| 传播行为 | 当前有事务 | 当前无事务 | 典型用途 |
| --- | --- | --- | --- |
| REQUIRED(默认) | 加入 | 新建 | 绝大多数业务 |
| REQUIRES_NEW | 挂起,新建 | 新建 | 日志、审计,不能受主事务影响 |
| SUPPORTS | 加入 | 非事务执行 | 查询方法 |
| NOT_SUPPORTED | 挂起,非事务执行 | 非事务执行 | 耗时操作,避免长事务 |
| MANDATORY | 加入 | 抛异常 | 强制要求上游开启事务 |
| NEVER | 抛异常 | 非事务执行 | 明确禁止事务 |
| NESTED | 建保存点 | 新建 | 子操作失败不影响主流程 |
REQUIRES_NEW 与 NESTED 最容易混淆:
REQUIRES_NEW会挂起外层事务、使用新的数据库连接,内外完全独立。外层回滚不影响内层已提交的数据,内层回滚也不影响外层。NESTED在同一个连接上设置保存点(Savepoint),内层回滚只回滚到保存点,外层可以继续。它要求底层驱动支持保存点,JTA 事务不支持。
NESTED 的一个典型陷阱:内层抛异常被外层捕获后,DataSourceTransactionManager 会把外层事务标记为 rollback-only,最终外层提交时抛 UnexpectedRollbackException。这一点在下面第五节展开。
三、失效场景一:自调用
@Service
public class OrderService {
public void createOrder(Order order) {
insertOrder(order);
insertDetail(order); // 内部调用,不经过代理
}
@Transactional
public void insertDetail(Order order) {
detailMapper.insert(order);
if (order.getAmount() == null) {
throw new IllegalArgumentException("amount is null");
}
}
}
createOrder 没有事务注解,从 Controller 直接调用它时,insertDetail 的 @Transactional 完全不生效。异常抛出后 insertOrder 已写入的数据不会回滚。
原因在代理:外部调用 createOrder 进入代理,代理把调用转给目标对象;目标对象内部 this.insertDetail() 是普通方法调用,不经过代理,事务拦截器无从介入。
正确写法:
@Service
public class OrderService {
private final OrderDetailService detailService;
public OrderService(OrderDetailService detailService) {
this.detailService = detailService;
}
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
orderMapper.insert(order);
detailService.insertDetail(order); // 跨 Bean 调用,经过代理
}
}
四、失效场景二:异常类型不对
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) throws Exception {
accountMapper.debit(from, amount);
accountMapper.credit(to, amount);
throw new Exception("业务校验失败"); // 受检异常,默认不回滚
}
@Transactional 默认只对 RuntimeException 和 Error 回滚,受检异常不会触发回滚。上面这段代码会把钱转出去却不回滚。
正确写法是显式声明:
@Transactional(rollbackFor = Exception.class)
同理,如果异常被 catch 后没有重新抛出,事务拦截器根本感知不到异常,也不会回滚:
@Transactional
public void save(Order order) {
try {
orderMapper.insert(order);
throw new IllegalStateException("boom");
} catch (Exception e) {
log.error("insert failed", e); // 吞掉异常,事务照常提交
}
}
需要局部容错时,把可能失败的操作拆到独立 Bean 并标注 REQUIRES_NEW,或手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
五、失效场景三:rollback-only 导致提交失败
@Service
public class OuterService {
private final InnerService innerService;
@Transactional
public void outer() {
try {
innerService.inner(); // inner 标注 @Transactional
} catch (Exception e) {
log.warn("inner failed, continue", e);
}
// 继续写主表
}
}
inner() 抛出运行时异常时,事务拦截器调用 completeTransactionAfterThrowing,发现当前事务是参与已有事务(REQUIRED),于是执行 txInfo.transactionStatus.setRollbackOnly(),把整个事务标记为只能回滚。外层 catch 之后继续执行,提交时抛:
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
这是分布式系统里极常见的线上问题:日志显示"已捕获异常",接口却返回 500。
正确做法取决于业务语义:
- 内层失败必须影响外层:不要
catch,让异常向上传播。 - 内层失败不应影响外层:内层改用
@Transactional(propagation = Propagation.REQUIRES_NEW),使用独立事务。
六、失效场景四:非 public 方法与 final 类
@Service
public class ReportService {
@Transactional
protected void generate() { // protected,CGLIB 跨包无法覆写
reportMapper.insert();
}
}
CGLIB 通过子类覆写实现拦截,private 方法无法覆写,protected 和包级方法在跨包代理时同样不可见。Spring 6 的 AnnotationTransactionAttributeSource 对非 public 方法直接跳过,不抛异常。
final 类、final 方法同样无法被 CGLIB 代理。检查 Lombok 的 @Value、Kotlin 默认 final 类,这些都会让事务静默失效。
七、失效场景五:多线程与异步
@Transactional
public void batchImport(List<Order> orders) {
orders.parallelStream().forEach(orderMapper::insert); // 子线程无事务
}
事务上下文绑定在 ThreadLocal,parallelStream 的工作线程拿不到当前事务的连接,每条 insert 各自自动提交。任何一条失败都不会回滚已插入的数据。
正确做法是在单线程内完成批量写入,使用 MyBatis 的 foreach 批量 SQL 或 JDBC addBatch:
@Transactional(rollbackFor = Exception.class)
public void batchImport(List<Order> orders) {
orderMapper.insertBatch(orders);
}
如果确实需要并发,必须自己控制每个子任务的事务边界,并接受"部分成功"的语义。
八、失效场景六:数据源与事务管理器不匹配
多数据源场景下,@Transactional 默认使用标注了 @Primary 的 TransactionManager。操作从库时如果注解没有指定 transactionManager = "slaveTransactionManager",事务会开在主库连接上,从库的写入根本不在事务内。
@Transactional(transactionManager = "slaveTransactionManager", rollbackFor = Exception.class)
public void writeToSlave(Record record) {
slaveMapper.insert(record);
}
另一个隐蔽情况是:@Transactional 标注在类上,但方法内部调用了 JdbcTemplate 的另一个 DataSource,两者连接不同,事务不覆盖。
九、失效场景七:只读事务里做写操作
@Transactional(readOnly = true)
public void refreshCache() {
cacheMapper.deleteAll(); // 只读事务下部分驱动会拒绝写操作
cacheMapper.insertBatch(data);
}
readOnly = true 会把连接设为只读,MySQL 驱动在 SET SESSION TRANSACTION READ ONLY 后拒绝写操作,抛 Connection is read-only。同时它也是给数据库优化器的提示,避免不必要的锁。查询方法加 readOnly = true 是好事,但不要在其中写数据。
十、常见坑 / 最佳实践
@Transactional只加在 Service 的public方法上。Controller 不加,DAO 不加,避免事务边界模糊。- 统一使用
rollbackFor = Exception.class。默认只回滚运行时异常,受检异常漏掉是高频事故。 - 不要在事务方法里做远程调用。HTTP、MQ 发送会把事务拉长到秒级,连接池很快耗尽。先提交事务,再用
TransactionSynchronization#afterCommit发消息。 REQUIRES_NEW要评估连接数。它需要额外一条连接,嵌套多层时容易打满连接池,且内外事务可能互相等待锁。- 自调用一律抽类。
AopContext.currentProxy()和注入自身代理都能用,但可读性和可测试性都差,抽类是最稳的解法。 - 事务方法内不要吞异常。要么抛出,要么显式
setRollbackOnly(),不要既catch又假装没事。
十一、小结
- 传播行为决定"加入、新建、挂起、拒绝"四种关系,
REQUIRED是默认值。 - 自调用绕过代理,是事务失效的第一大原因,跨 Bean 调用才能生效。
- 默认只回滚
RuntimeException和Error,受检异常必须显式rollbackFor。 - 内层参与事务失败会污染外层,导致
UnexpectedRollbackException,需要REQUIRES_NEW隔离。 - 非
public、final、多线程、多数据源都会让事务静默失效,排查时按这五项逐一确认。
- 点赞
- 收藏
- 关注作者
评论(0)