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 自己的事务中运行并立即提交。如果第二个订单失败,第一个订单仍然会保存在数据库中。
有三种修复方法,按优先级排列:
- 将
placeOrder移到另一个 bean(如OrderPlacementService)中,然后注入。 - 将
@Transactional添加到placeOrders本身,如果你想让整个批次成为一个事务。 - 通过
@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()。这两个工具会精确告诉你事务是否真正存在。