Spring AOP 为什么会失效:先确认调用有没有经过代理
@Transactional、@Async、@Cacheable突然不生效,注解本身很少写错。更常见的原因是这次调用根本没有经过代理:目标对象内部的this.persist()、final方法、private方法,都会让拦截器链没有机会执行。最隐蔽的是final方法,事务不生效之外,方法里读到的注入字段还是null。
本文用 Spring Framework 7.0.9 和 JDK 21 逐一验证常见的失效场景。实验没有启动 Spring Boot,只用 AnnotationConfigApplicationContext 加一个只打印 begin、commit、rollback 的事务管理器,所以结论针对的是 Spring Framework 本身的代理机制。
一、先说结论
- 只有经过代理的调用才会被增强。目标对象内部用
this调用自己的方法,事务、异步、缓存都不会生效。 - 首选的修法是拆分 Bean,让调用自然地从一个 Bean 进入另一个 Bean 的代理。
AopContext.currentProxy()能用,但会让业务代码依赖 AOP 的实现细节。 final方法最危险:它不能被 CGLIB 子类重写,调用直接落在代理对象上,而代理对象的字段从未被注入,实测读到repo = null。- 方法可见性:Spring 6.0 起,类代理支持
protected和包级可见方法;private方法任何时候都不会被拦截;JDK 接口代理只拦截接口里声明的public方法。 - 受检异常默认提交,只有
RuntimeException和Error触发回滚。Spring 6.2 起可以用@EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)全局改为「所有异常都回滚」。
二、代理模型
Spring 在创建 Bean 时,如果发现有切面要应用在它身上(事务、异步、缓存、自定义 @Aspect),就会生成一个代理对象放进容器,其他 Bean 注入到的是这个代理。调用代理的方法时,先执行拦截器链,再调用真正的目标对象。
生成代理有两种方式:
| 方式 | 原理 | 能拦截的方法 | 注入时的类型 |
|---|---|---|---|
| JDK 动态代理 | 实现目标 Bean 的接口 | 接口里声明的 public 方法 | 只能按接口注入 |
| CGLIB 类代理 | 生成目标类的子类(类名带 $$SpringCGLIB$$) | 可以被重写的方法:非 final、非 private、非 static | 按接口或实现类都可以 |
选择哪种由 proxyTargetClass 决定:
- Spring Framework 默认
false:Bean 实现了接口就用 JDK 代理,否则用 CGLIB。 - Spring Boot 默认使用 CGLIB 类代理(
spring.aop.proxy-target-class默认为true),所以大多数 Boot 项目里看到的都是$$SpringCGLIB$$类。
实测在默认配置的 Spring Framework 里,实现了接口的 OrderService 被代理成 jdk.proxy2.$Proxy21。这时按实现类获取 Bean 会失败:
NoSuchBeanDefinitionException: No qualifying bean of type 'demo.Demo$OrderService' available容器里只有一个实现了 OrderApi 接口的代理对象,它不是 OrderService 的子类。同一段代码放进 Spring Boot 就能正常工作,因为 Boot 默认使用类代理:在 Spring Boot 4.1.1 上实测,按实现类取 Bean 成功,拿到的是 CGLIB 代理。同样的代码在两种环境里行为不同,迁移或写集成测试时要特别留意。
三、实测:哪些调用被拦截了
测试类(节选):
@Component
public class OrderService {
@Autowired Repo repo;
public void create() { this.persist(); } // 自调用
@Transactional public void persist() { … }
@Transactional public final void finalMethod() { … }
@Transactional void packagePrivate() { … }
@Transactional private void privateTx() { … } // 通过 public 方法间接调用
@Transactional public void checked() throws Exception { throw new Exception("业务校验失败"); }
@Transactional public void unchecked() { throw new IllegalStateException("库存不足"); }
}结果(CGLIB 类代理,Spring 7.0.9):
| 调用方式 | 事务 | 说明 |
|---|---|---|
外部调用 persist() | begin → commit | 正常 |
create() 内部 this.persist() | 没有事务 | 自调用绕过代理 |
AopContext.currentProxy() 再调用 persist() | begin → commit | 需要 exposeProxy = true |
final 方法 | 没有事务,且 repo = null | 见第五节 |
| 包级可见方法 | begin → commit | Spring 6.0 起支持 |
private 方法 | 没有事务 | 永远不会被拦截 |
事务方法里用 CompletableFuture.runAsync 切换线程 | 调用线程有事务,新线程没有 | 事务绑定在线程上 |
| 抛出受检异常 | begin → commit | 默认回滚规则 |
| 抛出运行时异常 | begin → rollback | 默认回滚规则 |
受检异常 + rollbackOn = ALL_EXCEPTIONS | begin → rollback | Spring 6.2 起可用 |
判断事务是否真的生效,用的是 TransactionSynchronizationManager.isActualTransactionActive()。在排查线上问题时,这个方法也可以临时打在日志里。
四、自调用怎么修
4.1 首选:按职责拆 Bean
@Service
public class OrderApplicationService {
private final OrderWriter writer;
public OrderApplicationService(OrderWriter writer) { this.writer = writer; }
public void create(CreateOrderCommand cmd) {
Order order = prepare(cmd); // 校验、组装等不需要事务的工作
writer.persist(order); // 经过 OrderWriter 的代理,事务生效
}
}
@Service
public class OrderWriter {
@Transactional
public void persist(Order order) { … }
}自调用问题往往说明一个类承担了两层职责:流程编排和事务边界。拆开之后,事务边界在类的层面上就能看清楚。
4.2 显式划定边界:TransactionTemplate
事务边界只是方法里的一小段时,用编程式事务更直接,也不依赖代理:
public void create(CreateOrderCommand cmd) {
Order order = prepare(cmd); // 事务外
transactionTemplate.executeWithoutResult(status -> repository.save(order));
notifier.send(order); // 事务外
}这种写法顺带解决了另一个常见问题:把远程调用、消息发送放在事务里,拉长了数据库连接的持有时间。
4.3 不推荐的做法
| 做法 | 问题 |
|---|---|
AopContext.currentProxy() | 需要 exposeProxy = true;业务代码直接依赖 AOP 的实现方式 |
自己注入自己(@Autowired @Lazy OrderService self) | 依赖关系变得隐晦,可读性差 |
| 改用 AspectJ 编译期或加载期织入 | 能拦截自调用和 private 方法,但构建和调试成本明显上升,通常不值得只为自调用引入 |
五、final 方法:不只是事务不生效
CGLIB 代理是目标类的子类实例。Spring 创建代理时并不调用目标类的依赖注入,代理对象自己的字段一直是默认值,所有可重写的方法都被重写成「执行拦截器链,再转发给目标对象」。
final 方法不能被重写,调用时执行的就是父类里的原始代码,而 this 是代理对象。实测输出:
finalMethod():没有事务,repo = null容器启动时 Spring 会打印一条警告:Public final method [...] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies. 它只在启动时出现一次,之后每次调用都不会再有提示,很容易淹没在启动日志里。把这条警告加入日志告警规则,是成本最低的防线。
线上的表现通常是:某个方法偶尔抛 NullPointerException,本地单元测试(直接 new 目标对象)却一切正常。同样的问题也出现在 Kotlin 里:Kotlin 的类和方法默认是 final,所以 Spring 项目通常要配合 kotlin-spring(all-open)编译插件使用。
六、其他失效来源
| 现象 | 原因 | 检查方法 |
|---|---|---|
对象是 new 出来的 | 不归容器管理,没有代理 | 看对象的来源 |
| 切换线程后没有事务 | 事务、SecurityContext、MDC 都绑定在线程上 | 实测新线程里 isActualTransactionActive() 为 false |
| 异常被 catch 住没有抛出 | 拦截器看不到异常,照常提交 | 检查事务方法里的 try/catch |
| 受检异常没回滚 | 默认回滚规则 | 设置 rollbackFor 或全局 rollbackOn |
| 多个数据源时事务无效 | 使用了错误的事务管理器 | @Transactional("orderTxManager") 指定 |
| 多个切面顺序不对 | 事务切面在缓存切面外层时,缓存在事务提交前就写入了结果 | 让缓存在外层,用 order 属性显式排序 |
@Async 和 @Cacheable 同样依赖代理,自调用时一样失效。在 Spring Boot 4.1.1 上实测:@Cacheable 方法在类内部连续调用两次,方法体执行了 2 次,从外部调用两次只执行 1 次;@Async 方法在类内部调用时直接在调用方线程上同步执行。
最后一行的切面顺序值得单独说。同一个方法同时标了 @Transactional 和 @Cacheable,实测两种顺序的差别:
| 顺序 | 缓存命中 10 次 | 事务提交时回滚 |
|---|---|---|
缓存在外层(@EnableCaching(order = 1)) | 不开事务 | 结果不进缓存,下次重新执行 |
事务在外层(@EnableTransactionManagement(order = 1)) | 开 10 个物理事务 | 缓存里留着这次的结果,数据库里却没有对应的行 |
事务在外层时,缓存切面在方法返回后立刻写入结果,这时事务还没提交;如果提交时发现事务已被标记为只能回滚,数据回滚了,缓存却不会跟着回滚。两者都不指定 order 时,这次实验的表现和缓存在外层相同,但它取决于切面的注册顺序,不是 Spring 承诺的行为,应该显式写出来。
事务传播、嵌套事务和 UnexpectedRollbackException 的细节,见 Spring 事务传播。
七、怎么确认真实情况
- 打印运行时类型:
bean.getClass().getName(),看有没有$$SpringCGLIB$$或$Proxy。 - 用
AopUtils判断:AopUtils.isAopProxy(bean)、isCglibProxy、isJdkDynamicProxy;AopUtils.getTargetClass(bean)取目标类。 - 在方法里检查事务状态:
TransactionSynchronizationManager.isActualTransactionActive()和getCurrentTransactionName()。 - 打开事务日志:把
org.springframework.transaction的日志级别调到DEBUG,可以看到每一次Creating new transaction、Committing、Rolling back。 - 写集成测试验证结果,而不是验证调用:断言数据真的回滚了,而不是断言某个 mock 方法被调用过。
八、常见误区
- 「有接口用 JDK 代理,没接口用 CGLIB」:这只是 Spring Framework 的默认规则,Spring Boot 默认一律用类代理。要看的是运行时的实际类型。
- 「
@Transactional只能加在public方法上」:Spring 6.0 起,类代理下protected和包级可见方法也可以。private仍然不行。 - 「抛了异常就会回滚」:受检异常默认提交。
- 「加了
@Transactional的final方法只是事务不生效」:它还会读到代理对象上未注入的字段。 - 「用 AspectJ 织入就一劳永逸」:它确实能拦截自调用,但构建、调试和团队认知成本都更高;大多数情况下拆分 Bean 就够了。
小结
排查 AOP 失效时,先回答一个问题:这次调用有没有经过代理?自调用、final、private、new 出来的对象、切换线程,都会让答案变成「没有」。修法的优先级是:按职责拆分 Bean,其次用 TransactionTemplate 显式划边界,最后才考虑暴露代理或 AspectJ。另外建议在项目里统一设置 rollbackOn = ALL_EXCEPTIONS,避免受检异常静默提交。
CGLIB 在 Spring 中的来历,以及字节码库与 JDK 版本的兼容问题,见 Java 字节码操作库。
配套实验
- codesphere-labs/spring/aop-proxy-pitfalls:真实 Spring 容器上的 12 个断言与应用日志(验证记录)
- codesphere-labs/spring/aop-boot-proxies:Spring Boot 4.1.1 下的默认代理类型、
@Cacheable与@Async的自调用、缓存与事务切面的两种顺序(验证记录)
参考资料