回滚由
TransactionInterceptor捕获「穿过代理边界的异常」触发。事务方法内部 try-catch 把异常吞掉后正常返回,代理感知不到任何异常,事务照常提交——这是事务失效里最常见也最反直觉的一种,因为方法确实“报错了”,事务却“没坏”。
Issue & Env(案发现场与环境)
- 技术栈:Spring Boot 2.x / 3.x、Spring Framework 声明式事务(
@Transactional) - 业务场景:下单主流程在插入订单记录后,调用下游扣库存或发积分等子操作。开发者用
try-catch包裹了子调用以打印错误日志或做局部防御,但未重新抛出异常。 - 故障特征:业务日志中明确打印了
ERROR: 库存扣减失败,然而下游库存虽然未扣除成功,前置插入的order订单记录与积分却依然被持久化提交到了数据库,未发生预期内的全量事务回滚。
StackTrace(堆栈日志)
本故障表面上没有未捕获异常抛出,控制台通常输出业务自定义的错误日志,紧随其后的是事务正常提交的跟踪记录:
2026-09-14 10:02:11 ERROR c.e.OrderService : 库存扣减失败: insufficient stock
2026-09-14 10:02:11 DEBUG o.s.t.interceptor.TransactionInterceptor : Completing transaction for [OrderService.createOrder] after return: null
// ^^^^^^^^^^^^^^ after return,不是 after throwing
开启 Spring 事务详细调试日志可清楚观察到提交决策:
logging.level.org.springframework.transaction.interceptor=DEBUG
关键证据对比:
Completing transaction ... after return:代理拦截器收到的是方法正常返回,走成功提交流程。Completing transaction ... after throwing: xxxException:只有异常冒泡到代理层时,才会进入回滚判定链。
Reproduce(复现代码)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryService inventoryService;
@Autowired
private PointService pointService;
@Transactional
public void createOrder(Order order) {
// 1. 插入订单
orderMapper.insert(order);
// 2. 扣库存,被 catch 住且未重新抛出
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败: {}", e.getMessage()); // 仅记日志,吞掉异常
}
// 3. 正常执行后续逻辑并提交
pointService.add(order.getUserId(), order.getPoint());
}
}
触发调用后,inventoryService.deduct 抛出 BusinessException,但被内部消化,外部观测发现 order 成功落库。
Root Cause(根因分析)
Spring 声明式事务的回滚判定链路高度依赖 AOP 代理层的拦截:
业务方法抛出异常
→ 代理层 TransactionInterceptor.invokeWithinTransaction() 捕获
→ TransactionAspectSupport.completeTransactionAfterThrowing()
→ 按 rollbackFor 规则匹配(默认针对 RuntimeException 和 Error)
→ rollback()
整条链路的前提只有一个:异常必须穿透方法体并抛至代理层(Proxy Boundary)。
@Transactional是环绕通知包装器,无法窥探方法内部执行流程。- 当方法体内自行执行了
try-catch且没有throw,Java 方法调用视为“正常返回(Return Normally)”。 - 代理层拦截器只能触发
completeTransactionAfterReturning(),从而执行数据库连接提交。
快速定位排查: 使用 grep 快速排查业务层中所有只打印日志而不抛出异常的 catch 块:
grep -rn -A2 "catch.*Exception" src/main/java --include="*Service*" | grep -B1 "log.error"
The Fix(解决方案)
方案 A(推荐):catch 后重新抛出异常
如果需要记录业务上下文日志,可以保留 catch 块,但在记录后必须将异常继续向外抛出(或转换为受支持的运行时异常):
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败: {}", e.getMessage());
throw new RuntimeException("库存扣减失败,触发回滚", e); // 重新抛出,让代理感知
}
方案 B:catch 内手动标记回滚状态(setRollbackOnly)
对于确实需要执行降级返回、不能直接对外抛出异常中断响应的场景,可以在 catch 块内显式将当前事务标记为回滚状态:
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败,标记事务回滚: {}", e.getMessage(), e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
注意:setRollbackOnly() 会使当前事务在方法执行完毕后强制回滚,即便后续方法正常 return。
方案 C:将非关键子操作拆分为独立事务(REQUIRES_NEW)
如果子操作是边缘日志或非核心通知,即便失败也不应当影响主订单事务,应将该方法拆分至独立 Bean 并声明为独立事务:
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog(LogEntity log) {
// 独立事务,失败自生自灭,不污染主事务
}
}
总结与避坑要点
- 事务回滚的硬性前置条件是“异常穿过代理边界”,内部 catch 是对代理的“报喜不报忧”。
- catch 块内要么重新抛出异常,要么调用
setRollbackOnly(),绝不可静默吃掉。 - Code Review 时重点关注所有
@Transactional方法内的 catch 块,确认其异常传递路径是否符合事务原子性预期。
