ElIauk

Spring Boot @Async 失效场景

先看现象 给发邮件的接口加上 @Async,本意是让请求立刻返回,邮件在后台慢慢发。结果测试时发现接口还是卡了两秒,日志里线程名也没变;换一个类调用同样的方法,又突然生效了。还有人遇到更隐蔽的情况:异步方法确实执行了,但抛出异常时没有任何提示,任务就这么静默失败。 @Async 的生效条件比想象中严

评论
Spring Boot @Async 失效场景

先看现象

给发邮件的接口加上 @Async,本意是让请求立刻返回,邮件在后台慢慢发。结果测试时发现接口还是卡了两秒,日志里线程名也没变;换一个类调用同样的方法,又突然生效了。还有人遇到更隐蔽的情况:异步方法确实执行了,但抛出异常时没有任何提示,任务就这么静默失败。

@Async 的生效条件比想象中严格。它依赖 Spring 的代理机制,代理没走到,注解就是一段普通注释。

原理与正确配置

@Async 的本质是:Spring 给 Bean 生成代理,调用方拿到的是代理对象,代理把方法调用提交给线程池,然后立即返回。所以"调用方拿到的是不是代理"决定了异步是否生效。

开启异步需要 @EnableAsync,通常放在启动类或线程池配置类上:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("mailExecutor")
    public ThreadPoolTaskExecutor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("mail-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setTaskDecorator(runnable -> {
            Map<String, String> context = MDC.getCopyOfContextMap();
            return () -> {
                if (context != null) {
                    MDC.setContextMap(context);
                }
                try {
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        });
        executor.initialize();
        return executor;
    }
}

使用的时候指定线程池名称:

@Service
public class MailService {

    @Async("mailExecutor")
    public void sendWelcome(String email) {
        log.info("发送欢迎邮件 to={}", email);
        mailClient.send(email, "欢迎注册", "......");
    }
}

如果不指定名称,Spring Boot 2.1 以后会提供一个默认的 applicationTaskExecutor。这个默认线程池的核心线程数是 8,最大线程数是 Integer.MAX_VALUE,队列容量也是 Integer.MAX_VALUE。队列几乎无界,意味着最大线程数根本用不上,任务一多就全堆在队列里,最后可能把内存撑爆。生产环境最好自己声明线程池,把队列容量和拒绝策略定下来。

失效场景清单

场景 是否生效 说明
同类内部调用 this.send() 否 不经过代理
方法被 private、static、final 修饰 否 代理无法增强
类没有交给 Spring 管理(手动 new) 否 代理无从生成
忘记加 @EnableAsync 否 注解不被处理
返回值是 String、int 等普通类型 否 只支持 void、Future、CompletableFuture
调用方和被调用方不在同一个容器 否 跨容器的 Bean 互不感知
方法在另一个 Bean 中,且通过注入调用 是 正常场景
在 @Transactional 方法里调用异步方法 部分生效 异步线程看不到未提交的数据

表格里最后一行值得单独说:事务还没提交时,异步线程可能已经开始查数据,读到的还是旧值;如果异步任务执行得比事务提交快,甚至会出现"数据不存在"的报错。正确做法是等事务提交后再触发,用 TransactionSynchronizationManager 的 afterCommit 或者 @TransactionalEventListener(phase = AFTER_COMMIT)。

常见坑

1. 以为异步方法会抛出异常给调用方

void 返回值的异步方法,异常不会传回调用线程,默认只打一条日志。如果没人配置 AsyncUncaughtExceptionHandler,很容易漏掉。可以在配置类里实现 AsyncConfigurer 统一处理,或者在异步方法内部自己 try-catch。

2. 线程池队列无界

前面提到的默认线程池就是典型例子。任务提交速度长期大于处理速度,队列会一直涨,直到 OOM。队列容量要按峰值估算,拒绝策略选 CallerRunsPolicy 至少能形成背压,而不是静默丢弃。

3. traceId、用户上下文丢失

异步线程是另一个线程,ThreadLocal 和 MDC 都不会自动传递。上面的 TaskDecorator 就是干这个的。忘了复制上下文,异步日志里的 traceId 会是空的,排查时很难串起来。

4. 在异步方法里操作事务

@Async 和 @Transactional 同时标注时,事务是在异步线程里新开的,和调用方的事务没有关系。如果业务要求"主流程回滚,异步任务也回滚",这个方案根本不成立,应该改成事务提交后发消息或者写任务表。

5. 应用关闭时任务丢失

线程池默认不等待任务执行完。停机时正在发邮件、写日志的任务会被直接中断。设置 setWaitForTasksToCompleteOnShutdown(true) 和 setAwaitTerminationSeconds(...),给任务一个收尾窗口。

6. 用异步做"必须成功"的事

邮件、短信、对账这类不能丢的任务,只靠内存里的线程池不可靠:进程挂了任务就没了。应该落库或进消息队列,由消费端重试,线程池只负责"快",不负责"不丢"。

排查步骤

@Async 没生效时,按顺序确认:

  1. 启动类或配置类上有没有 @EnableAsync;
  2. 调用方注入的是不是 Spring 代理,可以打印 bean.getClass().getName(),看到 $$EnhancerBySpringCGLIB 之类的字样才正常;
  3. 调用是不是发生在另一个 Bean 中,有没有被 this 自调用;
  4. 方法是不是 public,返回值是不是支持的类型;
  5. 在方法第一行打印 Thread.currentThread().getName(),看线程名有没有变成配置的前缀;
  6. 打开 logging.level.org.springframework.aop=DEBUG,确认 Bean 被代理、切面匹配成功。

如果线程名变了但业务没执行,问题多半在异步方法内部;如果线程名没变,就是代理没生效,回到前五步找原因。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者