老炮踩坑录 · 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", "删除成功");
}
你看出问题了吗?
如果没看出来,我把这段代码翻译成"人话":
- ①②③ 三步删除执行成功,数据库里三张表的数据已经没了
- 如果④ 第四步抛异常了------比如数据库连接超时
- catch 接住了异常,记录日志
- 方法 return 了一个"删除失败"的响应,正常结束
- 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 |
这三条,随便哪一条搜出结果,你的项目就存在"事务假死",不回滚的风险。
💡 老炮补充:除了本文讲的三种,还有两个经典的事务失效场景:
- 同类内部调用:同一个Service类里,方法A(加事务)调用了方法B(加事务),因为不走代理,事务会失效。
- 方法不是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老炮踩坑录」,不错过每一篇真实案例,少踩坑。