一句话先说结论:
@Async和@Transactional都靠 Spring AOP 代理织入。外部通过代理对象调用时注解生效,但同一个类里用this.方法()内部调用时,走的是原始对象、绕过了代理,注解形同虚设——异步变同步、事务不回滚,而且默认没有任何报错提示。
背景
Spring 的声明式能力(@Async、@Transactional、@Cacheable 这些)都不是“加了注解就魔法生效”,而是通过 AOP 动态代理实现的:容器在你拿到的 Bean 外面包一层代理,外部调用先进代理、被拦截器(AsyncExecutionInterceptor / TransactionInterceptor)接管,再进到真正的业务方法。
这个设计意味着:只有“从代理对象进来的调用”才会走拦截器。正常情况下你注入到 Controller 里的 Service 就是代理,一切正常;真正出问题的是“同一个类内部的互相调用”。
现象
现象 1:@Async 加了,接口反而更慢
一个 CSV 导出接口耗时 3-5 秒,把导出逻辑抽到一个标了 @Async 的方法里,指望请求线程立刻返回。结果发布后 P99 不降反升:
发布前: avg 45ms p99 200ms
发布后: avg 90ms p99 400ms
排查时看线程池监控,发现诡异之处——异步线程池根本没被使用:
{ "activeCount": 0, "queueSize": 0, "completedTaskCount": 0, "poolSize": 0 }
线程池零活跃、零排队,说明 @Async 方法压根没提交到线程池,是在请求线程上同步跑完了那 3-5 秒,把 Tomcat 线程占住、请求排队,接口整体反而更慢。
现象 2:@Transactional 加了,回滚没发生
运营批量导入 48 条数据,batchCreate 方法标了 @Transactional。结果第 31 条唯一键冲突后,前 30 条已经写进库里、没有回滚。日志里看到的是:
第 1-30 次循环: insert 各自独立提交
第 31 次循环: DuplicateKeyException
// 关键线索:整个调用链上没有任何 TransactionInterceptor 的事务日志
不是“事务回滚失败”,而是事务压根没创建——batchCreate 内部是 this.createUser(user) 调的,this 指向原始对象,拦截器没机会执行。
根因分析
问题出在“谁来调这个方法”。以 @Async 为例,把调用链摊开:
调用方
→ proxy.batchExport() // 走代理,但 batchExport 没 @Async,拦截链为空
→ methodProxy.invoke(target) // CGLIB 直接调到原始对象
→ 目标对象.batchExport() 内部 this.exportAsync() // this = 原始对象,不是代理
→ 原始 exportAsync() 同步执行 // 拦截器没进来
核心是一条常识:Java 方法内部的 this 永远指向当前实例本身。CGLIB 代理是“子类继承目标类”,外部调用时进入的是增强后的 override 方法(里面有拦截器逻辑);但目标方法体里写的 this.xxx(),this 就是那个原始实例,调用直接跳转到原始实现,不会重新经过代理。JDK 动态代理同理——this 指向目标对象,而不是外面的 InvocationHandler。
所以这条规则同时适用于 @Async、@Transactional、@Cacheable 等所有基于 Spring AOP 代理的注解,本质是“运行时代理的边界”。
解决方案
方案 B(推荐):拆到独立 Bean,让调用天然走代理
把被调用的方法挪到另一个 Service,通过依赖注入调用。这是最干净的解法,也顺带理顺了职责划分:
@Service
public class OrderService {
private final AsyncEmailService asyncEmailService;
public OrderService(AsyncEmailService asyncEmailService) {
this.asyncEmailService = asyncEmailService;
}
public void process(Long orderId) {
// 外部 Bean 调用,走代理,@Async 生效
asyncEmailService.sendEmail(orderId);
}
}
@Service
public class AsyncEmailService {
@Async
public void sendEmail(Long orderId) {
System.out.println(Thread.currentThread().getName());
// 耗时操作在线程池里跑
}
}
方案 A(最快止血):注入自身代理
用 @Lazy 注入一个自己的代理引用,通过它来调,就能重新走拦截器:
@Service
public class OrderService {
@Autowired
@Lazy
private OrderService self; // 注入的是代理
public void handle(Order order) {
self.sendNotification(order.getId()); // 走代理,@Async 生效
}
@Async
public void sendNotification(Long orderId) { /* ... */ }
}
用 @Lazy 是为了避免注入自身造成构造阶段的循环依赖。
方案 C:AopContext.currentProxy()(需开启 exposeProxy)
@EnableAspectJAutoProxy(exposeProxy = true) // 开启才能用
((OrderService) AopContext.currentProxy()).sendNotification(orderId);
思路和“注入 self”一样,只是换成从 ThreadLocal 里取当前代理,不用额外注入字段。代价是要开 exposeProxy 且写起来啰嗦。
方案 D:编程式事务 / 显式提交线程池
对事务可以用 TransactionTemplate 手工圈定边界;对异步可以直接 taskExecutor.submit(...)。两者都跳过了注解代理,行为完全可控,但失去了声明式的简洁。
排查小抄
- 打印
Thread.currentThread().getName():如果@Async方法里打出来的还是http-nio-8080-exec-*,说明没走线程池。 - 开调试日志
logging.level.org.springframework.transaction=DEBUG:正常情况下能看到Creating new transaction或Participating in existing transaction;自调用时这些日志一句都没有。 - 记录一下“调用方式是
this.xxx()还是注入进来的 Bean 调的”——这是这类问题里最常被忽略的一步。
小结
- 根因就一条:
@Async/@Transactional靠代理生效,this自调用绕过代理。 - 排查先看调用方式,再看注解位置、方法可见性、异常类型这些次要因素。
- 修复优先拆 Bean,其次注入
self代理,临时兜底再用currentProxy()或编程式写法。