Spring 中的事务:@Transactional 和常见陷阱

1. 总览

@Transactional 是 Spring Boot 中使用最频繁的注解。它也是许多难以发现的 bug 的源头:数据不回滚、事务“消失”,或者 UnexpectedRollbackException 不明原因地出现。

本文解释 @Transactional 通过代理的工作原理。然后,我们逐一讨论常见的陷阱,每个陷阱都有代码示例和修复方案。读完本文后,你将知道事务何时真正开启,何时回滚。

2. 设置

我们使用 Spring Data JPA 配合 H2 内存数据库:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

启用事务拦截器的日志以查看事务何时开启和关闭:

logging.level.org.springframework.transaction.interceptor=TRACE

本文中使用的 Entity 和 Repository:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue
    private Long id;
    private String customerName;
    private BigDecimal amount;

    protected Order() {
    }

    public Order(String customerName, BigDecimal amount) {
        this.customerName = customerName;
        this.amount = amount;
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public BigDecimal getAmount() { return amount; }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
}

表被命名为 orders,因为 order 是 SQL 关键字。

3. @Transactional 如何工作

3.1. 基本示例

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("超过支付限额");
        }
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

调用 placeOrder("An", new BigDecimal("5000")) 时,订单首先被 save。然后 charge 抛出 IllegalStateException。因为这是 RuntimeException,Spring 将回滚整个事务,所以表 orders 保持为空。

3.2. 代理是核心

Spring 不会修改 OrderService 的字节码。它创建一个代理(在 Spring Boot 中默认为 CGLIB 子类),将其包装在 bean 周围。其他 bean 注入 OrderService 时,实际上得到的是这个代理。每次调用都经过 TransactionInterceptor:

调用者 → 代理 → [开启事务] → OrderService.placeOrder() → [提交/回滚] → 调用者

TRACE 日志清楚地显示了这一点:

TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: 超过支付限额

下面大多数陷阱都源于一个原则:不经过代理的调用就没有事务。

4. 陷阱 1:自调用

@Service
public class OrderService {

    // ... constructor 如上所示

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // 通过 this 调用
        }
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        System.out.println("Transaction active: "
                + TransactionSynchronizationManager.isActualTransactionActive());
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

从 controller 调用 orderService.placeOrders(List.of("An", "Bình")) 会输出:

Transaction active: false
Transaction active: false

placeOrders 没有 @Transactional,所以代理直接将调用转发到实际对象。在内部,placeOrder(...) 等价于 this.placeOrder(...),而 this 是原始对象,不是代理。注解被完全忽略。每个 save 都在 SimpleJpaRepository 自己的事务中运行并立即提交。如果第二个订单失败,第一个订单仍然会保存在数据库中。

有三种修复方法,按优先级排列:

  1. 将 placeOrder 移到另一个 bean(如 OrderPlacementService)中,然后注入。
  2. 将 @Transactional 添加到 placeOrders 本身,如果你想让整个批次成为一个事务。
  3. 通过 @Lazy 自注入。这种方法有效但可读性差,只有在无法拆分 bean 时才使用。
@Service
public class OrderBatchService {

    private final OrderService orderService; // 这是代理

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void placeOrders(List<String> customers) {
        customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
    }
}

现在每次调用都经过代理,日志会输出 Transaction active: true。

5. 陷阱 2:private 或 final 方法

从 Spring Framework 6.0 开始,使用基于类的代理 (CGLIB) 时,@Transactional 可以作用于 public、protected 和包级访问的方法。但仍有两种情况不起作用:

  • private 方法:子类无法重写,所以代理无法拦截调用。
  • final 方法:CGLIB 也无法重写。调用直接执行原始代码,没有事务。
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // 没有事务
    return orderRepository.save(new Order(customer, amount));
}

Spring 在这种情况下不会抛出错误。你只会看到一条 INFO 或 DEBUG 日志,很容易被忽略。

提示:Kotlin 默认将所有类和方法设为 final。使用 kotlin-spring 插件(all-open)自动打开 Spring bean。

6. 陷阱 3:检查异常不会导致回滚

默认情况下,Spring 仅在遇到 RuntimeException 或 Error 时回滚。检查异常会导致事务提交。

public class PaymentDeclinedException extends Exception {
    public PaymentDeclinedException(String message) {
        super(message);
    }
}

@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
    Order order = orderRepository.save(new Order(customer, amount));
    if (amount.signum() <= 0) {
        throw new PaymentDeclinedException("金额无效");
    }
    return order;
}

用 amount = -5 调用时,异常仍然会抛给调用者,但具有负金额的订单已经被保存。修复方法是明确声明:

@Transactional(rollbackFor = PaymentDeclinedException.class)

许多团队约定对整个 service 使用 rollbackFor = Exception.class。更简单的方法是设计业务异常继承 RuntimeException。相反的做法也可以:noRollbackFor = SomeRuntimeException.class 即使有错误也会提交。

7. 陷阱 4:吞掉异常和 UnexpectedRollbackException

这是最难理解的陷阱。考虑一个在库存中保留商品的 service:

@Service
public class InventoryService {

    @Transactional // 传播模式默认为:REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("库存不足:" + sku);
    }
}

而 OrderService 调用它,然后用一个 try-catch 捕获错误,意图是“保留失败就仍然创建订单”:

@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
    Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
    try {
        inventoryService.reserve(sku);
    } catch (IllegalStateException ex) {
        System.out.println("忽略库存错误:" + ex.getMessage());
    }
    return order;
}

结果:

忽略库存错误:库存不足:SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

发生的情况如下。reserve 参与与 placeOrderWithInventory 相同的物理事务。当异常通过 InventoryService 的代理时,Spring 将事务标记为仅回滚。你在外面的 catch 无法清除这个标记。在提交时,Spring 检测到该标记,回滚,然后抛出 UnexpectedRollbackException 以通知调用者,本来以为会提交但实际上没有。

修复方法取决于你的意图:

  • 如果库存错误必须取消整个订单,不要 catch。让异常传播。
  • 如果 reserve 是独立的操作,使用 REQUIRES_NEW 给它一个独立事务(见第 8 节)。
  • 如果 reserve 不需要写入数据库,从它移除 @Transactional。

提示:在同一方法内吞掉异常则事务不会被标记。标记仅在异常通过内部的一个 @Transactional 代理时设置。

8. Propagation REQUIRES_NEW:有用但代价高

REQUIRES_NEW 的一个合理用例是写入审计日志。日志即使业务逻辑主体回滚也需要被保存:

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action) {
        auditLogRepository.save(new AuditLog(action));
    }
}

Spring 暂停外部事务,在另一个连接上打开一个新事务。当 record 结束时,这个事务独立提交。

需要付出的代价:

  • 每个请求同时持有两个连接。在高负载下,连接池(HikariCP 默认有 10 个连接)可能耗尽,导致在连接池级别出现死锁:所有线程都持有一个连接并等待第二个。
  • 新事务看不到外部事务未提交的数据。如果 AuditLog 对新创建的 Order 有外键,你会遇到错误或被锁定。

仅在真正需要独立提交时使用 REQUIRES_NEW,不要将其用作处理所有回滚问题的“应急措施”。

9. readOnly 和长时间运行的事务

@Transactional(readOnly = true) 告诉 Hibernate 设置 flush 模式为 MANUAL 并跳过脏检查。对于返回许多 entity 的查询,这可以显著减少内存和 CPU 使用:

@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
    return orderRepository.findAll().stream()
            .filter(o -> o.getCustomerName().equals(customer))
            .toList();
}

需要记住两点:

  • readOnly 是一个优化提示,不是保护机制。根据驱动程序和数据库,一个本机 UPDATE 语句仍然可以执行。
  • 不要在事务内调用外部 HTTP API、发送邮件或 Thread.sleep。连接在整个持续期间都被占用,锁定在已写入的行上也是如此。先从外部获取数据,然后打开一个短暂的事务来写入。

10. 测试回滚行为

另一个陷阱在于测试。如果你在测试类上放置 @Transactional,所有内容都在测试的事务中运行并在末尾回滚。这隐藏了 service 是否正确提交或回滚。对于关于事务的测试,移除这个注解并手动清理数据:

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @AfterEach
    void cleanUp() {
        orderRepository.deleteAll();
    }

    @Test
    void whenPaymentFails_thenOrderIsRolledBack() {
        assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
        assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
                .isInstanceOf(UnexpectedRollbackException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
        assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
                .isInstanceOf(PaymentDeclinedException.class);

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

第三个测试“锁定”了检查异常的默认行为。当你添加 rollbackFor 时,这个测试会失败,你将期望改为 isZero()。这是在代码中记录回滚决策的好方法。

11. 结论

@Transactional 看起来很简单,但它的所有行为都依赖于代理。主要陷阱总结:

  • 自调用:通过 this 调用会绕过代理。拆分 bean。
  • private/final 方法:不被拦截,Spring 不报错。
  • 检查异常:默认提交。使用 rollbackFor 或使用 RuntimeException。
  • 仅回滚:异常通过内部代理会标记整个事务,外面的 catch 救不了。
  • REQUIRES_NEW:消耗额外连接,仅在需要独立提交时使用。
  • 带 @Transactional 的测试:隐藏真实的提交/回滚行为。

当有疑问时,在 TRACE 级别启用 TransactionInterceptor 日志,并检查 TransactionSynchronizationManager.isActualTransactionActive()。这两个工具会精确告诉你事务是否真正存在。

spring-boottransactionjpaspring-datatesting