@Async 异步方法没走代理导致同步执行,接口慢 2 倍
场景:接口 P99 从 200ms 涨到 400ms,调用量没变——耗时操作本该异步却被同步阻塞 路径:断言 → 现场 → 拆解 → 处方 → 雷标
断言
@Async 写在方法上,大多数人以为异步了
但真相是——同一个类里另一个方法用 this 调用它,@Async = 没写。
大多数人的直觉:"方法上有 @Async,Spring 就会提交线程池异步执行。" 但 Spring 的异步机制基于 AOP 代理。当你在同一个类的另一个方法里直接用 this.asyncMethod() 调用时,这个调用走的是原始对象,不是代理对象。
看一下生产上发生了什么——一个后台导出接口,处理 CSV 导出需要 3-5 秒。「太慢了,加个 @Async 异步不就解决了?」需求简单,改动两行代码就上线了。
上线后接口确实快了——但不是期望的那种快。P99 从 200ms 涨到了 400ms。同样的并发量,响应时间翻了一倍。

现场
接口突然慢了 2 倍
监控曲线很清晰:某次发布后,/api/export/csv 接口的 p99 稳步上升。
发布前: avg 45ms p99 200ms p999 350ms
发布后: avg 90ms p99 400ms p999 800ms
调用量没变,代码改动只有一行——把导出逻辑抽到了一个标了 @Async 的方法里。这不该变慢,应该变快才对。

排查的第一直觉是线程池不够大,@Async 提交的任务在排队。但查看了线程池活跃线程数,发现一个更诡异的现象——异步方法根本没在线程池里跑。
// ThreadPoolTaskExecutor 监控
{
"activeCount": 0,
"queueSize": 0,
"completedTaskCount": 0,
"poolSize": 0
}
线程池零活跃、零排队——说明 @Async 方法压根儿没提交到线程池。
排查链路
① @Async 存在? ✅ exportAsync() 方法上有
② @EnableAsync 配置? ✅ @EnableAsync 已开启
③ Spring 代理状态? ✅ Bean 被代理了
④ 调用方式? ❌ this.exportAsync()——没走代理
第五步之前,每步的答案都是"没问题"——所以这个小问题花了一整个下午。最终用 Arthas 确认了调用栈:
# Arthas 监控 exportAsync 方法的调用
watch com.example.OrderService exportAsync '{params,returnObj,throwExp}' -x 3
# 输出显示调用者是同一个类的 batchExport 方法
# 调用栈中没有 AsyncExecutionInterceptor
method=com.example.OrderService.exportAsync
caller=com.example.OrderService.batchExport # 同一个类!

同一个类。batchExport 方法体里写的是 this.exportAsync(...)——这个 this 指向原始对象,Spring 不认识它。
为什么接口反而慢了 2 倍
理解了"异步变同步"之后,慢的原因就清楚了。
@Async 本意是把耗时操作丢到线程池,请求线程立即返回。但自调用把它变成了同步执行:
- 请求线程调用
batchExport batchExport内部this.exportAsync()— 不走代理,同步执行导出逻辑- 3-5 秒的导出操作阻塞了 Tomcat 线程
- 并发请求一多,Tomcat 线程池被占满,后续请求排队等待
- P99 从 200ms 涨到 400ms
加了 @Async 比不加还慢——因为多了一层无用的代理创建开销,但异步逻辑没跑起来。
拆解
this.asyncMethod() 为什么跳过 AsyncExecutionInterceptor?
Spring 为 @Async 创建代理的机制与 @Transactional 同源。当容器启动时,AsyncAnnotationBeanPostProcessor 检测到 Bean 上有 @Async 注解,通过 AbstractAdvisingBeanPostProcessor 为这个 Bean 创建代理对象。代理对象持有目标对象引用和拦截器链——其中 @Async 对应的是 AsyncExecutionInterceptor。

外部调用者的所有方法调用都经过代理:
调用方 → proxy.batchExport(orders)
↓
CglibAopProxy.DynamicAdvisedInterceptor.intercept()
↓ 检查 batchExport 是否有 @Async → 没有
↓ 拦截器链为空
↓
methodProxy.invoke(target, args)
↓
目标对象.batchExport(orders) ← 方法体中的 this 指向 target
↓ 方法体中
this.exportAsync(orders) ← this = 目标对象, 不是代理
↓
OrderService.exportAsync() 原始实现 ← 无拦截器介入
↓
Thread.sleep(3000) 同步阻塞 ← 请求线程被占住
关键在于 methodProxy.invoke(target, args) —— CGLIB 的 FastClass 机制通过索引直接调用目标类的方法实现,不经过虚方法分派。target 就是原始 OrderService 实例,它方法体内的所有 this.xxx() 都指向这个原始实例,CGLIB 生成的代理子类中的 override 方法(含 interceptor.intercept())根本不会被调用。
JDK 动态代理的情况一样:
// JdkDynamicAopProxy.java — org.springframework.aop.framework
// 行号:约 150-210
@Nullable
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object target = getTarget();
Class<?> targetClass = AopUtils.getTargetClass(target);
List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
if (chain.isEmpty() && !canApplyToMethod(method, targetClass)) {
// ↓↓↓ 没有拦截器或不可应用 → 反射调用,this 指向 target
return method.invoke(target, args);
}
// 有拦截器 → 构建 ReflectiveMethodInvocation
invocation = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
return invocation.proceed();
}
exportAsync 在代理层面上有 @Async 的拦截器。但 batchExport 没有 @Async 注解,它的拦截器链为空。当目标对象执行 this.exportAsync() —— 这个 this 指向原始对象,不走代理,AsyncExecutionInterceptor 没有机会介入。
AsyncExecutionInterceptor 的触发条件
// AsyncExecutionInterceptor.java — org.springframework.aop.interceptor
// 行号:约 80-130
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
AsyncTaskExecutor executor = determineAsyncExecutor(invocation.getMethod());
if (executor == null) {
// 没有找到对应的线程池 → 同步执行 fallback
return invocation.proceed();
}
// 提交到线程池异步执行
CompletableFuture<Object> result = doSubmit(
() -> invocation.proceed(), executor, invocation.getMethod().getReturnType());
return result;
}
invoke 方法的入参是 MethodInvocation 对象——它只在代理拦截器链构建时创建。同类自调用走 this.exportAsync() 时,没有经过拦截器链,所以没有 MethodInvocation,AsyncExecutionInterceptor.invoke 方法从未被执行。
注意一个关键细节:AsyncExecutionInterceptor 的 invoke 方法里,如果 determineAsyncExecutor 返回 null,会同步执行 fallback——不会报错,不会抛异常。这给排查增加了难度:加 @Async 的方法不报错,就是默默地同步跑。

@Async 和 @Transactional 的同源异质
| 对比维度 | @Async | @Transactional |
|---|---|---|
| 后置处理器 | AsyncAnnotationBeanPostProcessor |
TransactionAttributeSourcePointcut |
| 拦截器 | AsyncExecutionInterceptor |
TransactionInterceptor |
| 代理方式 | AbstractAdvisingBeanPostProcessor + AOP |
同上 |
| 自调用失效 | ✅ 失效 | ✅ 失效 |
| fallback 行为 | 同步执行(不报错) | 无事务直接提交 |
两个注解共享相同的代理机制限制,但 @Async 的 fallback 行为更隐蔽——它不抛异常,只是默默同步执行。你是不是踩过也没发现?
处方
三种解法对比
| 方案 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 分离到不同 Bean | 将异步方法抽取到独立的 @Service,通过注入调用 |
新项目、重构 | 类数量增加但结构清晰 |
| 自我注入 | @Autowired OrderService self → self.exportAsync() |
遗留系统最小改动 | 循环依赖(构造注入时) |
| AopContext.currentProxy() | @EnableAspectJAutoProxy(exposeProxy=true) + ((OrderService) AopContext.currentProxy()).exportAsync() |
不改 Bean 结构 | 侵入性强,需要显式开启 exposeProxy |
方案一:分离到不同 Bean(推荐 ✅)
将异步方法抽取到独立的 Service 中:
// 旧代码:同一类内 this.exportAsync() 调用——异步失效
@Service
public class OrderService {
public void batchExport(List<Order> orders) {
for (Order order : orders) {
this.exportAsync(order); // ❌ 异步不生效,同步执行
}
}
@Async
public void exportAsync(Order order) {
// 3-5 秒的导出操作
exportService.doExport(order);
}
}
// 修复后:将异步方法分离到独立 Bean
@Service
public class OrderService {
@Autowired
private OrderExporter orderExporter; // 注入的是代理对象
public void batchExport(List<Order> orders) {
for (Order order : orders) {
orderExporter.exportAsync(order); // ✅ 走代理,异步生效
}
}
}
@Service
public class OrderExporter {
@Async
public void exportAsync(Order order) {
exportService.doExport(order);
}
}
这是最干净的方案——符合 Spring 的设计哲学:按职责拆分 Bean,依赖注入调用代理。调用路径变为:
batchExport → orderExporter.exportAsync()(注入的代理对象)
↓
proxy.exportAsync() → CGLIB 拦截器
→ AsyncExecutionInterceptor → determineAsyncExecutor()
→ threadPoolTaskExecutor.submit(callable) → 异步执行
方案二:自我注入 + @Lazy
@Service
public class OrderService {
@Autowired
@Lazy
private OrderService self; // 注入当前 Bean 的代理
public void batchExport(List<Order> orders) {
for (Order order : orders) {
self.exportAsync(order); // ✅ 走代理
}
}
@Async
public void exportAsync(Order order) {
exportService.doExport(order);
}
}
自我注入的原理与 @Transactional 那篇一致——Spring 在 Bean 初始化完成后,将代理对象注入到 self 字段。使用 @Lazy 避免构造器注入周期内的 null 问题。
方案三:AopContext.currentProxy()
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) // 必须显式开启
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Service
public class OrderService {
public void batchExport(List<Order> orders) {
for (Order order : orders) {
((OrderService) AopContext.currentProxy()).exportAsync(order); // ✅ 走代理
}
}
@Async
public void exportAsync(Order order) {
exportService.doExport(order);
}
}
重要限制:AopContext.currentProxy() 必须在 AOP 拦截器执行上下文中调用。如果 batchExport 本身没有被 AOP 拦截(不加 @Async/@Transactional),调用 currentProxy() 会抛出 IllegalStateException。
决策树
你的场景是?
│
├─ 新项目、新模块 → 方案一:分离到独立 Bean
│ 理由:结构最干净,不引入 Spring 特定 API
│
├─ 遗留系统,不改目录结构 → 方案三:AopContext
│ 理由:只在方法上加一行,不改接口
│ 前提:调用方法也处于 AOP 拦截上下文
│
├─ 遗留系统,少量改动可接受 → 方案二:自我注入 + @Lazy
│ 理由:直观,同事一看就懂
│
└─ 完全不改代码 → 无解,必须改
@Async 特有的一个坑:线程池的 fallback
异步方法自调用还有另一个隐蔽陷阱——即使你正确地分离了 Bean,如果 AsyncExecutionInterceptor 找不到对应的方法级别 @Async("executorName") 线程池,它会 fallback 到默认的 SimpleAsyncTaskExecutor:
// 未指定线程池名称时
@Async // 默认使用 "default" 或 "applicationTaskExecutor"
public void exportAsync(Order order) { ... }
// 指定线程池
@Async("exportTaskExecutor")
public void exportAsync(Order order) { ... }
AsyncExecutionInterceptor 的 executor 查找优先级:
// AnnotationAsyncExecutionInterceptor.java — 行号:约 60
// determineAsyncExecutor 方法
// 优先级:方法上 @Async("beanName") 指定的 → defaultExecutor
如果你在配置里只写了一个自定义线程池:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("exportTaskExecutor")
public Executor exportTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("export-");
executor.initialize();
return executor;
}
}
但 @Async 上没指定 bean 名,Spring 又没找到默认的 TaskExecutor bean —— 就会 fallback 到 SimpleAsyncTaskExecutor(每次 new 一个线程,无池化)。
解决:要么在 @Async("exportTaskExecutor") 里指定名字,要么声明一个 @Primary 的 TaskExecutor bean:
@Bean
@Primary
public Executor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
验证手段
// 集成测试中验证
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderExporter orderExporter;
@Test
void verifyProxy() {
// 验证注入的是代理对象
assertTrue(AopUtils.isAopProxy(orderExporter));
}
@Test
void verifyAsync() throws Exception {
// 验证注入的代理是 CGLIB 代理
assertTrue(AopUtils.isCglibProxy(orderExporter));
}
}
雷标
🔴 搜索你的项目
# 搜索 1:同一类中调 @Async 方法
grep -rn "@Async" --include="*.java" . \
| grep -v test \
| grep "public" \
| grep -oP '^\S+' \
| xargs -I{} grep -r -l "this\.\w\+(" {} \
| sort -u
# 搜索 2:确认项目是否已使用了自我注入
grep -rn "AopContext.currentProxy()" --include="*.java" .
# 搜索 3:检查 @EnableAsync 配置
grep -rn "@EnableAsync" --include="*.java" .
# 搜索 4:检查 @Async 是否指定了线程池
grep -rn "@Async" --include="*.java" . | grep -v "test" | grep -v '(@Async("'
# 如果输出不为空,说明有 @Async 没指定线程池——可能在用 SimpleAsyncTaskExecutor
检查清单
- 搜索所有
@Async方法 → 检查每个方法是否被同类中其他方法this.xxx()调用 - 检查
@Async是否指定了线程池 → 未指定的 case 确认有默认TaskExecutorbean - 新增代码必查 → Code Review 时专门问一句:"这个方法上的
@Async是通过代理调用的吗?" - 接口变慢的场景先排除"异步变同步" → Arthas 看一眼调用栈有没有
AsyncExecutionInterceptor
记住这句
"最危险的异步代码不是写错了——是它看起来像异步,实际在同步跑,而你毫无察觉。"
本系列下一篇预告:Spring 循环依赖真的"解决了"吗?三级缓存机制排查——为什么三级缓存不是解决循环依赖的最佳实践,而是最后的兜底方案?
引流块
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」