@Transactional 到底要不要加 rollbackFor?我建议你先看完这个事故
很多团队都有一个习惯:所有
@Transactional后面都顺手加上rollbackFor = Exception.class。也有人反对:不要无脑加,Spring 默认只回滚运行时异常是有原因的。
那到底该不该加?
这篇不背概念,直接从一个线上数据不一致的问题说起。
一、事故现场
线上有一个订单确认接口,逻辑很简单:
- 更新订单状态为
CONFIRMED - 扣减库存
- 写操作日志
这三个动作必须在同一个事务里。
代码大概长这样:
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockService stockService;
@Autowired
private OperationLogMapper operationLogMapper;
@Transactional
public void confirmOrder(Long orderId) throws Exception {
orderMapper.updateStatus(orderId, "CONFIRMED");
stockService.deduct(orderId);
operationLogMapper.insert(new OperationLog(orderId, "CONFIRM_ORDER"));
}
}
测试环境跑得好好的。
结果上线后某天出现了一个诡异问题:
订单状态已经变成
CONFIRMED,但是库存没有扣成功。
按理说,库存扣减失败,整个事务应该回滚。
但数据库里订单状态已经提交了。
第一反应通常是:
@Transactional又失效了?
其实不是注解没生效,而是异常类型不对。
二、问题藏在 throws Exception 里
看一下 stockService.deduct(orderId) 的实现:
java
@Service
public class StockService {
public void deduct(Long orderId) throws Exception {
Integer stock = queryStock(orderId);
if (stock == null || stock <= 0) {
throw new Exception("库存不足");
}
// 扣减库存
updateStock(orderId);
}
}
注意这里抛的是:
java
throw new Exception("库存不足");
这是一个受检异常,也就是 checked exception。
而 Spring 事务默认只会回滚这两类异常:
text
RuntimeException
Error
默认不会因为 checked exception 回滚。
所以当 deduct() 抛出 Exception 时,外层方法确实中断了,调用方也确实看到了异常。
但是 Spring 事务拦截器判断后发现:
这是 checked exception,不在默认回滚范围内。
于是事务提交了。
订单状态就这样被提交到了数据库。
三、最小复现
下面这段代码就能复现这个问题。
java
@Service
public class OrderService {
@Transactional
public void confirmOrder(Long orderId) throws Exception {
orderMapper.updateStatus(orderId, "CONFIRMED");
throw new Exception("库存不足");
}
}
调用:
java
try {
orderService.confirmOrder(1001L);
} catch (Exception e) {
log.error("确认订单失败", e);
}
你会发现:
text
方法抛异常了
调用方 catch 到异常了
但是订单状态还是被更新了
这就是最容易误判的地方。
很多人以为:
只要方法抛异常,事务就一定回滚。
实际上不是。
更准确地说:
只有抛出的异常命中了事务回滚规则,Spring 才会回滚。
四、为什么 Spring 默认不回滚 checked exception?
这不是 Spring 的 bug。
Spring 默认遵循的是 EJB 时代留下来的事务语义:
text
RuntimeException:通常代表程序错误或不可恢复异常,默认回滚
Checked Exception:通常代表业务可预期异常,默认不回滚
比如这些异常从语义上看,可能只是业务流程的一部分:
java
throw new FileNotFoundException("文件不存在");
throw new IOException("读取失败");
throw new Exception("库存不足");
Spring 没法判断你的 checked exception 到底意味着什么。
它不知道"库存不足"是应该回滚,还是应该允许前面的操作提交。
所以它选择了一个保守默认值:
checked exception 不回滚,除非你明确告诉 Spring 要回滚。
五、修复方式 1:加 rollbackFor
最直接的修复方式:
java
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) throws Exception {
orderMapper.updateStatus(orderId, "CONFIRMED");
stockService.deduct(orderId);
operationLogMapper.insert(new OperationLog(orderId, "CONFIRM_ORDER"));
}
加上后,只要方法抛出 Exception 或它的子类,事务都会回滚。
也就是说:
java
throw new Exception("库存不足");
现在会触发回滚。
很多团队会把它当成默认写法:
java
@Transactional(rollbackFor = Exception.class)
这个习惯不是没有道理。
因为在真实业务代码里,很多人会随手写:
java
throws Exception
或者在底层工具、RPC、文件、消息、第三方接口里抛出 checked exception。
如果事务方法里没加 rollbackFor,就很容易出现:
异常抛出去了,但数据已经提交了。
六、修复方式 2:抛 RuntimeException
另一种方式是把业务异常设计成运行时异常。
java
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}
然后业务里这样写:
java
@Transactional
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
Integer stock = queryStock(orderId);
if (stock == null || stock <= 0) {
throw new BizException("库存不足");
}
stockMapper.deduct(orderId);
}
因为 BizException 继承了 RuntimeException,所以即使不写 rollbackFor,事务也会默认回滚。
这也是很多项目里的统一异常设计:
text
业务异常 BizException 继承 RuntimeException
系统异常 SystemException 继承 RuntimeException
参数异常 ParamException 继承 RuntimeException
这样事务回滚规则会简单很多。
七、那到底该不该所有事务都加 rollbackFor?
我的建议是:
业务系统里,绝大多数写操作事务,都建议加
rollbackFor = Exception.class。
尤其是这些场景:
| 场景 | 建议 |
|---|---|
| 订单、支付、库存、账户余额 | 必须加 |
| 多表写入,要求强一致 | 必须加 |
| 调用底层组件可能抛 checked exception | 建议加 |
历史代码里大量 throws Exception |
建议加 |
| 只读查询事务 | 意义不大 |
| 明确希望 checked exception 不回滚 | 不要加,单独声明规则 |
为什么我倾向于加?
因为线上事故里,"该回滚却没回滚"的代价,通常比"多回滚了一次"的代价更高。
订单状态、账户金额、库存数量这种数据,一旦错了,后面补偿很麻烦。
所以我更愿意让事务默认更谨慎一点。
八、但不要把 rollbackFor 当万能药
加了 rollbackFor = Exception.class,不代表事务一定会回滚。
下面这些情况,它还是救不了。
1. 异常被 catch 吞掉
java
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
try {
stockService.deduct(orderId);
} catch (Exception e) {
log.error("扣减库存失败", e);
}
}
异常被 catch 后没有继续抛出。
Spring 看不到异常,事务照样提交。
正确做法:
java
try {
stockService.deduct(orderId);
} catch (Exception e) {
log.error("扣减库存失败", e);
throw e;
}
或者包装成业务异常:
java
try {
stockService.deduct(orderId);
} catch (Exception e) {
throw new BizException("扣减库存失败");
}
2. 自调用绕过代理
java
@Service
public class OrderService {
public void submit(Long orderId) throws Exception {
confirmOrder(orderId);
}
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) throws Exception {
orderMapper.updateStatus(orderId, "CONFIRMED");
throw new Exception("库存不足");
}
}
submit() 调用 confirmOrder() 是同类内部调用,本质是:
java
this.confirmOrder(orderId);
没有走 Spring 代理。
所以事务不会开启。
rollbackFor 写得再漂亮也没用。
3. 数据库本身不支持事务
sql
CREATE TABLE order_info (
id BIGINT PRIMARY KEY,
status VARCHAR(32)
) ENGINE = MyISAM;
MyISAM 不支持事务。
代码层事务配置再正确,也回滚不了数据库层已经提交的数据。
4. 事务方法里开了新线程
java
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CONFIRMED");
CompletableFuture.runAsync(() -> {
stockMapper.deduct(orderId);
throw new RuntimeException("扣减库存失败");
});
}
事务上下文绑定在线程上。
新线程里的异常不会自动影响外层事务。
所以 @Transactional 和异步一起用时,一定要特别小心。
九、一个更稳的团队规范
如果让我给 Java 后端团队定规范,我会这样写:
1. 写操作事务默认加 rollbackFor
java
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
// ...
}
尤其是涉及状态、金额、库存、积分、额度、账务的接口。
2. 不要在事务方法里吞异常
错误示例:
java
try {
doSomething();
} catch (Exception e) {
log.error("执行失败", e);
}
正确示例:
java
try {
doSomething();
} catch (Exception e) {
log.error("执行失败", e);
throw e;
}
3. 业务异常统一继承 RuntimeException
java
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}
不要到处 throws Exception。
4. 事务方法保持边界清晰
不要在事务里塞太多不确定操作:
text
远程接口
大文件上传
消息发送
耗时计算
第三方 API
这些操作会让事务持有时间变长,也会让失败边界变复杂。
能拆就拆,必须一致的再放进事务。
5. 明确哪些异常不回滚
如果你真的希望某类异常不回滚,可以显式写出来:
java
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = BizWarningException.class
)
public void confirmOrder(Long orderId) {
// ...
}
不要靠默认行为让后来维护的人猜。
十、上线前 checklist
| 检查项 | 风险 | 建议 |
|---|---|---|
是否加了 rollbackFor |
checked exception 不回滚 | 写操作建议加 |
| 是否 catch 后吞异常 | Spring 感知不到异常 | catch 后继续 throw |
| 是否存在自调用 | 绕过 Spring 代理 | 拆到另一个 Service |
| 是否手动 new 对象 | Bean 不受 Spring 管理 | 通过依赖注入使用 |
| 是否用了异步线程 | 事务上下文不传递 | 异步逻辑单独设计事务 |
| 是否调用远程接口 | 事务时间过长 | 尽量移出事务 |
| 表引擎是否支持事务 | 数据库无法回滚 | MySQL 使用 InnoDB |
十一、总结
rollbackFor = Exception.class 不是银弹,但它能防住一个非常常见的坑:
checked exception 抛出去了,事务却提交了。
我个人的建议是:
写操作事务,尤其是核心业务写操作,默认加
rollbackFor = Exception.class。
但同时要记住三句话:
- 异常必须抛到事务代理层,吞掉就不会回滚。
- 方法调用必须经过 Spring 代理,自调用不会生效。
- 事务边界要小,别把远程调用、异步任务、耗时操作全塞进去。
最后再说一句:
事务问题最坑的地方,不是直接报错,而是它看起来成功了,只是数据悄悄不一致了。
这也是为什么我建议你对每一个写操作事务都多看一眼。