@Transactional 到底要不要加 rollbackFor?一次数据不一致事故讲清楚

@Transactional 到底要不要加 rollbackFor?我建议你先看完这个事故

很多团队都有一个习惯:所有 @Transactional 后面都顺手加上 rollbackFor = Exception.class

也有人反对:不要无脑加,Spring 默认只回滚运行时异常是有原因的。

那到底该不该加?

这篇不背概念,直接从一个线上数据不一致的问题说起。


一、事故现场

线上有一个订单确认接口,逻辑很简单:

  1. 更新订单状态为 CONFIRMED
  2. 扣减库存
  3. 写操作日志

这三个动作必须在同一个事务里。

代码大概长这样:

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

但同时要记住三句话:

  1. 异常必须抛到事务代理层,吞掉就不会回滚。
  2. 方法调用必须经过 Spring 代理,自调用不会生效。
  3. 事务边界要小,别把远程调用、异步任务、耗时操作全塞进去。

最后再说一句:

事务问题最坑的地方,不是直接报错,而是它看起来成功了,只是数据悄悄不一致了。

这也是为什么我建议你对每一个写操作事务都多看一眼。

相关推荐
Csvn1 小时前
日志分析进阶 — Logwatch 与 GoAccess 实战
后端
Moment1 小时前
牛逼,NextJs 从 16.3 开始全面拥抱 Agent Native 🥰🥰🥰
前端·后端·面试
Csvn1 小时前
CI/CD 入门 — 用 GitLab CI 构建自动化部署流水线
后端
沸点小助手2 小时前
6月沸点活动获奖名单公示|本周互动话题上新🎊
前端·后端
胡萝卜术2 小时前
从“分数打架”到“排名投票”:为什么你的ChatBI必须用RRF?
算法·设计模式·面试
远航_2 小时前
git submodule
前端·后端·github
狂师2 小时前
测试工程师的AI 技能库:推荐5个让你效率翻倍的Skills
前端·后端·测试
CodeSheep2 小时前
DeepSeek正式官宣摇人,夯!
前端·后端·程序员
亦暖筑序2 小时前
Java 8老系统AI Workflow实战:把一次性AI对话升级成可恢复工作流
java·后端