本节目标:把「注解写了却没效果」的各类场景收敛到一个根因——调用没有经过代理,并给出可落地的诊断步骤与两种自调用解法及其代价。
适用版本: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(...) 时发生了什么:
- 外部拿到的是代理,调用进入代理;
- 代理构造
ReflectiveMethodInvocation,执行proceed(),最终反射调用目标对象的borrow; - 进入目标对象的
borrow后,this指向的是目标对象自己; this.doBorrow(...)是一次普通的 JVM 虚方法调用,直接命中目标对象的方法,根本不会回到代理;- 于是
doBorrow上的@Transactional从未被TransactionInterceptor看到,事务不存在。
一句话概括:代理是「外面那层壳」,this 永远是「里面的核」。凡是壳内发起的调用,都不过壳。
这一点与 3.2 的 ReflectiveMethodInvocation 完全自洽:拦截器链只在「代理被外部调用」时构造。this.doBorrow() 既不经过代理,也就没有链、没有 advice。
3.3.2 @Transactional、@Async、@Cacheable 都栽在这
这三个注解的实现方式不同,但都是基于代理的,因此都受同一条规则约束。核实各自的拦截器与 Advisor:
| 注解 | 入口后置处理器 / 配置 | 核心拦截器 | 所在 jar |
|---|---|---|---|
@Transactional | @EnableTransactionManagement | TransactionInterceptor(配 BeanFactoryTransactionAttributeSourceAdvisor) | spring-tx |
@Async | AsyncAnnotationBeanPostProcessor(@EnableAsync) | AsyncExecutionInterceptor(配 AsyncAnnotationAdvisor) | spring-context/spring-aop |
@Cacheable | @EnableCaching | CacheInterceptor(配 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 处理链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。