Appearance
第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 事务 |
|---|---|---|
| 开启事务 | 手动 begin 或 setAutoCommit(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 代理、异常回滚、传播行为、数据库隔离和锁,才能写出可靠的企业业务逻辑。