Skip to content

第62课:Spring Transaction

🎯 学习目标

  • 理解 Spring 声明式事务基于 AOP 代理,事务边界通常放在 Service 层。
  • 掌握 @Transactional 的回滚规则、传播行为、隔离级别和只读事务。
  • 能识别自调用、非 public 方法、异常被吞、错误传播行为等事务失效场景。
  • 能设计合理事务边界,避免事务内远程调用和长事务。
  • 能从数据库锁、连接池、AOP 代理三个角度排查事务问题。

📖 一、为什么需要事务

转账场景:

text
A 扣款 100
B 加款 100

两步必须同时成功或同时失败。否则会出现资金不一致。

java
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountRepository.deduct(fromId, amount);
    accountRepository.add(toId, amount);
}

Spring 在方法开始前开启事务,正常返回时提交,抛出异常时回滚。


📖 二、声明式事务原理

@Transactional 本质依赖 AOP 代理:

text
调用方 -> 事务代理 -> 开启事务 -> 目标方法 -> 提交/回滚事务

因此事务是否生效取决于调用是否经过代理对象。

这也是为什么同类自调用会失效。


📖 三、基本用法

java
@Service
public class OrderService {

    @Transactional
    public OrderResponse createOrder(OrderCommand command) {
        Order order = orderRepository.save(command.toOrder());
        orderItemRepository.saveAll(command.toItems(order.getId()));
        inventoryRepository.deduct(command.productId(), command.quantity());
        return OrderResponse.from(order);
    }
}

事务通常放在应用服务层,因为这里最清楚一个业务用例包含哪些数据库操作。


📖 四、回滚规则

默认情况下:

text
RuntimeException 和 Error 回滚。
受检异常 checked exception 默认不回滚。

指定回滚:

java
@Transactional(rollbackFor = Exception.class)
public void importFile(MultipartFile file) throws IOException {
    // IOException 也会触发回滚
}

不要无脑所有方法都写 rollbackFor = Exception.class,要理解业务语义。


📖 五、传播行为

传播行为含义
REQUIRED默认,有事务就加入,没有就新建
REQUIRES_NEW新建事务,挂起外层事务
SUPPORTS有事务就加入,没有就非事务执行
MANDATORY必须已有事务,否则报错
NOT_SUPPORTED以非事务执行,挂起外层事务
NEVER必须无事务,否则报错
NESTED嵌套事务,依赖保存点

常见用法:

java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(AuditLog log) {
    auditLogRepository.save(log);
}

审计日志使用独立事务,即使主事务回滚,日志仍可保留。但这是否符合业务,需要明确。


📖 六、隔离级别

隔离级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED避免可能可能
REPEATABLE_READ避免避免可能
SERIALIZABLE避免避免避免

示例:

java
@Transactional(isolation = Isolation.READ_COMMITTED)
public OrderResponse getOrder(Long id) {
    return orderRepository.findResponse(id);
}

隔离级别最终由数据库支持。MySQL InnoDB 默认通常是 REPEATABLE_READ


📖 七、只读事务

java
@Transactional(readOnly = true)
public UserResponse getUser(Long id) {
    return userRepository.findResponseById(id);
}

只读事务可以给 ORM 和数据库优化提示,也表达语义。但不要在只读事务里做写操作。


📖 八、事务边界设计

错误示例:

java
@Transactional
public void pay(Order order) {
    orderRepository.markPaying(order.getId());
    paymentClient.pay(order); // 远程调用在事务内
    orderRepository.markPaid(order.getId());
}

远程调用慢或超时会导致数据库事务长时间持锁。

更合理的设计:

text
创建支付单 -> 提交事务
调用支付网关 -> 接收回调
回调中更新订单状态

复杂分布式场景要用状态机、消息队列、补偿机制,而不是一个巨大本地事务。


⚠️ 九、常见事务失效场景

这些也是最常见陷阱。排查事务问题时,不要只看注解是否存在,要确认调用链是否真的进入代理。

1. 同类自调用

java
public void outer() {
    inner();
}

@Transactional
public void inner() {
}

没有经过代理,事务不生效。

2. 非 public 方法

Spring 默认只对 public 方法可靠生效。

3. 异常被吞

java
@Transactional
public void create() {
    try {
        repository.save(entity);
    } catch (Exception e) {
        log.error("failed", e);
    }
}

方法正常返回,事务会提交。

4. 数据库不支持事务

例如 MySQL MyISAM 不支持事务。

5. 新线程中执行数据库操作

事务上下文绑定在线程上,新线程不会自动继承外层事务。


🆚 十、Java vs C 对比

维度C/JDBC 手动事务Spring 事务
开启事务手动 beginsetAutoCommit(false)AOP 自动开启
提交回滚手动 commit/rollback根据方法返回/异常处理
传播行为手动管理连接和嵌套Propagation 声明
失效风险忘记回滚/关闭连接代理边界、自调用、异常吞掉

Spring 事务简化了样板代码,但更依赖你理解 AOP 和数据库行为。


💡 十一、最佳实践

  • 事务边界放在 Service 层,一个方法表达一个业务用例。
  • 事务方法保持短小,不在事务内做远程调用。
  • 写操作方法明确是否需要事务。
  • 读操作可以使用 readOnly = true
  • 遇到事务失效先检查是否经过代理。
  • 捕获异常后如果需要回滚,要重新抛出或手动标记回滚。
  • 传播行为不要滥用,尤其是 REQUIRES_NEW
  • 慢事务要结合数据库锁等待和连接池一起排查。

🔍 十二、自测问题

text
@Transactional 为什么依赖 AOP?
默认哪些异常会触发回滚?
REQUIRED 和 REQUIRES_NEW 有什么区别?
readOnly=true 有什么意义?
为什么同类自调用会导致事务失效?
事务内远程调用有什么风险?
捕获异常后不抛出会发生什么?
事务和数据库锁是什么关系?

🧭 十三、事务排查清单

事务没有按预期回滚时,检查:

text
方法是否 public?
方法是否在 Spring Bean 中?
调用是否经过代理?
是否同类自调用?
异常是否被 catch 后吞掉?
异常类型是否满足回滚规则?
数据库表是否支持事务?
是否使用了错误的传播行为?
是否在新线程中执行了数据库操作?

事务导致接口慢时,检查:

text
事务是否过大?
事务内是否有远程调用?
是否批量更新大量数据?
是否等待锁?
数据库连接池是否耗尽?
是否存在慢 SQL?

🧪 十四、实战案例:订单创建事务

java
@Transactional
public OrderResponse createOrder(OrderCreateRequest request) {
    OrderEntity order = orderRepository.save(OrderEntity.create(request));
    List<OrderItemEntity> items = request.toItems(order.getId());
    orderItemRepository.saveAll(items);
    inventoryRepository.deduct(request.getProductId(), request.getQuantity());
    return OrderResponse.from(order);
}

这个事务适合保证本地数据库内订单和明细一致。

不适合放进同一个事务:

text
调用支付网关
调用短信服务
调用库存微服务
等待用户确认
生成大型报表

这些操作会让事务持有数据库连接和锁太久。


📌 十五、学习建议

建议手动制造四个事务失效案例:

text
同类自调用
private 方法
catch 后不抛异常
checked exception 默认不回滚

然后分别修复。能解释这些案例,才算真正理解 Spring 事务。


📚 十六、传播行为选择建议

场景推荐
普通业务写操作REQUIRED
审计日志独立保存REQUIRES_NEW
必须在外层事务中执行MANDATORY
查询且可有可无事务SUPPORTS
需要保存点回滚NESTED

不要把传播行为当成“修 bug 开关”。传播行为改变的是事务边界,可能直接改变业务一致性。


📌 十七、事务日志建议

排查事务问题时,日志要包含:

text
业务 ID,例如 orderId。
事务入口方法。
关键数据库操作。
异常堆栈。
耗时。
是否发生重试。

如果一个事务方法同时操作多个聚合对象,日志中应能看出执行到哪一步失败。


🧪 十八、锁等待案例

text
事务 A 更新订单 1001 后迟迟不提交。
事务 B 也要更新订单 1001,被阻塞。
接口表现为响应慢。
线程 dump 可能看不到明显 CPU 问题。
数据库侧能看到锁等待。

所以事务慢不一定是 Java 慢,也可能是数据库锁等待。


📌 十九、事务边界反例

不要把一个完整用户操作中所有步骤都塞进一个事务:

text
保存订单
调用支付
发短信
写操作日志
推送 WebSocket
调用第三方积分

这类流程应该拆成:

text
本地事务保存核心状态。
提交后发布事件。
异步执行通知、积分、推送。
失败时可重试或补偿。

事务越短,锁持有时间越短,系统吞吐越稳定。

如果必须跨多个外部系统完成业务,优先考虑事件驱动和补偿机制,而不是扩大本地事务。

事务边界要能被测试覆盖。 核心事务方法建议配合集成测试验证回滚。 不要只依赖人工代码审查判断事务是否生效。 事务问题一旦进入生产,通常会表现为数据不一致或锁等待。


🎓 小结

Spring 事务的核心不是注解本身,而是事务边界设计。你需要同时理解 AOP 代理、异常回滚、传播行为、数据库隔离和锁,才能写出可靠的企业业务逻辑。