欢迎光临
我们一直在努力

Spring @Async 注解失效排查:同实例方法调用导致的代理绕过问题

场景:接口 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 本意是把耗时操作丢到线程池,请求线程立即返回。但自调用把它变成了同步执行:

  • 请求线程调用 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。

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

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

    AsyncExecutionInterceptor 源码关键行

    @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 确认有默认 TaskExecutor bean
  • 新增代码必查 → Code Review 时专门问一句:“这个方法上的 @Async 是通过代理调用的吗?”
  • 接口变慢的场景先排除"异步变同步" → Arthas 看一眼调用栈有没有 AsyncExecutionInterceptor
  • 记住这句

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

    赞(0)
    未经允许不得转载:171主机测评 » Spring @Async 注解失效排查:同实例方法调用导致的代理绕过问题
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址