引言:为什么加了 事务还是没生效
几乎每个用过 Spring 的人都遇到过这种诡异现象:明明在方法上老老实实标了 @Transactional,出了异常数据却照样落库了,回滚完全没发生。排查半天,最后发现是踩了一个"经典坑"——而这些坑几乎都逃不出五个模式:自调用、非 public 方法、异常类型不对、跨线程执行、多数据源配置错误。
这篇文章不逐条罗列"注意事项",而是把每一种失效场景拆到源码层面,讲清楚为什么会失效,这样遇到没写过的新变种也能自己推理出原因。
一、失效的根源都是同一件事:代理不是目标对象
Spring 的声明式事务本质上是一个 AOP 切面:容器没有直接把你写的 Service 实例交给调用方,而是在启动时生成了一个代理对象,事务逻辑(开启事务、提交、回滚)被织入在代理这一层,代理内部再调用真正的目标方法。
调用链条大致是:
调用方 → 代理对象.method()
→ TransactionInterceptor.invoke()
→ 开启/加入事务
→ 目标对象.method() ← 真正的业务逻辑在这里执行
→ 提交或回滚
记住这张图:只有"从代理对象外部发起"的调用,才会经过**TransactionInterceptor**。后面五个场景,有三个(自调用、非 public、跨线程)本质上都是"调用没有经过代理"或者"代理判断这个方法不需要管",另外两个(异常类型、多数据源)是代理逻辑内部的判断分支走错了。理解了这个前提,剩下的内容就是在填细节。

二、场景一:自调用(Self-Invocation)导致事务失效
现象
@Service
public class OrderService {
public void createOrder(Order order) {
saveOrder(order); // 直接通过 this 调用
}
@Transactional
public void saveOrder(Order order) {
orderMapper.insert(order);
if (order.getAmount() > 10000) {
throw new RuntimeException("金额异常");
}
}
}
外部调用 orderService.createOrder(order),抛异常后 saveOrder 里插入的数据依然被提交了,@Transactional 形同虚设。
原理
createOrder 和 saveOrder 同属一个类,createOrder 内部调用 saveOrder 用的是 this.saveOrder(order)(哪怕代码里省略了 this.)。而 this 指向的是目标对象本身,不是容器注入给别人使用的代理对象。调用链变成:
调用方 → 代理对象.createOrder()
→ 目标对象.createOrder()(代理直接转发,因为 createOrder 本身没有事务)
→ this.saveOrder() ← 这里的 this 是目标对象,不是代理!
→ 完全绕开了 TransactionInterceptor
saveOrder 上的 @Transactional 注解本身没有任何问题,问题是这次调用根本没有经过代理,事务拦截器自然不会被触发。

三种修复方案的取舍
| 方案 | 做法 | 代价 |
|---|---|---|
AopContext.currentProxy() |
在 createOrder 内部用 ((OrderService) AopContext.currentProxy()).saveOrder(order) 代替 this.saveOrder(order) |
需要在 @EnableAspectJAutoProxy(exposeProxy = true) 中开启暴露代理,代码里出现强转,侵入性强 |
| 注入自身 Bean | @Autowired @Lazy private OrderService self; 然后调用 self.saveOrder(order) |
需要 @Lazy 打破循环依赖初始化顺序问题,本质上还是"多此一举地拿到代理" |
| 拆分成两个 Service | 把 saveOrder 移到 OrderWriteService 里,OrderService 依赖注入它 |
最推荐:符合单一职责,调用天然经过代理,无额外配置 |
工程实践中最该问的问题不是"选哪个方案",而是"为什么会写出自调用"——多数情况下是方法职责没有拆干净,拆分服务往往比任何一种"技巧"更治本。
三、场景二:非 public 方法上的 不生效
现象
@Service
public class InventoryService {
@Transactional
protected void deduct(Long skuId, int count) {
// ...
}
@Transactional
void deductPackagePrivate(Long skuId, int count) {
// ...
}
}
即使这两个方法都能被外部通过某种方式触发调用,事务依然不生效——哪怕用的是 CGLIB 代理(CGLIB 本身是可以拦截 protected 和包可见方法的)。
源码依据
事务属性的解析逻辑在 AbstractFallbackTransactionAttributeSource.computeTransactionAttribute() 里,核心是这一段判断(逻辑等价):
protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null; // 直接返回 null,等于没有事务属性
}
// ... 后续才是真正解析 @Transactional 注解
}
allowPublicMethodsOnly() 默认返回 true。也就是说,在事务属性的解析阶段就直接短路了——不是"代理拦不到",而是 Spring 从设计上主动规定:只有 public 方法才允许配置事务。这是一个框架层面的统一约束,跟你用的是 JDK 动态代理还是 CGLIB 无关(JDK 动态代理天然只能代理接口方法,必然是 public;CGLIB 能力更强但 Spring 为了行为一致,人为收窄到跟 JDK 代理同样的范围)。

为什么要这样设计
如果允许 protected/private 方法配置事务,会出现"同一个类用 JDK 代理时这个方法没事务、换成 CGLIB 又有事务"的不一致行为,排查成本极高。Spring 选择用一条简单粗暴的规则换取跨代理方式的行为一致性。
解决方法
把方法签名改成 public。如果因为封装考虑不想暴露成 public,说明这个方法可能不该由外部代理调用,应该重新考虑方法边界。
四、场景三:异常类型导致回滚不生效
默认回滚规则
Spring 声明式事务默认的回滚规则并不是"抛异常就回滚",而是:
RuntimeException及其子类、Error→ 触发回滚- 受检异常(继承自
Exception但不是RuntimeException)→ 默认不触发回滚,事务照常提交
这是历史遗留的设计取舍:受检异常在 Spring 的语义里更接近"业务已知的、可恢复的分支结果",而非"系统性错误"。
源码依据
判断逻辑在 DefaultTransactionAttribute.rollbackOn():
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
TransactionInterceptor 捕获目标方法抛出的异常后,就是调用这个方法来决定"该提交还是该回滚"。

自定义回滚规则
@Transactional(rollbackFor = Exception.class)
public void saveOrder(Order order) throws IOException {
// 抛出受检异常 IOException 时也会回滚
}
@Transactional(noRollbackFor = BusinessWarningException.class)
public void saveOrder(Order order) {
// 即使是 RuntimeException 的子类,遇到这个特定异常也不回滚
}
两个最容易踩的陷阱
陷阱一:内部把异常吞掉了。
@Transactional
public void saveOrder(Order order) {
try {
orderMapper.insert(order);
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (Exception e) {
log.error("保存订单失败", e); // 只打了日志,异常没有往外抛
}
}
TransactionInterceptor 是靠捕获目标方法向外抛出的异常来判断是否回滚的。方法内部把异常吃掉、正常 return,在 AOP 拦截器看来这就是一次"成功执行",事务照常提交——哪怕 orderMapper.insert 已经执行成功而 deduct 失败,数据也不会回滚。想在 catch 块里手动控制,必须显式调用:
} catch (Exception e) {
log.error("保存订单失败", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

陷阱二:受检异常没有配置**rollbackFor**。 团队里常见把自定义业务异常继承 Exception 而不是 RuntimeException,如果没有显式配置 rollbackFor,即便代码逻辑上"看起来"抛了异常,事务其实早已悄悄提交。
五、场景四:跨线程导致事务失效
原理:事务资源绑定在 上
Spring 通过 TransactionSynchronizationManager 内部维护事务上下文(数据库连接、Savepoint 等资源),底层就是一组 ThreadLocal。也就是说,一个物理事务从开启到提交,全程必须在同一条线程上完成。
典型翻车场景
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
executorService.submit(() -> {
inventoryMapper.deduct(order.getSkuId(), order.getCount()); // 在另一条线程执行
});
}
submit 里的任务运行在线程池的工作线程上,该线程从 TransactionSynchronizationManager 里读取到的是空的——它拿到的是一个全新的、独立的数据库连接,deduct 这条 SQL 是在事务之外直接落库(如果自动提交)。表面上这段代码"写在事务方法内部",实际上它完全没有事务保护:createOrder 本身回滚不会影响 deduct,deduct 出错也不会导致 createOrder 回滚。
@Async 标注的方法是同样的道理——它本身就是"另起一个线程执行",如果异步方法内部还想要事务保护,必须在异步方法自己身上单独标 @Transactional,而不是指望调用方的事务能"传递"过来。

解决方式
- 明确跨线程本来就意味着跨事务边界,不要假设线程池里的任务能共享主线程的事务。
- 如果确实要保证原子性,要么把操作收敛回同一线程内顺序执行,要么用
TransactionTemplate在每个线程内部各自管理自己的事务,再用其他机制(消息、状态补偿)保证最终一致。
六、场景五:多数据源下的事务配置错误
现象一:容器里有多个
当项目接入了多个数据源,通常会配置多个事务管理器:
@Bean
public PlatformTransactionManager orderTxManager(DataSource orderDataSource) { ... }
@Bean
public PlatformTransactionManager userTxManager(DataSource userDataSource) { ... }
这时候如果 @Transactional 不指定具体用哪个管理器:
@Transactional // 容器里有两个 PlatformTransactionManager,这里到底用哪个?
public void createOrder(Order order) { ... }
Spring 会尝试按类型查找唯一的 PlatformTransactionManager,找到多个候选时可能抛出 NoUniqueBeanDefinitionException,或者(如果配置了 @Primary)默默绑定到错误的数据源对应的事务管理器上——这种情况最隐蔽,因为它不报错,只是这个方法里对 orderDataSource 的操作实际上没有被任何事务包裹。必须显式指定:
@Transactional("orderTxManager")
public void createOrder(Order order) { ... }
现象二(更根本):声明式事务管不了跨数据源的原子性
即使指定对了 transactionManager,还有一个更本质的限制:一个**PlatformTransactionManager**只能管理一个数据源上的一个物理连接。如果一个方法既要往数据源 A 写、又要往数据源 B 写:
@Transactional("orderTxManager")
public void createOrder(Order order) {
orderMapper.insert(order); // 落在数据源 A
userPointsMapper.deduct(order); // 落在数据源 B,用的是完全独立的连接
}
orderTxManager 只认识数据源 A 的连接,对数据源 B 上发生的写入完全没有感知能力。如果 orderMapper.insert 之后抛异常,orderTxManager 只能回滚 A 上的操作,B 上如果已经提交(大多数情况下数据源 B 的连接是默认自动提交的,因为没有事务管理器接管它),就回滚不了了——这不是配置错误能解决的,是单数据源事务管理器的能力边界。
常见的误区是以为多套一层 @Transactional 或者叠加多个事务管理器就能凑出跨数据源的原子性——不能。真正需要跨数据源强一致性时,出路是 JTA/XA 两阶段提交(性能代价大,工程上用得越来越少),或者放弃强一致性,走可靠消息、TCC、Saga 等最终一致性方案,把"跨库事务"问题转化成"业务补偿"问题。

七、排查思路小结
遇到"事务不生效",按这个顺序自查最快:
- 调用方式:这次调用是从容器外部/别的 Bean 发起的,还是
this.xxx()自调用? - 方法可见性:方法是不是
public? - 异常路径:异常是否真的抛出到了方法外部?异常类型是
RuntimeException还是受检异常?有没有配置对应的rollbackFor? - 线程边界:这段代码有没有跨越
Thread/线程池/@Async? - 数据源边界:项目里有几个
PlatformTransactionManager?当前方法操作的所有数据表是否都落在同一个数据源上?
这五条基本覆盖了日常开发中 95% 以上的"事务失效"报障。
文末答疑:面试官会怎么问
Q1:如果一个方法既有**@Transactional**,又在方法体里**try-catch**了所有异常并且不重新抛出,事务管理器是怎么判断要不要回滚的?这种设计有没有更好的替代方案?
A:TransactionInterceptor 完全依赖"目标方法是否向外抛出了异常"来决策,它不会去检查方法体内部发生过什么。方法体内 catch 掉异常且正常返回,在拦截器视角就是一次成功调用,事务照常提交。这本质上是 AOP 的能力边界——切面只能感知"方法调用的输入输出",感知不到内部控制流。更好的替代方案是显式调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),把"是否回滚"的决策权从隐式的异常传播改成显式的 API 调用,这样代码意图也更清晰,不依赖阅读者去反推 AOP 的内部机制。
Q2:为什么 Spring 选择用**ThreadLocal**而不是别的机制来传递事务上下文?这个设计在什么场景下会成为限制?如果要支持"父子线程共享事务",理论上要怎么改?
A:ThreadLocal 是最低成本地把"当前调用上下文"和"当前执行线程"绑定的手段,不需要在每一层方法调用中显式传参就能让下游代码通过 TransactionSynchronizationManager 拿到当前连接,天然契合同步调用栈的场景。限制就是一旦引入线程切换(线程池、@Async、CompletableFuture 的非 *Async 变体在某些情况下也会切换执行线程),上下文传递就断了。如果要支持跨线程传递,理论上需要在提交异步任务的那一刻,把当前线程 TransactionSynchronizationManager 里的资源显式打包传递给目标线程,并在任务执行前后手动 bind/unbind——这基本等价于自己重新实现一套"事务上下文的显式传递",工程复杂度和风险都不小,这也是为什么业界更倾向于干脆让异步任务成为独立的事务边界,而不是硬把同步事务模型套到异步场景上。
Q3:假设你在做代码评审,看到一个 Service 方法同时操作了两个不同数据源的表,且方法上标了单个**@Transactional**,你会给出什么结论和整改建议?
A:结论是这个方法只对声明的那个**transactionManager**对应的数据源具备事务保护,另一个数据源上的写入实际上是"裸奔"的——出现异常时可能出现一边回滚一边已提交的数据不一致。整改方向要看一致性要求:如果两边数据的一致性要求是"立即强一致",需要引入 XA/JTA,但要评估性能和运维成本是否值得;如果可以接受短暂不一致、最终达成一致即可,更推荐把第二个数据源的写入通过可靠消息或者本地消息表异步化,让"跨库"这个问题从数据库事务层面移到业务补偿层面解决,这也是目前大多数中大型系统的实际选择。
Q4:**@Transactional(propagation = Propagation.REQUIRES_NEW)**标注的方法,如果是通过自调用(**this.xxx()**)触发的,会不会开启新事务?为什么?
A:不会。REQUIRES_NEW 这个传播行为的判断逻辑同样是写在 TransactionInterceptor 里的,前提依然是"这次调用经过了代理"。自调用完全绕开代理,TransactionInterceptor 根本没有被触发,REQUIRES_NEW 的语义也就没有执行的机会——目标方法就是被当作一次普通的 Java 方法调用直接执行,跟外层是否存在事务、内层配置了什么传播行为完全无关。这也印证了本文最核心的一句话:所有事务相关的注解语义,都必须建立在"调用经过代理"这个前提之上,脱离这个前提讨论任何传播行为、回滚规则都没有意义。