@Async 异步方法没走代理导致同步执行,接口慢 2 倍

场景:接口 P99 从 200ms 涨到 400ms,调用量没变——耗时操作本该异步却被同步阻塞 路径:断言 → 现场 → 拆解 → 处方 → 雷标

断言

@Async 写在方法上,大多数人以为异步了

但真相是——同一个类里另一个方法用 this 调用它,@Async = 没写。

大多数人的直觉:"方法上有 @Async,Spring 就会提交线程池异步执行。" 但 Spring 的异步机制基于 AOP 代理。当你在同一个类的另一个方法里直接用 this.asyncMethod() 调用时,这个调用走的是原始对象,不是代理对象。

看一下生产上发生了什么——一个后台导出接口,处理 CSV 导出需要 3-5 秒。「太慢了,加个 @Async 异步不就解决了?」需求简单,改动两行代码就上线了。

上线后接口确实快了——但不是期望的那种快。P99 从 200ms 涨到了 400ms。同样的并发量,响应时间翻了一倍。

常见误解:@Async 加了不等于异步执行

现场

接口突然慢了 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  # 同一个类!

Arthas 调用栈:没有 AsyncExecutionInterceptor

同一个类。batchExport 方法体里写的是 this.exportAsync(...)——这个 this 指向原始对象,Spring 不认识它。

为什么接口反而慢了 2 倍

理解了"异步变同步"之后,慢的原因就清楚了。

@Async 本意是把耗时操作丢到线程池,请求线程立即返回。但自调用把它变成了同步执行:

  1. 请求线程调用 batchExport
  2. batchExport 内部 this.exportAsync() — 不走代理,同步执行导出逻辑
  3. 3-5 秒的导出操作阻塞了 Tomcat 线程
  4. 并发请求一多,Tomcat 线程池被占满,后续请求排队等待
  5. P99 从 200ms 涨到 400ms

加了 @Async 比不加还慢——因为多了一层无用的代理创建开销,但异步逻辑没跑起来。

拆解

this.asyncMethod() 为什么跳过 AsyncExecutionInterceptor

Spring 为 @Async 创建代理的机制与 @Transactional 同源。当容器启动时,AsyncAnnotationBeanPostProcessor 检测到 Bean 上有 @Async 注解,通过 AbstractAdvisingBeanPostProcessor 为这个 Bean 创建代理对象。代理对象持有目标对象引用和拦截器链——其中 @Async 对应的是 AsyncExecutionInterceptor

外部调用 vs 内部自调用代理链路对比

外部调用者的所有方法调用都经过代理:

调用方 → 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() 时,没有经过拦截器链,所以没有 MethodInvocationAsyncExecutionInterceptor.invoke 方法从未被执行。

注意一个关键细节:AsyncExecutionInterceptorinvoke 方法里,如果 determineAsyncExecutor 返回 null,会同步执行 fallback——不会报错,不会抛异常。这给排查增加了难度:加 @Async 的方法不报错,就是默默地同步跑。

AsyncExecutionInterceptor 源码关键行

@Async 和 @Transactional 的同源异质

对比维度 @Async @Transactional
后置处理器 AsyncAnnotationBeanPostProcessor TransactionAttributeSourcePointcut
拦截器 AsyncExecutionInterceptor TransactionInterceptor
代理方式 AbstractAdvisingBeanPostProcessor + AOP 同上
自调用失效 ✅ 失效 ✅ 失效
fallback 行为 同步执行(不报错) 无事务直接提交

两个注解共享相同的代理机制限制,但 @Async 的 fallback 行为更隐蔽——它不抛异常,只是默默同步执行。你是不是踩过也没发现?

处方

三种解法对比

方案 做法 适用场景 风险
分离到不同 Bean 将异步方法抽取到独立的 @Service,通过注入调用 新项目、重构 类数量增加但结构清晰
自我注入 @Autowired OrderService selfself.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") 里指定名字,要么声明一个 @PrimaryTaskExecutor 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

检查清单

  1. 搜索所有 @Async 方法 → 检查每个方法是否被同类中其他方法 this.xxx() 调用
  2. 检查 @Async 是否指定了线程池 → 未指定的 case 确认有默认 TaskExecutor bean
  3. 新增代码必查 → Code Review 时专门问一句:"这个方法上的 @Async 是通过代理调用的吗?"
  4. 接口变慢的场景先排除"异步变同步" → Arthas 看一眼调用栈有没有 AsyncExecutionInterceptor

记住这句

"最危险的异步代码不是写错了——是它看起来像异步,实际在同步跑,而你毫无察觉。"


本系列下一篇预告:Spring 循环依赖真的"解决了"吗?三级缓存机制排查——为什么三级缓存不是解决循环依赖的最佳实践,而是最后的兜底方案?

引流块

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」