人人都会AI编程

22.2 事务使用最佳实践与常见避坑

更新时间:2026-07-11

Spring 的声明式事务极大降低了事务管理的复杂度,但在实际项目中,不合理的使用方式仍然会引发数据不一致、死锁、性能瓶颈甚至事务失效等问题。本节总结了经过大量生产验证的最佳实践和常见陷阱,帮助开发者将事务真正用对、用好。

22.2.1 精确控制事务边界

原则:事务应该包裹最小的必要操作,将非事务性逻辑排除在外。

常见错误是将整个 Service 方法标记为 @Transactional,而方法内部包含大量与数据库无关的耗时操作,如文件读写、远程 API 调用、复杂计算等。这些操作会无谓地延长数据库事务的持有时间,导致:

  • 数据库连接被长时间占用,连接池耗尽。
  • 锁持有时间过长,阻塞其他并发事务,甚至引发连锁雪崩。
  • 长事务增加死锁概率。

最佳实践:

// 错误示例:整个方法包含外部调用和计算
@Transactional
public void processOrder(Order order) {
    validateOrder(order);           // 纯逻辑校验,不需要事务
    httpClient.notifyWarehouse();   // 远程调用,耗时且与数据库无关
    orderDao.insert(order);         // 真正需要事务的操作
    inventoryService.deduct(order); // 扣减库存,同样需要事务
}

// 正确做法:事务仅包裹数据库操作的核心方法
public void processOrder(Order order) {
    validateOrder(order);
    httpClient.notifyWarehouse();
    orderService.saveOrderAndDeductInventory(order); // 该方法内部使用事务
}

@Transactional
public void saveOrderAndDeductInventory(Order order) {
    orderDao.insert(order);
    inventoryService.deduct(order);
}

更彻底的方案是使用编程式事务(TransactionTemplate)精确控制边界,或者将纯读操作放在事务外执行,只在真正需要原子性的一组写操作时开启事务。

22.2.2 合理选用事务传播行为

Spring 提供了七种传播行为,日常使用中最关键的是理解 REQUIREDREQUIRES_NEWNESTED 的差异,避免想当然。

核心区别:

  • REQUIRED(默认):沿用当前事务,没有则新建。适合大多数情况,多个 Service 方法可以自然组成一个事务单元。
  • REQUIRES_NEW:总是新建事务,当前事务被挂起。新事务与外部事务独立提交或回滚。常用于需要强隔离的日志记录、消息发送等场景。
  • NESTED:嵌套事务,内部回滚不影响外部事务,但外部回滚会连带内部事务回滚。仅部分 JTA 实现和 JDBC 保存点支持。

常见误用: 在一个事务中调用标记了 REQUIRES_NEW 的方法,期望外部回滚时内部已经提交,这确实可以实现。但反过来,如果内部 REQUIRES_NEW 方法抛出异常却未被外部方法捕获,外部事务也会回滚,且内部新事务已经提交,导致数据不一致。使用时务必明确异常处理策略。

实践建议:

// 场景:订单创建成功后强制记录审计日志,即使后续业务失败,日志也必须保留
@Transactional
public void createOrder(Order order) {
    orderDao.insert(order);
    // 审计日志使用新事务独立提交
    auditService.record("ORDER_CREATED", order.getId());
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String event, Long orderId) {
        // 该日志操作会在独立事务中提交
    }
}

22.2.3 避坑:自调用导致事务失效

这是 Spring 事务中最常踩的坑。由于 Spring AOP 基于代理机制,当在同一个类中通过 this 调用方法时,实际调用的是目标对象的方法,而非代理对象,因此切面无法织入,事务注解完全失效。

@Service
public class OrderService {
    public void methodA() {
        // 直接通过this调用,事务不生效!
        this.methodB();
    }

    @Transactional
    public void methodB() {
        // 数据库操作
    }
}

解决方案:

  • 通过注入自身的代理对象调用:注入 @Autowired private OrderService self;(注意避免循环依赖),然后调用 self.methodB()
  • 将需要独立事务的方法抽离到另一个 Bean:这是一种更清晰的做法,也更符合单一职责原则。
  • 使用 AopContext.currentProxy() 获取当前代理:需要在启动类或配置类添加 @EnableAspectJAutoProxy(exposeProxy = true),然后通过 ((OrderService) AopContext.currentProxy()).methodB(); 调用。此方法有一定侵入性,且依赖线程绑定,不推荐作为首选。
  • 切换为 AspectJ 编译器织入:彻底摆脱代理限制,但会增加构建复杂度,不常用。

最推荐的方式是始终将事务方法放在独立的 Service 中,从架构层面避免自调用。

22.2.4 正确处理异常回滚

@Transactional 默认只在遇到 RuntimeException 和 Error 时回滚,受检异常(Checked Exception)不会触发回滚。很多开发者容易忽略这一点,在抛出业务异常(通常是自定义的受检异常或继承 Exception 的异常)时期望事务回滚,却发现数据已提交。

正确做法:

// 明确指定回滚的异常类型
@Transactional(rollbackFor = Exception.class)
public void businessMethod() throws BusinessException {
    // 业务逻辑
    if (error) {
        throw new BusinessException("业务异常");
    }
}

反之,也存在一种误区:对于不期望回滚的异常,也使用 rollbackFor 强行回滚,但事实上某些查询异常或参数校验失败不应导致事务标记为回滚,否则会触发不必要的额外开销。更精细的做法是定制 noRollbackFor 或在代码中妥善捕获异常并转换为恰当状态。

22.2.5 只读事务与性能

Spring 为事务提供了 readOnly 属性,当方法仅执行查询操作时,应显式设置:

@Transactional(readOnly = true)
public List<Order> queryOrders(OrderQuery query) {
    return orderDao.findByQuery(query);
}

其收益在于:

  • JDBC 层面的优化:数据库驱动可以利用该标识跳过脏数据检查、减少锁开销(如 MySQL InnoDB 会避免设置事务 ID,降低回滚代价)。
  • ORM 层面的优化:Hibernate 在只读事务下会禁用清空操作,避免不必要的脏检查,显著提升查询吞吐量。
  • 语义清晰:一看注解即知方法不会修改数据,降低维护风险。

22.2.6 超时与事务大小控制

在可能发生死锁或资源争抢的场景,务必设置事务超时时间,避免无限期挂起:

@Transactional(timeout = 5) // 单位秒
public void processCriticalOperation() {
    // ...
}

同时,上面已经提到的事务边界控制也是避免事务膨胀的关键。需要特别留意循环体中的数据库操作,避免在循环内开启事务,或者一次事务中包含大量循环操作。对于批量处理,应采用分批提交策略(如使用 Spring Batch 或手动 TransactionTemplate 分批执行)。

22.2.7 避免事务嵌套中的锁顺序不一致

在分布式系统或涉及多表操作的场景中,锁定资源的顺序不一致极易导致死锁。虽然 Spring 事务本身不直接管理锁序,但可以通过约定业务操作的调用顺序来降低风险。例如,扣减库存时始终先锁商品记录再锁用户钱包,避免交叉等待。

22.2.8 事务与异步操作的陷阱

当带有事务的方法内部启动异步线程执行数据库操作时,异步线程不会继承事务上下文,事务的传播会被切断。通常需要将异步操作解耦为独立事务,或者使用消息队列等最终一致性手段。

总结: Spring 事务很强大,但前提是理解其代理本质、传播机制和回滚规则。将事务边界缩到最小、优先使用独立 Bean 拆分事务方法、明确定义异常回滚策略,这三条原则足以避开 90% 的实际问题。