Spring 中 @Transactional 的生效条件与失效场景
某天订单系统出现了一个数据不一致问题:用户支付成功了,但订单状态没更新,优惠券却已经被核销。排查后发现,开发同学在 Service 中写了一个内部方法调用, @Transactional 悄无声息地失效了。
这类问题在实际开发中屡见不鲜。要彻底理解 @Transactional 为何失效,我们必须先搞清楚它是如何生效的。
@Transactional 的原理
声明式事务
@Transactional 的本质是 AOP 代理 + 事务拦截器。理解这一点是分析所有生效/失效场景的基础。
当我们在方法上标注 @Transactional 时,Spring 并不会对我们的代码做任何修改。它的魔法在于 代理模式。
Spring 在启动时会扫描所有带有 @Transactional 注解的 Bean,然后为这些 Bean 创建一个代理对象。当外部调用这个 Bean 的方法时,实际上调用的是代理对象,而不是原始对象。代理对象会在调用真实方法前后插入事务管理的逻辑。
没有代理时的调用链:
1 | 调用方 → 目标对象.方法() |
有代理时的调用链:
1 | 调用方 → 代理对象.方法() → 开启事务 → 目标对象.方法() → 提交/回滚事务 |
这就是为什么 @Transactional 被称为”声明式事务”——你只需要声明(标注注解),Spring 帮你实现事务管理。但这也意味着, 如果调用绕过了代理对象,事务就不会生效。
代理实现的方式
Spring 支持两种代理方式:
JDK 动态代理:基于接口实现。如果你的 Bean 实现了接口,Spring 默认使用这种方式。代理对象和目标对象实现同一个接口,调用时通过接口方法进入代理逻辑。
CGLIB 代理:基于继承实现。如果你的 Bean 没有实现接口,Spring 会使用 CGLIB 生成一个目标类的子类作为代理。子类覆写了父类的方法,在覆写的方法中加入事务逻辑。
CGLIB 基于继承的特性决定了 final 方法无法被代理,子类无法覆写父类的 final 方法。
事务是如何被拦截和管理的
事务拦截器
Spring 事务的核心拦截器是 TransactionInterceptor,它实现了 MethodInterceptor 接口。每当代理对象的方法被调用时,都会先经过这个拦截器。
拦截器的工作流程如下:
- 解析事务属性:读取方法上的
@Transactional注解,获取传播行为、隔离级别、超时时间、回滚规则等配置。 - 获取事务管理器:根据配置找到对应的
PlatformTransactionManager(如DataSourceTransactionManager)。 - 开启事务:调用事务管理器的
getTransaction()方法,根据传播行为决定是开启新事务还是加入已有事务。 - 执行业务方法:调用目标对象的真实方法。
- 处理执行结果:如果方法正常返回,提交事务;如果抛出异常,根据回滚规则决定回滚还是提交。
关键源码
事务的核心处理逻辑在 TransactionAspectSupport 类的 invokeWithinTransaction 方法中:
1 | protected Object invokeWithinTransaction(Method method, Class<?> targetClass, |
事务回滚规则
在 completeTransactionAfterThrowing 方法中:
1 | protected void completeTransactionAfterThrowing(TransactionInfo txInfo, Throwable ex) { |
默认的回滚规则定义在 DefaultTransactionAttribute 中:
1 | public boolean rollbackOn(Throwable ex) { |
这意味着: 默认情况下,只有 RuntimeException 及其子类,以及 Error,才会触发回滚。 如果抛出的是 IOException、 SQLException 这类受检异常,事务会正常提交。
事务生效五个条件
@Transactional 生效必须同时满足的条件:
Bean 必须由 Spring 容器管理
这是最基本的前提。只有被 Spring 管理的 Bean,Spring 才有机会为其创建代理。如果你手动 new 了一个对象,或者类上没有 @Service、 @Component 等注解,那这个对象就是一个普通的 Java 对象,与 Spring 无关,自然也不会有事务能力。
方法必须是 public
Spring 在解析 @Transactional 注解时,会显式检查方法的访问修饰符。如果方法不是 public,注解会被直接忽略。
这个限制的原因与代理机制有关。JDK 动态代理只能代理接口方法(接口方法默认都是 public);CGLIB 虽然理论上可以代理非 public 方法,但 Spring 为了保持一致性和明确性,统一要求必须是 public 方法。
必须通过代理对象调用
这是最容易踩坑的地方。即使前两个条件都满足了,如果调用方式绕过了代理对象,事务依然不会生效。典型的场景就是同一个类中的方法互相调用(后面会详细讲解)。
事务管理器必须正确配置
Spring 需要一个 PlatformTransactionManager 来实际管理事务。对于 JDBC 操作,通常是 DataSourceTransactionManager;对于 JPA,是 JpaTransactionManager。如果你使用 Spring Boot,自动配置通常会帮你搞定这一切。但在某些复杂场景(如多数据源),你需要手动指定使用哪个事务管理器。
底层存储引擎必须支持事务
事务是数据库层面的概念。如果你使用的存储引擎不支持事务(如 MySQL 的 MyISAM 引擎),那么无论 Spring 层面怎么配置,事务都不会生效。MySQL 请务必使用 InnoDB 引擎。
事务失效八个场景
同类中的内部方法调用
这是最常见、最隐蔽的失效场景。
假设有这样一段代码:
1 |
|
开发者的预期是 bindPromotion 方法有独立的事务,如果失败应该回滚。但实际上,这个事务根本不会生效。
原因分析:当外部调用 orderService.createOrder() 时,确实是通过代理对象进入的。但在 createOrder 方法内部调用 bindPromotion 时,此时的 this 指向的是目标对象(原始的 OrderService 实例),而不是代理对象。调用直接发生在目标对象内部,完全绕过了代理层的事务拦截器。
解决方案:
方案一:注入自身的代理对象
1 |
|
方案二:使用 AopContext 获取当前代理
1 |
|
方案三:将方法拆分到不同的类中
这是最推荐的做法,既解决了自调用问题,也符合单一职责原则。
方法不是 public
1 |
|
以上三种写法, @Transactional 都不会生效。Spring 在 AbstractFallbackTransactionAttributeSource 类中有明确的检查:
1 | if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) { |
这个限制的设计初衷是:非 public 方法通常是内部实现细节,不应该暴露给外部调用。如果你需要事务,说明这个方法应该是一个对外的业务入口,理应是 public 的。
异常被 catch 后没有重新抛出
1 |
|
这段代码的问题在于: catch 块捕获了异常后,只是打印了日志,没有重新抛出。从事务拦截器的视角来看,方法正常执行完毕并返回了,没有任何异常抛出到方法边界之外。根据前面分析的源码逻辑,正常返回会触发 commitTransactionAfterReturning,事务被提交了。
解决方案:
方法一:重新抛出异常
1 |
|
方法二:手动标记事务回滚
1 |
|
抛出了受检异常但没有配置 rollbackFor
1 |
|
这个方法抛出了 IOException,这是一个受检异常(Checked Exception),不属于 RuntimeException 的子类。根据默认的回滚规则,这个异常不会触发回滚,事务会正常提交。
正确写法:
1 | // 指定所有异常都回滚 |
或者更精确地指定:
1 |
|
阿里巴巴 Java 开发手册建议, @Transactional 注解应该始终指定 rollbackFor = Exception.class,避免受检异常导致的意外提交。
类没有被 Spring 管理
1 | // 注意:没有 @Service、@Component 等注解 |
使用时如果手动创建实例:
1 | StandaloneService service = new StandaloneService(); |
这种情况下,Spring 根本不知道这个类的存在,自然也不会为它创建代理。 @Transactional 注解就是一个普通的注解标记,没有任何运行时效果。
多线程调用
1 |
|
Spring 的事务信息是存储在 ThreadLocal 中的。 ThreadLocal 的特性是线程隔离:每个线程有自己独立的变量副本。当你创建新线程或使用并行流时,新线程无法访问主线程的事务上下文,因此它们的数据库操作是在没有事务的情况下执行的。
主线程中的事务回滚,不会影响子线程已经提交的操作;子线程的操作失败,也不会导致主线程事务回滚。数据一致性完全无法保证。
解决方案:避免在事务方法中使用多线程,或者为每个线程单独管理事务(但这样就不是同一个事务了)。
使用了 final 或 static
1 |
|
对于 final 方法:CGLIB 通过继承目标类来创建代理,但子类无法覆写父类的 final 方法,因此事务拦截逻辑无法插入。
对于 static 方法:静态方法属于类而不是实例,代理对象拦截的是实例方法调用。你调用 FinalService.staticMethod() 时,根本不经过任何代理对象。
底层存储引擎不支持事务
现在 MySQL 默认是 InnoDB 存储引擎,但是如果你的 MySQL 表使用了 MyISAM 引擎:
1 | CREATE TABLE orders ( |
MyISAM 引擎不支持事务。无论你的 Java 代码如何配置,数据库层面根本不会开启事务。Spring 的事务管理器以为自己在管理事务,实际上数据库根本不配合。
传播行为的困惑
1 |
|
开发者的预期是: innerMethod 失败后,我在外层 catch 住异常,继续执行后续逻辑。但实际运行时,当执行到 orderDao.updateOrderStatus 时,会抛出 UnexpectedRollbackException,提示事务已被标记为回滚。
原因分析:默认的传播行为是 REQUIRED,表示”如果当前存在事务,就加入该事务”。所以 innerMethod 和 outerMethod 实际上在同一个事务中。当 innerMethod 抛出 RuntimeException 时,事务被标记为 rollback-only(必须回滚)。即使你在外层 catch 住了异常,这个标记仍然存在。当 outerMethod 尝试正常返回时,事务管理器发现事务已被标记为必须回滚,于是抛出异常。
解决方案:如果希望内层方法的失败不影响外层事务,可以使用 REQUIRES_NEW 传播行为:
1 |
|
排查事务问题
开启事务调试日志
在 application.yml 中配置:
1 | logging: |
代码中检查事务状态
1 |
|
检查代理类型
1 |
|
如果输出的是原始类名,说明没有被代理。
总结
核心原则:
- 理解代理机制:
@Transactional的生效依赖于 AOP 代理,任何绕过代理的调用都会导致失效。 - 让异常飞出去:事务的回滚依赖于异常抛出。如果你 catch 了异常,要么重新抛出,要么手动标记回滚。
- 明确指定 rollbackFor :不要依赖默认的回滚规则,建议始终配置
rollbackFor = Exception.class。
@Transactional 的本质是通过代理在方法执行前后插入事务管理代码。理解了这一点,所有失效场景都能用”是否经过代理”和”异常是否抛出”这两个问题来分析。


