@Transactional 事务失效排查,try-catch 吞异常导致回滚失败(附源码分析)

老炮踩坑录 · F09 · 翻车现场系列

· 基于「企业融合评估平台」真实源码,复盘 @Transactional 被 try-catch 架空的经典翻车

· 关键词:@Transactional 失效 · 事务不回滚 · try-catch 吞异常 · rollbackFor

引子

如果一个 Java 开发者只能记住一条 Spring 注解,大部分人会选 @Transactional。

加上去,事务就有了,数据一致性,也稳了。

真的稳了吗?

2022年的老项目里有个Service接口加了 @Transactional(rollbackFor = Exception.class)------你看,连 rollbackFor 都写了,多规范。但它的事务一次都没成功回滚过。

不是 Spring 的锅,是代码里一个 try-catch,把异常吞得干干净净。

@Transactional 以为一切正常,事务正常提交。但数据库里的数据,已经半截 delete掉了、半截还存在------数据不一致了。

今天这篇,我从项目里一个真实的 "删除失败" 接口出发,拆透这个 90% 的 Java 开发者都踩过的坑。

这个坑不是个例,它藏在项目的很多角落。整个排查和复盘的过程,我整理成了下面这张图。你可以对照看看,你项目里有没有类似的隐患。

案发现场:一个"删除失败"的接口

这个接口叫 deleteReport,功能是"删除诊断报告"------用户想换一套诊断模型,系统需要先删掉旧数据再重建。

删除涉及 5 张关联表,必须原子操作,要么全删,要么全不删,所以开发者加了 @Transactional。

看起来考虑得很周到,来看代码:

java 复制代码
// ApplyInfoController.java L353-381
@GetMapping("/deleteReport")
@Transactional(rollbackFor = Exception.class)
public Object deleteReport(HttpServletRequest request) throws Exception {
    String enterpriseId = SessionCacheUtils.getEnterpriseid();
    // ...前置校验...
    try {
        applyInfoService.deleteReport(enterpriseId);    // ① 删申报信息
        diaService.delete(enterpriseId);                // ② 删诊断试卷
        maService.delete(enterpriseId);                 // ③ 删成熟度考卷
        caService.delete(enterpriseId);                 // ④ 删能力试卷
        otherService.delete(enterpriseId);              // ⑤ 删其他试卷
        applyInfoService.updateApplyId(enterpriseId);   // ⑥ 清空关联ID
    } catch (Exception e) {
        log.error("删除失败:{} ",e);                       // ⑦ 记录错误日志
        return ResultInfo.setResultInfo(true, 500, "error", "删除失败,重新尝试"); // ⑧ 返回响应
    }
    return ResultInfo.setResultInfo(true, 200, "ok", "删除成功");
}

你看出问题了吗?

如果没看出来,我把这段代码翻译成"人话":

  1. ①②③ 三步删除执行成功,数据库里三张表的数据已经没了
  2. 如果④ 第四步抛异常了------比如数据库连接超时
  3. catch 接住了异常,记录日志
  4. 方法 return 了一个"删除失败"的响应,正常结束
  5. Spring 事务管理器一看:方法正常返回,没有异常,提交事务

结果:前三张表的数据删了,后两张表的数据还在。说好的"要么全删,要么全不删"呢?没有兑现呀?

为什么没回滚?一句话讲透

Spring 的 @Transactional 注解靠的是 AOP 代理。原理很简单:

关键点:代理只能看到"方法有没有抛异常"。

如果你的方法内部用 try-catch 把异常吃掉了,然后正常 return------代理看到的是"方法正常返回",于是提交事务。

打个比方:

@Transactional 就像大楼的消防系统------它能感知火灾(异常),然后自动喷水(回滚)。

但你的 try-catch 相当于有人在火灾报警器响之前,把报警器关了。

消防队以为一切正常,但楼已经烧了一半。

Spring 事务的核心逻辑在 TransactionInterceptor 这个类里。它的工作流程极其简单,核心代码逻辑大致如下:

java 复制代码
// TransactionAspectSupport.java 核心逻辑
try {
    // 执行业务方法
    retVal = invocation.proceed();
} catch (Throwable ex) {
    // 一旦捕获到异常,立刻标记回滚
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
}
// 如果方法正常返回,提交事务
commitTransactionAfterReturning(txInfo);

问题就出在这里 :因为你的代码在业务方法内部自己 try-catch 了,异常没有"冒泡"到 invocation.proceed() 外面。Spring 以为业务方法"正常返回"了,于是正常的地执行了 commitTransactionAfterReturning方法提交事务。

一句话总结:Spring 监听的是"异常抛出",而不是"数据异常"。

这就是 deleteReport 的问题:@Transactional 配了,但 try-catch 把异常吞了,Spring事务永远看不到异常,永远不回滚。

这不是个例:同一个项目,同一个坑

我以为只有这一个接口这么写的,搜了一下,发现至少有两处。

第二处:saveSelectPaper

java 复制代码
// ApplyInfoController.java L176-192
@GetMapping("/createSelectType")
@Transactional(rollbackFor = Exception.class)
public Result createSelectType(Integer paperid) {
    // ...
    try {
        return applyInfoService.addApplyInfo(paperid);
    } catch (Exception e) {  // 吞了异常
        e.printStackTrace();                    // 记录日志
        return result.fail(ResultEnum.FAIL);    // 正常返回
    }
}

这个方法中调用了 ApplyInfoServiceImpl.addApplyInfo(),而 addApplyInfo 里面又做了什么?

java 复制代码
// ApplyInfoServiceImpl.java L232-295
public Result addApplyInfo(Integer... papers) throws Exception {
    // ① 创建申报信息
    mapper.addApplyInfo(info);
    // ② 更新企业基本信息表的 applyId
    mapper.updateApplyId(applyid, enterpriseId, userAccount);
    // ③ 循环创建试卷(4 种模型,每张都要建)
    for (Integer str : papers) {
        switch (str) {
            case 0: diagnosisMapper.save(...); break;
            case 1: maMapper.save(...); break;
            case 2: caMapper.save(...); break;
            case 3: cloudMapper.save(...); cloudMapper.saveExtend(...); break;
        }
    }
    // ④ 初始化申报附件表
    applyFileMapper.insert(applyFile);
    return result.success(retData);
}

这个方法里做了 4 步写操作:创建申报、更新企业表、建 4 张试卷、初始化附件表。任何一步失败都应该整体回滚。

它确实加了 @Transactional(rollbackFor = Exception.class),而且也抛出了异常。

但调用它的 Controller 用 try-catch 把异常吞了。异常传不到 Service 层的事务边界,Spring事务以为一切正常,提交。

如果第 ③ 步建第 3 张试卷时数据库报错------申报信息创建了、企业表更新了、前 2 张试卷建好了------这些全部提交,不会回滚。

这个项目的 @Transactional 问题全景

我全局扫了一遍整个项目,发现事务管理的问题远不止 try-catch 吞异常,而是三个层次全踩了:

层次 问题 本项目实例 风险
位置错 @Transactional 加在 Controller 上 ApplyInfoController.deleteReport、ReportController.updateMaturityInfo Controller 不该管事务,事务边界应在 Service 层
吞异常 try-catch 吃掉异常后正常返回 deleteReport、saveSelectPaper 事务永远提交,回滚形同虚设
缺rollbackFor 裸 @Transactional 不指定回滚异常类型 10 个 Service 类全部裸写 受检异常(IOException 等)不回滚

第三个问题值得单独说说。

项目里有 10 个 Service 类用了裸的 @Transactional,没写 rollbackFor:

java 复制代码
@Transactional  // 没有 rollbackFor
public class EnterpriseRegistServiceImpl implements EnterpriseRegistService { ... }

@Transactional  // 没有 rollbackFor
public class DagInfoServiceImpl implements DiagnosisInfoService { ... }

@Transactional  // 没有 rollbackFor
public class HwFileServiceImpl extends AbstractFileService { ... }

// ... 还有 7 个,全是同样的写法

Spring 的默认行为:只对 RuntimeException 和 Error 回滚,对受检异常(checked exception)不回滚。

也就是说,如果 EnterpriseRegistServiceImpl 里的方法抛了 IOException 或 SQLException------事务不回滚,正常提交。

你以为加了 @Transactional 就万事大吉,但 Spring 默认只保护了一半。

更离谱的是 HwFileServiceImpl等------它们把 @Transactional 加在了类级别 。这意味着所有 public 方法都被事务包裹,包括那些根本不需要事务的查询方法。每个 SELECT 都开一个事务,白白浪费数据库连接资源。

正确写法:三条铁律

铁律一:@Transactional 放 Service 层,不要放 Controller

java 复制代码
// 错误:Controller 层加事务
@Transactional(rollbackFor = Exception.class)
public Object deleteReport(...) { ... }

// 正确:Controller 只调 Service,事务由 Service 管
public Object deleteReport(...) {
    return reportService.deleteReport(enterpriseId);
}

Controller 的职责是接请求、返响应。事务是业务逻辑的事,交给 Service。这也符合单一职责设计原则。

铁律二:事务方法里,异常必须往外抛

java 复制代码
// 错误:try-catch 吞异常,事务永远提交
try {
    service.deleteA();
    service.deleteB();
} catch (Exception e) {
    e.printStackTrace();  // 吞了异常,异常止步于此,spring事务看不到了
    return "删除失败";
}

// 正确:异常抛出去,让 @Transactional 看到
try {
    service.deleteA();
    service.deleteB();
} catch (Exception e) {
    log.error("删除失败: enterpriseId={}", enterpriseId, e);
    throw new BusinessException("删除失败,请稍后重试", e);  // 抛出去!
}

如果你一定要在 catch 里做点事情(比如记日志、发告警),做完之后必须 throw 。可以抛原异常,也可以抛新异常,但不能 return。

但如果在Controller层捕获了异常,事务会生效吗?先说结论,会的。看个例子。

如果事务边界在 Service 方法,不在 Controller.

Service 里写:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Result addApplyInfo(Integer... papers) throws Exception {//抛出异常
    // 创建申报信息
    mapper.addApplyInfo(info);
    ....
}

Controller 里写:

java 复制代码
try {
    return applyInfoService.addApplyInfo(paperid);
} catch (Exception e) {
    log.error("删除失败: paperid={}", paperid, e);
    return result.fail(ResultEnum.FAIL);
}

这个 try-catch 是在 事务代理之外。调用顺序大致是:

rust 复制代码
Controller
   -> Spring 事务代理
       -> 开启事务
       -> 执行 addApplyInfo()
       -> 方法抛出异常
       -> 事务代理捕获异常
       -> 根据 rollbackFor 执行 rollback
       -> 重新抛出异常
   -> Controller 的 catch 捕获到异常
   -> 返回 result.fail

所以异常先被 Spring 事务拦截器感知并回滚,然后才被 Controller 捕获。Controller 即使把异常"吞掉"了,也 不会影响已经发生的回滚,也不会让事务提交。

记住:Service 里的 catch 里可以做事,但做完必须让异常继续传播。@Transactional 只认异常,不认 return。

铁律三:永远写 rollbackFor = Exception.class

java 复制代码
// 裸 @Transactional:受检异常不回滚
@Transactional
public void doSomething() { ... }

// 明确指定回滚条件
@Transactional(rollbackFor = Exception.class)
public void doSomething() { ... }

这一条没有例外。不管你觉不觉得"我的方法不会抛受检异常"------写上它,零成本,但关键时刻能救命。


终极方案:使用 TransactionTemplate 手动控制。 如果你觉得声明式事务太容易踩坑,那么最安全的做法就是显式编程。在代码里直接注入 TransactionTemplate,在 execute 方法里写业务逻辑,一旦有异常直接抛出,绝对可控。 适用场景:逻辑极其复杂、有大量中间状态处理的逻辑,用声明式事务难以控制边界时。

自查清单

在项目里搜到三个东西:

检查项 怎么搜 正确标准
Controller 上的 @Transactional IDE 搜 @Transactional,看文件路径 只出现在 service/ 目录,Controller 里一个都没有
事务方法里的 try-catch 搜 catch (Exception,看是否在 @Transactional 方法内 catch 块最后一行必须是 throw,不能是 return
裸 @Transactional 搜 @Transactional 后不带 rollbackFor 的 每一个都带 rollbackFor = Exception.class

这三条,随便哪一条搜出结果,你的项目就存在"事务假死",不回滚的风险。

💡 老炮补充:除了本文讲的三种,还有两个经典的事务失效场景:

  1. 同类内部调用:同一个Service类里,方法A(加事务)调用了方法B(加事务),因为不走代理,事务会失效。
  2. 方法不是public:Spring AOP无法代理 private、protected 或包级私有方法。 这两个坑,以后在别的文章里我会单独拆解。

老炮点评

这个坑的本质,是"形式正确 "和"实际有效"之间的幻觉。

  • @Transactional 加了 → 形式正确
  • rollbackFor 写了 → 形式正确
  • try-catch 兜住了 → 看起来健壮

但组合在一起,事务保护就静默失效 了。最可怕的是,这种失效不会报错、不会告警、不会在测试环境暴露------它只会在生产环境某个并发高峰,悄悄留下半截数据。

我见过太多团队花大力气设计分布式事务、搞最终一致性方案,但最基本的 @Transactional + try-catch 这关都没过。

事务管理的第一课不是分布式,是别让 try-catch 把异常吞了。

事务回滚的关键:异常必须让事务拦截器感知到。要么抛出事务方法外,要么内层事务已标记 rollback-only,要么手动 setRollbackOnly()。吞掉异常且无内层标记,事务就不会回滚。


下期预告:《Spring Boot 2.1.0 已经"死"了,你的项目还在裸奔吗?》

Spring事务的坑填完了,下期把眼光从代码抬到框架------pom.xml 里那个 Spring Boot 2.1.0,已经停维护了。没有安全补丁、没有社区支持,升级又怕踩坑。

老项目用停维护的版本,到底有多危险?下期见。

我是老炮,18年Java老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
IT_陈寒1 小时前
Java中equals方法比了个寂寞?原来这才是正确的重写姿势
前端·人工智能·后端
Thneonl1 小时前
Celery 生产踩坑:1000 任务积压与 acks_late 双重执行
后端·python
卷福同学1 小时前
第一次当面试官有感
后端·面试
苏三说技术1 小时前
为什么越来越多人用 OnlyOffice?
后端
羑悻1 小时前
Codex + Seed-2.1-pro 实测:多模态理解 + Coding Agent 能扛住真实仓库吗?
后端
颜进强1 小时前
14 · NestJS ExecutionContext 执行上下文:守卫、拦截器、过滤器拿到的"同一个 context",为什么能力不一样?
前端·后端·ai编程
小小张说故事1 小时前
Python 多线程为什么跑不快?asyncio 入门指南:异步并发从零上手
后端·python
明月_清风1 小时前
数据进入平台后怎么处理?一文搞懂 ETL
大数据·后端·数据分析
初学AI的小高1 小时前
LangGraph断点恢复与幂等执行实战
后端·架构