1. 先搞清楚:声明式事务到底在替我们做什么
@Transactional这个注解,几乎是每个 Java 后端开发者每天都在用的东西。加在方法上,方法里的多条数据库操作就自动变成一个原子单元,抛异常就回滚,正常结束就提交。看起来简单到不需要思考,但恰恰是这种“看起来简单”的东西,坑最多。
我见过太多项目里,注解加得满满当当,代码跑起来也没报错,但数据该回滚的时候没回滚,该一致的时候不一致。排查半天,最后发现是事务压根没生效——注解在那儿摆着,但代理没拦住,或者拦住了但异常被吞了,或者数据源根本就不是同一个。这类问题有个共同特点:不报错、不提示、静默失效,等你发现数据脏了,往往已经过去好几天。
这篇内容就是把我这些年踩过的、帮别人排查过的声明式事务失效场景做个系统梳理。核心聚焦三类最高频的坑:代理机制导致的失效、异常处理导致的失效、事务传播与数据源配置导致的失效。每一类我都会讲清楚底层原理、给出可复现的代码片段、说明排查方法,最后整理成速查表。适合已经用过@Transactional但被它坑过的开发者,也适合想彻底搞明白 Spring 事务代理机制的同学。读完之后,你至少能做到:看到一段加了事务注解的代码,能快速判断它到底会不会生效。
先说一个基本认知,这是后面所有内容的地基:Spring 的声明式事务,本质上是AOP 代理。你写的 Service 方法加了@Transactional,Spring 在启动时会给这个 Bean 生成一个代理对象,调用方拿到的其实是代理,不是原始对象。代理在方法执行前后插入事务的开启、提交、回滚逻辑。所以,事务生效的前提是:调用必须经过代理。任何绕过代理的调用,注解就是一行注释。这个认知记住了,后面很多坑你甚至能自己预判。
2. 第一类坑:代理没拦住,注解成了摆设
这是最经典、最高频的一类。注解明明加了,日志里也看不到任何事务相关的输出,方法里的多条 SQL 各自独立提交。根因只有一个:调用没有经过代理对象。
2.1 同类内部方法调用:最隐蔽的自我欺骗
先看一段几乎每个项目都出现过的代码:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; public void createOrder(Order order) { // 一些前置校验 validate(order); // 调用本类的另一个方法,期望它有事务 saveOrderAndItems(order); } @Transactional(rollbackFor = Exception.class) public void saveOrderAndItems(Order order) { orderMapper.insert(order); // 插入订单明细,如果这里抛异常,期望订单也回滚 orderMapper.insertItems(order.getItems()); } }这段代码里,saveOrderAndItems加了事务注解,但通过createOrder调用它时,事务不会生效。原因很直接:createOrder内部调用saveOrderAndItems,用的是this.saveOrderAndItems(...),this是原始对象,不是代理对象。代理根本没参与这次调用,事务拦截器自然也不会执行。
这个坑的隐蔽性在于:代码看起来完全合理,注解也在,编译不报错,运行不报错,只有数据出问题时才会暴露。而且很多人第一次遇到时,会怀疑是注解参数写错了、是数据库引擎不支持事务,排查方向完全跑偏。
解决办法有几种,各有取舍:
- 把方法拆到另一个 Bean 里。这是最干净的做法。
saveOrderAndItems挪到一个独立的OrderTxService中,通过注入调用。跨 Bean 调用走的是代理,事务正常生效。缺点是类会变多,但对于核心事务逻辑,这点拆分成本完全值得。 - 注入自身代理。通过
@Autowired注入自己(需要配置允许循环依赖,或者用@Lazy),或者用AopContext.currentProxy()拿到当前代理再调用。后者需要开启@EnableAspectJAutoProxy(exposeProxy = true)。这种做法能用,但代码可读性差,团队里新人看到会懵,我不太推荐在业务代码里大量使用。 - 用
TransactionTemplate编程式事务。如果只是个别方法需要,直接注入TransactionTemplate,在方法里显式执行。牺牲了一点声明式的优雅,但意图极其明确,不会有“到底生没生效”的疑虑。
注意:同类调用失效这个坑,和 Spring 版本无关,和 Boot 版本也无关,它是代理机制的本质决定的。任何声称“升级版本就好了”的说法都是误导。
2.2 private 方法和 final 方法:代理根本没法覆盖
第二类代理失效,出在方法的可见性和修饰符上。
Spring 的 AOP 代理分两种:JDK 动态代理和 CGLIB 代理。Boot 2.x 之后默认用 CGLIB。CGLIB 的原理是生成一个子类,覆盖父类的方法,在覆盖的方法里织入增强逻辑。那么问题来了:private 方法不能被继承,final 方法不能被覆盖。这两种方法上的@Transactional,代理无能为力。
@Service public class PaymentService { @Transactional(rollbackFor = Exception.class) private void doPay(Payment payment) { // 这里的注解完全无效 paymentMapper.insert(payment); accountMapper.deduct(payment.getAmount()); } }private方法加事务注解,编译能过,启动不报错,但事务百分之百不生效。final方法同理,CGLIB 生成的子类无法覆盖它。还有一种情况是static方法,同样不行。
排查这类问题有个简单办法:看方法签名。只要不是public(或者至少是包级可见且非 final),就要警惕。我个人的习惯是,事务方法一律public,这是硬规矩,团队 code review 时也会专门盯这一点。
另外提一句,接口上的@Transactional和实现类上的注解,在 JDK 代理和 CGLIB 代理下行为有差异。Boot 默认 CGLIB 时,注解写在实现类方法上即可;如果项目强制用了 JDK 代理(比如spring.aop.proxy-target-class=false),那注解最好写在接口方法上,否则可能读不到。这个细节在混合代理配置的老项目里经常出问题。
2.3 对象不是 Spring 管的:new 出来的没有代理
第三类代理失效更基础:你操作的对象压根不是 Spring 容器里的 Bean。
public class ReportService { @Transactional(rollbackFor = Exception.class) public void generateReport(Long id) { // 多条写操作 } } // 在某个地方 ReportService reportService = new ReportService(); reportService.generateReport(1L); // 事务不生效new出来的对象,没有经过 Spring 的 Bean 生命周期,没有代理,注解就是装饰。这种情况在工具类、静态工厂、反射创建对象的场景里偶有出现。还有一种变体:通过@Bean方法手动new了一个对象返回,但方法内部又直接用了这个new出来的引用去调用事务方法,同样失效。
判断标准很简单:这个对象是不是从容器里拿的。@Autowired注入的、getBean拿的,是代理;new的、反序列化出来的、反射构造的,不是。写代码时如果对某个对象的来源不确定,宁可多看一眼注入链路。
3. 第二类坑:异常被吞了,回滚条件没触发
代理拦住了,事务也开启了,但最后数据还是没回滚。这类问题的根因在异常处理上。Spring 声明式事务的默认回滚规则是:遇到RuntimeException及其子类、Error时回滚;遇到受检异常(Checked Exception)时不回滚。围绕这条规则,衍生出一堆坑。
3.1 受检异常默认不回滚:最容易踩的默认值
先看代码:
@Transactional public void transfer(Long from, Long to, BigDecimal amount) throws Exception { accountMapper.deduct(from, amount); if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new Exception("金额非法"); // 受检异常 } accountMapper.add(to, amount); }金额非法时抛了Exception,按直觉应该回滚,扣款不该生效。但实际结果是:扣款提交了,加款没执行。因为Exception是受检异常,不在默认回滚范围内。
这个默认设计有它的历史原因,但对业务代码来说,几乎总是反直觉的。解决办法是显式指定rollbackFor:
@Transactional(rollbackFor = Exception.class)我现在的习惯是,所有事务注解一律带上rollbackFor = Exception.class,不依赖默认值。多写几个字符,省掉未来可能的排查时间,这笔账怎么算都划算。团队规范里也应该把这条写死。
3.2 异常被 catch 住没抛出:事务感知不到
第二类异常坑更常见:方法内部把异常吞了。
@Transactional(rollbackFor = Exception.class) public void batchImport(List<Item> items) { for (Item item : items) { try { itemMapper.insert(item); } catch (Exception e) { log.error("导入失败: {}", item.getId(), e); // 异常被吞,循环继续 } } }这段代码的意图可能是“单条失败不影响其他条”,但结果是:整个方法正常结束,事务提交,前面插入成功的记录全部落库。如果业务期望是“有任何一条失败就整体回滚”,那这个 catch 就是致命的。
这里要区分两种业务诉求:
- 整体原子:任何一条失败都要全部回滚。那就不能在循环里 catch,或者 catch 之后必须重新抛出(
throw new RuntimeException(e)),让异常传播到代理层。 - 单条独立:每条独立事务,失败的不影响成功的。那就不该用一个外层大事务,而应该让每条走独立事务(配合
REQUIRES_NEW或者拆成独立方法调用),并且明确接受“部分成功”的语义。
最怕的是代码写着大事务,心里想的是单条独立,或者反过来。这种语义错位,比技术 bug 更难排查,因为代码“运行正常”,只是结果和预期不符。
还有一种变体:catch 之后没有重新抛,而是返回了一个失败标记。事务代理只看有没有异常抛出,你返回什么它不关心。所以“返回失败对象”这种模式,在事务方法里要格外小心,它不会触发回滚。
3.3 异常被包装后类型变了:回滚规则对不上
第三类异常坑稍微绕一点:异常抛了,但类型变了。
@Transactional(rollbackFor = RuntimeException.class) public void process(Data data) { try { doProcess(data); } catch (IOException e) { throw new BizException("处理失败", e); // 自定义异常 } }如果BizException继承的是Exception而不是RuntimeException,那它就不在rollbackFor = RuntimeException.class的范围内,事务不回滚。很多人写rollbackFor时随手写了个RuntimeException,但业务里抛的自定义异常偏偏是受检的,两边对不上。
我的建议还是那条:rollbackFor = Exception.class,一劳永逸。如果团队有自定义异常体系,确保所有业务异常都继承RuntimeException,这样即使不写rollbackFor也能兜住。两条路选一条,别混着来。
提示:
noRollbackFor也要留意。有些代码为了“某些异常不回滚”,配了noRollbackFor = BizException.class,结果后来业务异常体系调整,这个配置变成了隐形炸弹。配置越少越安全。
4. 第三类坑:传播行为和数据源,事务边界错乱
前两类坑集中在“事务有没有开启”和“异常有没有触发回滚”。第三类坑更隐蔽:事务开启了,也回滚了,但回滚的范围不对,或者操作的根本不是同一个数据源。
4.1 传播行为用错:REQUIRES_NEW 和 NESTED 的语义差异
先看一个典型场景:主流程里记录操作日志,希望日志独立提交,不受主流程回滚影响。
@Transactional(rollbackFor = Exception.class) public void mainProcess(Order order) { orderMapper.insert(order); logService.saveLog(order.getId()); // 期望日志独立提交 if (someCondition) { throw new RuntimeException("业务失败"); } } // LogService @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void saveLog(Long orderId) { logMapper.insert(orderId); }REQUIRES_NEW的语义是:挂起当前事务,开启一个全新的事务,新事务独立提交或回滚。上面的代码在saveLog正常返回后,日志已经提交了,主流程后续抛异常回滚,日志不受影响。这个行为符合预期。
但很多人会把REQUIRES_NEW和NESTED搞混。NESTED是嵌套事务,基于保存点(savepoint)实现,外层回滚时内层也会回滚。如果上面的代码把REQUIRES_NEW换成NESTED,主流程抛异常时,日志会跟着回滚,和“独立提交”的预期完全相反。
两者的关键差异整理如下:
| 传播行为 | 底层机制 | 外层回滚时内层 | 内层回滚时外层 | 适用场景 |
|---|---|---|---|---|
| REQUIRES_NEW | 挂起外层,开新事务 | 不受影响 | 不受影响 | 日志、审计等独立提交 |
| NESTED | 保存点 | 一起回滚 | 可单独回滚 | 子步骤失败不影响主流程其他部分 |
还有一个高频错误:REQUIRES_NEW用在同类调用里。因为同类调用不走代理,传播行为配置根本不生效,实际执行的是外层事务的传播行为。这个坑和 2.1 节叠加,排查时容易只看到一层。
另外,REQUIRES_NEW会占用额外的数据库连接。如果外层事务持有连接,内层又开新事务,连接池瞬间需要两个连接。高并发下如果连接池配置偏小,可能出现等待超时。这个副作用在压测时才会暴露,平时开发环境很难发现。
4.2 多数据源下事务管理器不匹配
项目里配了多个数据源,事务注解加了,但操作的数据源和事务管理器对不上,回滚自然失效。
@Transactional(rollbackFor = Exception.class) // 默认事务管理器 public void crossDataSourceOp() { primaryMapper.insert(a); // 数据源 A secondaryMapper.insert(b); // 数据源 B }如果primaryMapper和secondaryMapper绑定的是不同数据源,而@Transactional没有指定transactionManager,那默认事务管理器只管其中一个数据源。另一个数据源的操作是独立提交的,回滚时管不到。
这类问题的排查思路:先确认每个 Mapper 绑定的数据源,再确认事务管理器绑定的数据源,两边必须一致。跨数据源的事务,单靠@Transactional是解决不了的,需要分布式事务方案(如两阶段提交、补偿事务),或者从业务设计上拆开,避免强一致要求。
还有一种更隐蔽的情况:动态数据源切换。通过 AOP 在方法执行前切换数据源,但事务管理器在切换之前就已经绑定了连接。这时候事务里的操作可能落在错误的数据源上。这类问题需要确保数据源切换发生在事务开启之前,通常的做法是把切换逻辑放在事务注解的外层,或者用编程式方式显式控制顺序。
4.3 只读事务里做写操作:不报错但不生效
@Transactional(readOnly = true)本意是优化查询性能,提示数据库这是只读操作。但有人在这个方法里顺手做了写操作:
@Transactional(readOnly = true) public void refreshCache(Long id) { Data data = dataMapper.selectById(id); cache.put(id, data); statMapper.updateAccessTime(id); // 写操作 }不同数据库和驱动对只读事务的处理不一样。有的会直接抛异常,有的会静默忽略写操作,有的则正常执行。行为不确定,就意味着不可依赖。只读事务就老老实实只读,写操作拆到独立方法里,这是底线。
5. 排查套路:事务不生效时按这个顺序查
上面三类坑讲完,给一套实操排查流程。遇到“事务好像没生效”的问题,按这个顺序走,基本能定位到根因。
5.1 第一步:确认代理是否生效
最直接的办法是看日志。开启 Spring 的logging.level.org.springframework.transaction=DEBUG,方法执行时如果能看到Creating new transaction、Participating in existing transaction这类日志,说明代理生效了。如果完全没有事务相关日志,那就是代理没拦住,回到第 2 节排查:同类调用?private/final?对象不是 Bean?
也可以在启动日志里找Bean 'xxxService' is not eligible for getting processed by all BeanPostProcessors这类提示,或者用AopUtils.isAopProxy(bean)在代码里断言。我习惯在单元测试里加一个断言,确认关键 Service 是代理对象,防止重构时不小心破坏了代理条件。
5.2 第二步:确认异常是否触发了回滚
代理生效但没回滚,看异常。在事务方法里临时加日志,确认异常确实抛到了方法外层。如果方法内部 catch 了,检查是否重新抛出。如果抛了,检查异常类型是否在rollbackFor范围内。这一步用 DEBUG 日志配合断点很快能确认。
有个小技巧:在@Transactional方法上临时把rollbackFor改成Throwable.class,如果这样能回滚,说明就是异常类型不匹配的问题。定位后再改回合理配置。
5.3 第三步:确认事务边界和数据源
前两步都正常,但回滚范围不对,查传播行为和数据源。确认方法调用链上每一层的事务传播配置,确认每个数据库操作绑定的数据源和事务管理器是否一致。多数据源项目里,这一步尤其重要。
常见问题速查表:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 完全无事务日志 | 代理未生效 | 查同类调用、方法修饰符、对象来源 |
| 有事务日志但不回滚 | 异常被吞或类型不匹配 | 查 catch 块、rollbackFor 配置 |
| 部分操作回滚部分不回滚 | 传播行为或数据源不一致 | 查传播配置、事务管理器绑定 |
| 日志跟着主流程回滚了 | REQUIRES_NEW 写成了 NESTED | 核对传播行为枚举值 |
| 高并发下事务超时 | REQUIRES_NEW 占用额外连接 | 查连接池大小和事务嵌套层数 |
6. 几条写事务代码的硬规矩
踩了这么多坑,我现在写事务代码有几条不商量的规矩,分享出来供参考。
规矩一:事务方法一律 public,一律带rollbackFor = Exception.class。不依赖默认值,不给自己留模糊空间。团队规范里写死,code review 时作为检查项。
规矩二:事务方法不 catch 异常,除非明确要吞并且知道后果。如果必须 catch,要么重新抛出,要么在 catch 里显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。后者是编程式补救,能用但不如直接抛异常清晰。
规矩三:同类调用不指望事务。需要事务的子逻辑,拆到独立 Bean。拆不动的时候,用TransactionTemplate显式控制,别赌代理会不会生效。
规矩四:传播行为只在真正需要时配置。默认的REQUIRED能覆盖 90% 的场景。用REQUIRES_NEW之前,先想清楚连接池够不够、外层事务挂起的代价能不能接受。
规矩五:多数据源项目,事务注解必须显式指定transactionManager。不依赖默认,避免数据源调整后事务悄悄失效。
最后分享一个我实际排查中用过的小手段:在测试环境给事务方法加一个环绕切面,记录方法进入时的事务状态(TransactionSynchronizationManager.isActualTransactionActive())和退出时的回滚标记。跑一轮集成测试,哪些方法“以为有事务实际没有”,一目了然。这个切面只在测试环境启用,成本很低,但能把大部分静默失效提前暴露出来。事务这东西,最怕的就是它不说话,你得主动去问它。