一、场景引入:一个回滚失败的线上事故
某天凌晨促销活动结束后,运营反馈「订单状态更新了但库存没扣减」。排查日志发现:方法 B 抛了异常,但外层方法 A 用 try-catch 吞掉了异常,Spring 检测到 rollback-only 标记与 commit 意图冲突,抛出 UnexpectedRollbackException,最终事务既没提交也没回滚干净——数据出现了部分写入。
这类事务边界问题在 Spring Boot 应用中极为常见,本文梳理 6 种 @Transactional 失效场景,并给出多数据源事务的实战方案。
二、6 种 @Transactional 失效场景
场景 1:同一类中方法内部调用(自调用)
现象:
@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
saveOrder(dto); // 无事务
deductStock(dto.getSkuId()); // 有 @Transactional,但无效
}
@Transactional
public void deductStock(Long skuId) {
// 扣减库存 ...
}
}
原因:Spring 声明式事务基于 AOP 代理。内部方法通过 this 调用,不走代理对象,事务拦截器不生效。
解决:
- 将事务方法提取到单独的
StockService,注入后调用 - 或注入自身代理:
@Autowired private OrderService self;(配合@EnableAspectJAutoProxy(exposeProxy = true)+AopContext.currentProxy(),但不太推荐)
场景 2:rollbackFor 未指定,非 RuntimeException 不回滚
现象:
@Transactional
public void payOrder(String orderNo) throws Exception {
// 更新订单状态...
if (someCondition) {
throw new Exception("业务校验失败"); // 不会回滚!
}
}
原因:Spring 默认只对 RuntimeException 和 Error 回滚,受检异常(Exception)不回滚。
解决:
@Transactional(rollbackFor = Exception.class)
public void payOrder(String orderNo) throws Exception {
// ...
}
场景 3:异常被 try-catch 吞掉
现象:
@Transactional
public void processOrder(OrderDTO dto) {
try {
orderDao.update(dto);
stockService.deduct(dto.getSkuId()); // 内部抛异常标记了 rollback-only
} catch (Exception e) {
log.error("忽略异常", e); // 吞掉,直接导致 UnexpectedRollbackException
}
}
后果:子事务标记了 rollback-only,外层却尝试 commit,Spring 抛出 UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only。
解决:
- 不要在事务方法内
try-catch后不抛出;要么重新抛出,要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() - 若需异常处理且不回滚,改用
Propagation.REQUIRES_NEW隔离子事务
场景 4:事务传播级别设置错误
现象:
@Transactional(propagation = Propagation.NEVER) // 如果当前存在事务则抛异常
public void queryPrice(Long skuId) {
// ...
}
原因:SUPPORTS、NOT_SUPPORTED、NEVER 三种传播特性不会在当前线程中创建事务,若期望事务而未检查传播类型则失效。
常用传播对照表:
REQUIRED(默认):有事务则加入,无则新建REQUIRES_NEW:挂起当前事务,新建独立事务NESTED:嵌套事务(JDBC savepoint),外部回滚会影响内部MANDATORY:必须有事务,否则抛异常SUPPORTS/NOT_SUPPORTED/NEVER:不保证事务存在
场景 5:@Transactional 加在 private 或非 public 方法上
原因:Spring AOP 默认使用 JDK 动态代理(或 CGLIB),代理对象只拦截 public 方法。private 方法上的 @Transactional 完全被忽略。
解决:确保事务方法为 public。
场景 6:多线程调用
现象:
@Transactional
public void batchCreate(List<OrderDTO> dtos) {
dtos.parallelStream().forEach(dto -> {
orderDao.insert(dto); // 新线程内无事务
});
}
原因:Spring 事务通过 ThreadLocal 将数据库连接绑定到当前线程。新线程拿不到已有事务的连接,每个线程自己也没有开启事务。
解决:
- 每个线程内独立开启事务(编程式事务)
- 或使用声明式事务 +
@Async+ 单独TransactionTemplate - 大数据量下建议分批同步处理,避免线程安全问题
三、多数据源事务实战
电商中台常见场景:订单库(MySQL)+ 日志库(PostgreSQL),跨库操作必须保证要么全成功、要么全回滚。
方案一:ChainedTransactionManager(已废弃,不推荐)
Spring Data 曾提供 ChainedTransactionManager,按顺序提交各数据源事务。问题在于:若第二个数据源提交失败,第一个已成功提交的数据源无法回滚,造成数据不一致。
方案二:Atomikos(JTA 分布式事务)
引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
</dependency>
配置两个数据源,并将事务管理器改为 JtaTransactionManager:
@Primary
@Bean(name = "orderTransactionManager")
public PlatformTransactionManager orderTransactionManager() {
return new JtaTransactionManager();
}
此时 @Transactional 即可跨两个数据源实现两阶段提交(2PC)。代价是性能损耗和 XA 协议的超时风险。
方案三:TCC / Saga + 最终一致性(推荐)
对于高并发场景,2PC 的「准备-提交」阻塞模型难以承受。更务实的做法是放弃强一致性,改为最终一致性:
// 步骤1:本地事务写入本地表 + 消息表
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
orderDao.insert(dto);
eventDao.insert(new EventMessage("ORDER_CREATED", dto.getOrderNo()));
}
// 步骤2:轮询消息表,发送到 MQ
// 步骤3:下游消费后更新日志库
若必须在一个 @Transactional 内跨库,且无高性能要求,Atomikos 可用;否则请拥抱最终一致性——本地消息表或 MQ 事务消息。
四、调试与排查工具
- 开启 SQL 日志:
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG,观察事务 begin / commit / rollback 日志 - 观察事务同步钩子:
TransactionSynchronizationManager.isActualTransactionActive()可在代码中打点验证 - 检查代理状态:
AopUtils.isAopProxy(service)确认代理是否生效
五、最佳实践总结
- 事务方法务必
public,且不要同类自调用 - 必须指定
rollbackFor = Exception.class,除非你清楚默认行为 - 不要在事务内
try-catch后不重新抛出异常 - 多线程不在事务管理范围内,改用编程式事务或异步消息
- 跨数据源事务优先考虑 TCC + 最终一致性,而非 2PC
- 开启事务日志,上线前用单元测试验证回滚行为