StackTraces
Spring Boot已验证

Spring Boot 事务内 try-catch 吞异常不回滚

@debug_master27 天前更新阅读 6 min00

回滚由 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) {
        // 独立事务,失败自生自灭,不污染主事务
    }
}

总结与避坑要点

  1. 事务回滚的硬性前置条件是“异常穿过代理边界”,内部 catch 是对代理的“报喜不报忧”。
  2. catch 块内要么重新抛出异常,要么调用 setRollbackOnly(),绝不可静默吃掉。
  3. Code Review 时重点关注所有 @Transactional 方法内的 catch 块,确认其异常传递路径是否符合事务原子性预期。

References(参考来源)

最后更新于 2026-09-14

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法