Skip to content

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 注入到的是这个代理。调用代理的方法时,先执行拦截器链,再调用真正的目标对象。

调用方其他 Bean代理对象(CGLIB 生成的子类)TransactionInterceptor其他拦截器(缓存、异步……)目标对象 OrderServicecreate()persist() @Transactionalcreate() 里的 this.persist():this 是目标对象,不是代理,所以没有事务Spring 7.0.9 实测:外部调用 persist() 开启并提交事务;create() 内部调用 persist() 时没有事务
图 1 · 事务、异步、缓存都实现在代理的拦截器链里;目标对象内部的 this.persist() 直接调用自己,拦截器链根本没有机会执行

生成代理有两种方式:

方式原理能拦截的方法注入时的类型
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 会失败:

text
NoSuchBeanDefinitionException: No qualifying bean of type 'demo.Demo$OrderService' available

容器里只有一个实现了 OrderApi 接口的代理对象,它不是 OrderService 的子类。同一段代码放进 Spring Boot 就能正常工作,因为 Boot 默认使用类代理:在 Spring Boot 4.1.1 上实测,按实现类取 Bean 成功,拿到的是 CGLIB 代理。同样的代码在两种环境里行为不同,迁移或写集成测试时要特别留意。

三、实测:哪些调用被拦截了 ​

测试类(节选):

java
@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 → commitSpring 6.0 起支持
private 方法没有事务永远不会被拦截
事务方法里用 CompletableFuture.runAsync 切换线程调用线程有事务,新线程没有事务绑定在线程上
抛出受检异常begin → commit默认回滚规则
抛出运行时异常begin → rollback默认回滚规则
受检异常 + rollbackOn = ALL_EXCEPTIONSbegin → rollbackSpring 6.2 起可用

判断事务是否真的生效,用的是 TransactionSynchronizationManager.isActualTransactionActive()。在排查线上问题时,这个方法也可以临时打在日志里。

四、自调用怎么修 ​

4.1 首选:按职责拆 Bean ​

java
@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 ​

事务边界只是方法里的一小段时,用编程式事务更直接,也不依赖代理:

java
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 方法:不只是事务不生效 ​

代理对象(子类实例)repo = null可重写的方法:转发给目标对象final 方法:无法重写,就地执行目标对象(真正的 Bean)repo = Repo@…依赖注入发生在这里persist()finalMethod()实测:final 方法上的 @Transactional 不生效,方法里读到 repo = null;在业务代码里表现为莫名其妙的 NullPointerException
图 2 · CGLIB 代理是目标类的子类,但它自己的字段从未被注入;final 方法无法被重写,调用直接落在代理对象上,读到的是代理的空字段

CGLIB 代理是目标类的子类实例。Spring 创建代理时并不调用目标类的依赖注入,代理对象自己的字段一直是默认值,所有可重写的方法都被重写成「执行拦截器链,再转发给目标对象」。

final 方法不能被重写,调用时执行的就是父类里的原始代码,而 this 是代理对象。实测输出:

text
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 事务传播。

七、怎么确认真实情况 ​

  1. 打印运行时类型:bean.getClass().getName(),看有没有 $$SpringCGLIB$$ 或 $Proxy。
  2. 用 AopUtils 判断:AopUtils.isAopProxy(bean)、isCglibProxy、isJdkDynamicProxy;AopUtils.getTargetClass(bean) 取目标类。
  3. 在方法里检查事务状态:TransactionSynchronizationManager.isActualTransactionActive() 和 getCurrentTransactionName()。
  4. 打开事务日志:把 org.springframework.transaction 的日志级别调到 DEBUG,可以看到每一次 Creating new transaction、Committing、Rolling back。
  5. 写集成测试验证结果,而不是验证调用:断言数据真的回滚了,而不是断言某个 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 字节码操作库。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权