《Spring Boot 高级》3.3 代理失效的边界

从「this 自调用不经过代理」这一根因出发,解释 @Transactional、@Async、@Cacheable 为何会在同类内部调用时静默失效,梳理 private、final、static 方法的织入失败与 CGLIB 的真实告警文本,并对比 AopContext.currentProxy() 与 ObjectProvider 自注入两种解法的代价。

本节目标:把「注解写了却没效果」的各类场景收敛到一个根因——调用没有经过代理,并给出可落地的诊断步骤与两种自调用解法及其代价。
适用版本:Spring Boot 4.1.x(Java 21)

3.3 代理失效的边界

前两节讲了代理怎么生成、顺序怎么排。本节讲最容易被线上事故暴露的一面:代理并不是万能的,它只在「外部调用」这一条路径上生效。大量「我明明加了 @Transactional」的排查,最后都落到这里。

3.3.1 this 自调用为什么绕过代理

回看 3.1 的结构:代理对象持有目标对象,外部调用先到代理,代理再转给目标。用一段最容易踩坑的代码说明问题:

@Service
public class BorrowServiceImpl implements BorrowService {

    private final BorrowRecordRepository repository;

    public BorrowServiceImpl(BorrowRecordRepository repository) {
        this.repository = repository;
    }

    @Override
    public void borrow(String userId, String isbn) {
        // 自调用:这里的 this 是目标对象,不是代理
        this.doBorrow(userId, isbn);
    }

    @Transactional
    public void doBorrow(String userId, String isbn) {
        repository.save(new BorrowRecord(userId, isbn));
    }
}

调用 borrowService.borrow(...) 时发生了什么:

  1. 外部拿到的是代理,调用进入代理;
  2. 代理构造 ReflectiveMethodInvocation,执行 proceed(),最终反射调用目标对象的 borrow;
  3. 进入目标对象的 borrow 后,this 指向的是目标对象自己;
  4. this.doBorrow(...) 是一次普通的 JVM 虚方法调用,直接命中目标对象的方法,根本不会回到代理;
  5. 于是 doBorrow 上的 @Transactional 从未被 TransactionInterceptor 看到,事务不存在。

一句话概括:代理是「外面那层壳」,this 永远是「里面的核」。凡是壳内发起的调用,都不过壳。

这一点与 3.2 的 ReflectiveMethodInvocation 完全自洽:拦截器链只在「代理被外部调用」时构造。this.doBorrow() 既不经过代理,也就没有链、没有 advice。

3.3.2 @Transactional、@Async、@Cacheable 都栽在这

这三个注解的实现方式不同,但都是基于代理的,因此都受同一条规则约束。核实各自的拦截器与 Advisor:

注解入口后置处理器 / 配置核心拦截器所在 jar
@Transactional@EnableTransactionManagementTransactionInterceptor(配 BeanFactoryTransactionAttributeSourceAdvisor)spring-tx
@AsyncAsyncAnnotationBeanPostProcessor(@EnableAsync)AsyncExecutionInterceptor(配 AsyncAnnotationAdvisor)spring-context/spring-aop
@Cacheable@EnableCachingCacheInterceptor(配 BeanFactoryCacheOperationSourceAdvisor)spring-context

三者在自调用场景下的表现:

@Service
public class BorrowServiceImpl implements BorrowService {

    @Override
    public void borrow(String userId, String isbn) {
        this.doBorrow(userId, isbn);   // 三类注解全部失效
    }

    @Transactional
    @Async
    @Cacheable(cacheNames = "borrow", key = "#userId")
    public void doBorrow(String userId, String isbn) {
        // 事务不生效:方法跑在无事务连接上
        // 异步不生效:仍然在调用者线程里同步执行
        // 缓存不生效:每次调用都会真正进入方法体
    }
}

最危险的地方是三者都静默失败:

  • @Transactional 失效 → 没有异常、没有日志,只是数据没回滚;
  • @Async 失效 → 方法同步执行,吞吐在压力下才暴露,单测完全看不出来;
  • @Cacheable 失效 → 每次都查库,性能下降但功能正常。

这类 bug 的共同特征是「功能看起来对,边界条件全错」。第 3.3.5 节给出一份对照排查表。

实战卷处理的是「事务/缓存/异步怎么配」,本节补的是「为什么配了还不生效」——根因往往不是配置,而是调用没有经过代理。

3.3.3 private、final、static 方法的织入失败

自调用是「调用路径」问题,方法可见性则是「字节码」问题。CGLIB 通过继承目标类并覆盖方法来实现拦截,因此任何不可覆盖的方法都无法被织入。

CglibAopProxy#doValidateClass(spring-aop-7.0.9-sources.jar 核实)会在类加载时检查这些方法,并打出明确的告警。它的判定逻辑是:

int mod = method.getModifiers();
if (!Modifier.isStatic(mod) && !Modifier.isPrivate(mod)) {
    if (Modifier.isFinal(mod)) {
        // public final 方法:打 WARN
    }
    else if (!Modifier.isPublic(mod) && !Modifier.isProtected(mod) && /* 跨 ClassLoader */) {
        // 包可见方法:打 DEBUG
    }
}

真实告警文本(源码核实,可直接在日志里检索):

Public final method [public void com.example.audit.BorrowServiceImpl.doBorrow(java.lang.String,java.lang.String)] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies.

若该 final 方法同时是接口方法的实现,告警换成:

Unable to proxy interface-implementing method [...] because it is marked as final, consider using interface-based JDK proxies instead.

各类方法的结局汇总:

方法形态JDK 代理CGLIB结果
public 实例方法在接口中才拦截拦截正常
public final 实例方法是接口方法则拦截,否则不拦截不拦截CGLIB 下静默失效 + WARN
protected 实例方法不在接口中拦截JDK 代理下失效
private 实例方法不拦截不拦截永远失效
static 方法不拦截不拦截永远失效
包可见方法(跨 ClassLoader)不拦截不拦截失效 + DEBUG

final 类更严重——连代理都生成不出来。CglibAopProxy 会直接抛 AopConfigException(源码核实):

Could not generate CGLIB subclass of com.example.audit.FinalBorrowService:
Common causes of this problem include using a final class or a non-visible class

对于 @Transactional,还有一条独立的规则:AbstractFallbackTransactionAttributeSource#computeTransactionAttribute(spring-tx-7.0.9-sources.jar 核实)在 allowPublicMethodsOnly() 为真时,遇到非 public 方法直接返回 null:

// Don't allow non-public methods, as configured.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
    return null;
}

所以把 @Transactional 标在 private 方法上,事务属性根本查不出来,连「找到拦截器但没生效」这一步都不会走到。这与 CGLIB 的覆盖限制是两条独立的失效路径,排查时要分别确认。

3.3.4 两种解法:AopContext.currentProxy() 与 ObjectProvider 自注入

既然根因是 this 指向了目标对象,解法就是「拿到代理再调」。有两条路。

解法一:AopContext.currentProxy()。 代理在调用期间把自身放进一个 ThreadLocal,目标对象可以取回来:

// 需要先开启 exposeProxy
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class AopExposeConfig { }
@Override
public void borrow(String userId, String isbn) {
    ((BorrowService) AopContext.currentProxy()).doBorrow(userId, isbn);
}

要点与代价:

  • exposeProxy 没有对应的 spring.aop.* 配置项。核实 spring-configuration-metadata.json,spring.aop 下只有 auto 与 proxy-target-class 两项,所以只能靠 @EnableAspectJAutoProxy(exposeProxy = true) 打开。
  • 若忘了开,AopContext.currentProxy() 会抛 IllegalStateException,消息为(源码核实):Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.
  • 它基于 NamedThreadLocal(名字 Current AOP proxy)。只对同一线程有效——一旦上游方法本身是 @Async 的,自调用发生在另一个线程,currentProxy() 取不到,仍会抛异常。
  • 业务代码显式依赖 AopContext,把「必须经过代理」这一约定写死在了实现里,可测性变差。

解法二:ObjectProvider 自注入。 让容器把代理注回给自己:

@Service
public class BorrowServiceImpl implements BorrowService {

    private final ObjectProvider<BorrowService> self;

    public BorrowServiceImpl(ObjectProvider<BorrowService> self) {
        this.self = self;
    }

    @Override
    public void borrow(String userId, String isbn) {
        // getObject() 拿到的是代理,调用会走拦截器链
        self.getObject().doBorrow(userId, isbn);
    }

    @Transactional
    public void doBorrow(String userId, String isbn) {
        // ...
    }
}

也可以写成字段注入的形式:

@Lazy
@Autowired
private BorrowService self;

ObjectProvider 是延迟解析的(javap 核实其 getObject() 为 default 方法),因此不会在启动时触发自引用循环依赖;@Lazy 注入的则是一个延迟代理,同理。

两种解法对比:

维度AopContext.currentProxy()ObjectProvider / @Lazy 自注入
是否要额外开关需要 exposeProxy = true不需要
跨线程(@Async 内)失效(ThreadLocal 隔离)正常
每次调用开销需要 set / restore ThreadLocal一次 provider 解析
业务代码耦合直接依赖 Spring AOP 的 AopContext只依赖 ObjectProvider,耦合更轻
可测性差(单测需模拟 AopContext)好(注入的 provider 可替换)
推荐度仅在无法改结构时使用首选

还有一条更根本的建议:把需要独立事务 / 异步 / 缓存的方法挪到另一个 Bean,从设计上消除自调用。自注入与 AopContext 都是补丁,拆出独立 Bean 才是把边界划清的做法。

3.3.5 排查清单

遇到「注解没效果」,按下面顺序逐项排除,多数问题在前两行就能定位:

症状最可能的原因检查点
@Transactional 不生效自调用 / 方法非 public调用是否来自同类内部;方法是否 public
@Async 变同步执行自调用 / 未启用调用链是否经过代理;是否加了 @EnableAsync
@Cacheable 每次查库自调用调用是否来自同类内部
切面完全不执行方法 private / final / static看日志有无 cannot get proxied via CGLIB
启动报 AopConfigException目标类是 final 类去掉 final 或改用接口
拿到 Bean 却转不成具体类用的是 JDK 代理检查 spring.aop.proxy-target-class

两个容易误判的自检点:

  • 在目标方法里用 AopUtils.isAopProxy(this) 判断会得到 false。 因为 this 是目标对象而不是代理,isAopProxy 对目标对象自然返回 false。要判断「这个 Bean 有没有被代理」,应在外部持有引用处判断,或看注入点的类名。
  • @Async 的失效比 @Transactional 更隐蔽。 事务失效往往还有数据不一致这个现象可查,而 @Async 退化成同步只是「变慢了」,除非专门压测,否则很难发现。

一个通用的验证手法:在 advice / 拦截器里打一条日志,观察调用时是否打印。打印了就说明代理生效,没打印就往「自调用」或「方法不可覆盖」两个方向查。

小结

  • 代理只在「外部调用」路径上生效;目标对象内部 this.xxx() 是普通虚方法调用,绕过代理,这是所有自调用失效的根因。
  • @Transactional、@Async、@Cacheable 都基于代理拦截器实现(TransactionInterceptor、AsyncExecutionInterceptor、CacheInterceptor),因此都会在自调用时静默失效。
  • CGLIB 无法覆盖 private / final / static 方法与包可见方法,CglibAopProxy#doValidateClass 会打出 cannot get proxied via CGLIB 等告警;final 类会直接抛 AopConfigException。
  • @Transactional 还有一条独立规则:AbstractFallbackTransactionAttributeSource 对非 public 方法直接返回 null 事务属性。
  • 解法上,ObjectProvider / @Lazy 自注入优于 AopContext.currentProxy():后者需要 exposeProxy = true(无 spring.aop.* 配置项)、基于 ThreadLocal 跨线程失效、且与 Spring AOP 强耦合。
  • 最彻底的做法是把需要独立语义的方法拆到另一个 Bean,从结构上消除自调用。

到这里,AOP 这条线就闭环了:3.1 看清代理是谁,3.2 理清顺序怎么定,3.3 划出失效边界。下一章进入 Web 层,从 DispatcherServlet 开始拆解一个请求是怎么被处理完的。

阅读导航:上一节:3.2 切面织入与顺序 · 下一节:4.1 DispatcherServlet 处理链 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计