Spring Boot 异步编程实战:@Async 的 8 个坑+正确姿势

引言

"我明明加了 @Async,为什么接口还是变慢了?"——打断点一看,方法还在 Tomcat 线程里同步执行,注解形同虚设。另一个故事:压测时监控发现线程数一路涨到几千个,最后 unable to create new native thread 整个服务挂掉——排查发现 @Async 没指定线程池,任务全堆在一个无界队列里。

@Async 可能是 Spring 里"看起来最简单、坑却最密集"的注解:一个注解就让方法异步执行,但它背后是 AOP 代理、线程池、异常处理、上下文传递四套机制。这篇文章把生产中最常见的 8 个坑一次讲透——每坑给翻车代码、原理、修复代码,最后给一份可以直接抄的生产级配置。


一、前置:@Async 的工作原理

调用方线程 ──→ 代理对象(@Async 由 AOP 生成代理)
                  │
                  ├─ 方法上有 @Async?
                  │    ├─ 是 → 把"方法调用"包装成 Task,提交给线程池 → 立即返回
                  │    └─ 否 → 直接反射调用目标方法(同步)
                  │
                  └─ 代理只拦截"外部通过 Spring Bean 的调用"
                     this.xxx() 内部调用不走代理 → 注解失效

理解这张图,8 个坑里的前 5 个都能自己推导出来:代理机制决定了坑 1/2,线程池决定了坑 3/7,提交任务模型决定了坑 4/5,线程切换决定了坑 6/8


二、坑 1:方法不是 public,注解静默失效

2.1 翻车代码

@Service
public class SmsService {

    @Async                    // ❌ protected 方法,代理不拦截
    protected void sendSms(String phone) {
        smsClient.send(phone);
    }

    @Async                    // ❌ private 更不可能
    private void log(String msg) { }
}

现象:调用方该等多久还等多久,@Async 像没写一样,而且不报错、没日志——静默失效最坑人。

2.2 原理

Spring AOP 代理(JDK 动态代理或 CGLIB)只能重写 public 方法:JDK 代理基于接口,接口方法天然 public;CGLIB 生成子类,非 public 方法无法被覆盖拦截。方法不是 public,代理的拦截逻辑根本织入不了。

2.3 修复

@Async
public void sendSms(String phone) {   // ✅ public
    smsClient.send(phone);
}

自检方法:开启 logging.level.org.springframework.context.annotation=DEBUG,或直接在异步方法第一行打 Thread.currentThread().getName()——看到 task- 前缀才是真异步,看到 http-nio- 就是没生效。


三、坑 2:同类内部调用,this 绕过代理

3.1 翻车代码

@Service
public class OrderService {

    public void createOrder(Order order) {
        orderMapper.insert(order);
        this.sendNotify(order);      // ❌ this 调用,不走代理!@Async 失效
    }

    @Async
    public void sendNotify(Order order) {
        notifyClient.push(order);
    }
}

createOrder 内部的 this.sendNotify() 是对原始目标对象的直接方法调用,不经过 AOP 代理——通知在 Tomcat 线程里同步执行,通知服务慢 3 秒,下单接口就慢 3 秒。

3.2 修复方案(三选一)

// 方案 A(推荐):拆到另一个 Bean
@Service
public class OrderService {
    private final NotifyService notifyService;   // 注入另一个 Bean
    public void createOrder(Order order) {
        orderMapper.insert(order);
        notifyService.sendNotify(order);        // ✅ 跨 Bean 调用走代理
    }
}

@Service
public class NotifyService {
    @Async
    public void sendNotify(Order order) { ... }
}
// 方案 B:注入自身代理(Spring 4.3+)
@Service
public class OrderService {

    @Lazy
    @Resource
    private OrderService self;      // 注入的是代理对象

    public void createOrder(Order order) {
        orderMapper.insert(order);
        self.sendNotify(order);     // ✅ 通过代理调用
    }

    @Async
    public void sendNotify(Order order) { ... }
}
// 方案 C:AopContext(需要 @EnableAsync(exposeProxy = true))
((OrderService) AopContext.currentProxy()).sendNotify(order);

优先级:A > B > C。拆 Bean 最干净,还顺带满足单一职责;注入自身是侵入较小的折中;AopContext 要暴露 ThreadLocal 代理,非必要不用。


四、坑 3:没自定义线程池,行为不可控

4.1 先厘清一个常见误传

很多文章说"@Async 默认用 SimpleAsyncTaskExecutor,每次新建线程,无限创建"——这在 Spring Boot 里不准确

环境未配置任何执行器时的实际行为
纯 Spring(@EnableAsync)回退 SimpleAsyncTaskExecutor——每次新建线程,不复用,确实危险
Spring Boot自动配置 applicationTaskExecutor(ThreadPoolTaskExecutor:核心线程 8、队列容量 Integer.MAX_VALUE、最大线程 Integer.MAX_VALUE)

Spring Boot 的默认池虽然复用线程,但无界队列才是真正的雷:任务积压时队列无限增长 → OOM;而且核心线程只有 8 个,所有 @Async 方法共享,一个慢任务占满后其他业务的异步任务全部排队。

正确结论:不管哪种环境,都必须显式定义线程池,不要依赖默认值。

4.2 生产级线程池配置

@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {

    /** 通知类任务:IO 密集,核心线程可多一些 */
    @Bean("notifyExecutor")
    public ThreadPoolTaskExecutor notifyExecutor() {
        return buildExecutor("notify-", 4, 16, 2000,
                new ThreadPoolExecutor.CallerRunsPolicy());
    }

    /** 文件处理类任务:重 IO,独立池隔离 */
    @Bean("fileExecutor")
    public ThreadPoolTaskExecutor fileExecutor() {
        return buildExecutor("file-", 2, 8, 500,
                new ThreadPoolExecutor.AbortPolicy());
    }

    private ThreadPoolTaskExecutor buildExecutor(String prefix, int core,
                                                 int max, int queue, RejectedExecutionHandler handler) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(core);
        executor.setMaxPoolSize(max);
        executor.setQueueCapacity(queue);                 // ✅ 有界队列,拒绝 OOM
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix(prefix);
        executor.setRejectedExecutionHandler(handler);
        executor.setWaitForTasksToCompleteOnShutdown(true);  // 配合坑 8
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }

    /** 默认执行器:@Async 不指定名字时走它 */
    @Override
    public Executor getAsyncExecutor() {
        return notifyExecutor();
    }

    /** 全局异常处理:配合坑 4 */
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }
}
// 使用时显式指定线程池,按业务隔离
@Async("notifyExecutor")
public void sendNotify(Order order) { ... }

@Async("fileExecutor")
public void upload(File file) { ... }

4.3 参数怎么定

参数经验值依据
corePoolSizeIO 密集 ≈ CPU 核数 × 2,CPU 密集 ≈ 核数IO 任务大量时间在等待,可多开
maxPoolSizecore 的 2~4 倍只在队列满后才扩容到 max
queueCapacity按峰值任务量 × 平均处理秒数估算有界,宁拒绝不 OOM
拒绝策略见坑 7按业务语义选

注意 ThreadPoolExecutor 的扩容顺序:core 满 → 进队列 → 队列满 → 才扩到 max → max 也满 → 拒绝策略。队列设得过大,maxPoolSize 永远不会生效。


五、坑 4:异常被线程"吞掉",主流程毫无感知

5.1 翻车代码

@Async
public void sendSms(String phone) {
    smsClient.send(phone);   // 抛异常了……
}

// 调用方
userService.register(req);
smsService.sendSms(req.getPhone());
return R.ok();    // ✅ 接口永远返回成功,短信失败无人知晓

异步方法在另一个线程执行,异常不会传播到调用方线程。void 返回值的方法抛异常,默认只打一条日志(甚至日志框架没配好都看不到),调用方完全无感

5.2 修复:分两种返回值处理

// 情况 A:void 方法 → 全局 AsyncUncaughtExceptionHandler
@Slf4j
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("[AsyncError] method={} params={}", method.getName(),
                Arrays.toString(params), ex);
        // 接入告警 / 失败补偿表
        alertService.sendP1("异步任务失败: " + method.getName());
    }
}
// 情况 B:有返回值 → 用 Future/CompletableFuture,异常在取结果时暴露
@Async("notifyExecutor")
public CompletableFuture<Boolean> sendSms(String phone) {
    try {
        smsClient.send(phone);
        return CompletableFuture.completedFuture(true);
    } catch (Exception e) {
        return CompletableFuture.failedFuture(e);   // 异常封装进 Future
    }
}

// 调用方
CompletableFuture<Boolean> future = smsService.sendSms(phone);
future.whenComplete((ok, ex) -> {
    if (ex != null) {
        log.error("短信发送失败", ex);    // ✅ 显式处理
    }
});

void 即"发后不理"——如果任务失败你不能接受,要么配全局 handler + 补偿,要么用 CompletableFuture 显式感知。


六、坑 5:需要返回值却写了 void,或 Future 用法错误

6.1 正确的三种返回签名

@Async
public void noReturn() { }                        // 发后不理

@Async
public Future<String> legacyFuture() {            // Spring 传统 Future
    return new AsyncResult<>("done");
}

@Async
public CompletableFuture<String> modern() {       // ✅ 推荐:可编排
    return CompletableFuture.completedFuture("done");
}

6.2 CompletableFuture 编排:并行化的正确姿势

// 两个远程调用无依赖关系,并行执行,总耗时 ≈ max(A,B) 而不是 A+B
public OrderDetailVO detail(Long orderId) {
    CompletableFuture<Order> orderFuture = orderApi.getAsync(orderId);
    CompletableFuture<Logistics> logisticsFuture = logisticsApi.getAsync(orderId);

    CompletableFuture.allOf(orderFuture, logisticsFuture).join();   // 等两个都完成
    return new OrderDetailVO(orderFuture.join(), logisticsFuture.join());
}

6.3 两个常见错误

// ❌ 错误 1:方法里手动 new 线程池 supplyAsync,绕过了 @Async 的受管线程池
CompletableFuture.supplyAsync(() -> query());   // 走 ForkJoinPool.commonPool,不可控

// ✅ 正解:指定受管线程池
CompletableFuture.supplyAsync(() -> query(), notifyExecutor);

// ❌ 错误 2:异步方法返回 CompletableFuture 却在方法内部 join 自己——等于同步
@Async
public CompletableFuture<String> a() {
    return CompletableFuture.completedFuture(doWork());
}
// 调用方立刻 .join() 阻塞等待,如果业务不需要结果就让它真异步

七、坑 6:ThreadLocal/MDC 上下文丢失

7.1 翻车现象

// 主线程 MDC 里有 traceId
MDC.put("traceId", "abc123");
logService.asyncRecord(req);    // @Async 方法内部日志的 traceId 变成 null
http-nio-8080-exec-3  [traceId=abc123] 提交异步任务
       ↓ 线程切换,ThreadLocal 天然不跨线程
notify-1              [traceId=?]      ← 日志断链,排查时无法关联

ThreadLocal 是线程私有存储,线程池线程从任务队列取任务执行,不会自动继承提交者的上下文。MDC(traceId)、SecurityContext、RequestContextHolder、自定义租户上下文全部会丢

7.2 修复:任务装饰器传递上下文

/**
 * 提交任务时"拍照"主线程上下文,执行前装到工作线程,执行后清理
 */
public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // ① 在提交线程(主线程)抓取上下文快照
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        Long userId = LoginContext.getUserId();

        return () -> {
            // ② 在工作线程恢复
            Map<String, String> previous = MDC.getCopyOfContextMap();
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                if (userId != null) {
                    LoginContextHolder.set(userId);
                }
                runnable.run();
            } finally {
                // ③ 必须清理!线程会被复用,否则上下文污染下一个任务
                if (previous != null) {
                    MDC.setContextMap(previous);
                } else {
                    MDC.clear();
                }
                LoginContextHolder.clear();
            }
        };
    }
}
// 在线程池配置里挂上装饰器
executor.setTaskDecorator(new MdcTaskDecorator());

finally 里的清理是关键——线程池线程会被复用,不清理会导致 A 用户的 traceId 出现在 B 用户的日志里,这种污染比上下文丢失更难排查。

Spring Security 场景:SecurityContextHolder 默认用 InheritableThreadLocal 也不能可靠传递线程池场景,同样用装饰器在提交时取 SecurityContextHolder.getContext() 快照,或使用框架提供的 DelegatingSecurityContextRunnable


八、坑 7:拒绝策略选错,任务静默丢弃

8.1 四种拒绝策略对照

策略池满时行为适用
AbortPolicy(默认)抛 RejectedExecutionException推荐:失败显式,配合告警
CallerRunsPolicy退回提交者线程执行能天然限流(提交线程被占用就减速),但会拖慢主流程
DiscardPolicy静默丢弃,不报错日志/埋点类可容忍丢失的任务
DiscardOldestPolicy丢掉队列最老任务再重试极少用,容易丢重要任务

8.2 翻车场景

// ❌ 通知任务用了 DiscardPolicy,大促时池满 → 通知悄悄丢失,客诉才发现
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
// ✅ 推荐写法:拒绝时记录 + 告警/降级,绝不静默
executor.setRejectedExecutionHandler((r, e) -> {
    log.error("[AsyncRejected] pool={} active={} queue={}",
            e.getThreadNamePrefix(), e.getActiveCount(), e.getQueue().size());
    // 写失败补偿表,由定时任务兜底重试
    failoverRepository.save(buildCompensation(r));
    Metrics.counter("async.rejected").increment();
});

选型口诀:不能丢的任务用 Abort + 失败补偿;允许降速的用 CallerRuns(如网关回源);只允许丢日志类任务用 Discard,且要打丢弃指标。


九、坑 8:停机时任务丢失或应用挂起

9.1 两种翻车

翻车 A:应用 kill 时线程池立即 shutdownNow()
  → 队列里 200 个待发通知全部丢失

翻车 B:配了等待但没设超时
  → 某个任务卡死(等一个永不返回的 HTTP)
  → 应用 shutdown 永远完不成 → K8s 等到 terminationGracePeriod 后 SIGKILL 强杀

9.2 修复:优雅停机三件套

executor.setWaitForTasksToCompleteOnShutdown(true);  // ① 停机时等已提交任务完成
executor.setAwaitTerminationSeconds(30);             // ② 最多等 30 秒,超时不再等

任务自身可中断:任务内的 HTTP/DB 调用必须设置超时,不能无限阻塞——否则 awaitTermination 等到超时后任务依然被杀。

配合 Spring Boot 的优雅停机(与前面讲过的 Graceful Shutdown 一篇呼应):

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

时序保证:K8s preStop 摘流量 → 30s 内 HTTP 在途请求处理完 → 线程池 30s 内排空队列 → JVM 退出。


十、常见问题

10.1 @Async 和 @Transactional 同时用有什么坑?

两个注解都基于代理,同类调用问题相同。更隐蔽的坑是执行顺序:异步方法在新线程里执行时,调用方的事务上下文不会传播过去(同坑 6,事务连接绑定在 ThreadLocal)。正确模式:异步方法内自己开新事务(@Async 方法内部调用的另一个 Bean 的 @Transactional 方法),而不是指望加入调用方事务。反过来,在 @Transactional 方法里调用 @Async 方法,异步任务会在事务提交前就开始执行——可能读到未提交数据或查不到数据,需要用 TransactionSynchronizationManager.registerSynchronization 在 afterCommit 后再触发。

10.2 一个项目里应该建几个线程池?

按业务隔离建 2~4 个,不要全局一个大池也不要一方法一池。隔离的价值是故障域隔离:慢的文件任务占满自己的池,不影响通知任务。典型划分:通知池(短信/邮件/MQ 发送)、文件池(导入导出)、外部调用池(慢 HTTP)。命名线程前缀(notify-/file-)在线程 dump 和监控里一眼能区分。

10.3 怎么监控 @Async 线程池?

ThreadPoolTaskExecutor 的 ThreadPoolExecutor 暴露了 activeCount/queueSize/completedTaskCount。Spring Boot 可以把这些注册成 Metric:executor.getThreadPoolExecutor().getQueue().size() 接 Micrometer Gauge,配 Prometheus 告警:队列使用率 > 80% 预警、拒绝次数 > 0 立即告警。JVM 线程数总量也要监控(jvm_threads_live_threads),防止意外的线程泄漏。

10.4 @Async 失效还有哪些冷门原因?

① 没加 @EnableAsync;② Bean 没被 Spring 管理(自己 new 的对象);③ 方法被 final/static 修饰(代理无法重写);④ 类被多个 AOP 切面代理时注解顺序问题;⑤ 自测时用单元测试直接 new 对象调用(没走 Spring 容器,必须 @SpringBootTest 注入 Bean 才有效)。

10.5 虚拟线程(Java 21)下还需要配线程池吗?

需要,但思路不同。Spring Boot 3.2 开启 spring.threads.virtual.enabled=true 后可让 @Async 走虚拟线程执行器——虚拟线程轻量,IO 阻塞时会卸载载体线程,适合大批量 IO 型异步任务,此时传统池的 core/max 参数失去意义。但下游资源保护(DB 连接池、外部 API 限流)仍需信号量限流,否则百万虚拟线程会把连接池打穿——这正是"虚拟线程 5 个坑"那一篇讲过的内容。

10.6 AsyncConfigurer 实现后不生效?

常见原因:实现类没被扫描到(不在 @ComponentScan 范围)、存在多个 AsyncConfigurer Bean(Spring 不知道用哪个)、或用了 @Configuration(proxyBeanMethods=false) 导致 Bean 方法间引用异常。排查时给 getAsyncExecutor 打断点,确认启动时被调用;也可以直接用 @Async("beanName") 显式指定,比依赖全局默认更稳。


十一、总结

8 坑速查卡

┌────┬────────────────┬──────────────────────────────────────┐
│ 坑 │ 症状            │ 解法                                  │
├────┼────────────────┼──────────────────────────────────────┤
│ 1  │ 非 public 失效  │ 方法必须 public                        │
│ 2  │ this 内部调用   │ 拆 Bean(首选)/注入自身代理/AopContext │
│ 3  │ 默认池不可控    │ 自定义 ThreadPoolTaskExecutor,有界队列 │
│ 4  │ 异常被吞        │ AsyncUncaughtExceptionHandler / Future │
│ 5  │ 返回值错误      │ CompletableFuture,别 join 自己        │
│ 6  │ MDC/上下文丢失  │ TaskDecorator 快照+恢复+finally 清理   │
│ 7  │ 拒绝静默丢任务  │ Abort/自定义 handler+补偿,Discard 限用 │
│ 8  │ 停机丢任务/挂起 │ waitForShutdown + awaitTermination+任务超时│
└────┴────────────────┴──────────────────────────────────────┘
验证口诀:线程名前缀确认真异步;队列/拒绝数接监控;线程池按业务隔离

一句话

@Async 的 8 个坑本质上是四组机制的副作用:AOP 代理决定了非 public 和 this 内部调用必然失效(跨 Bean 调 public 方法才走代理);线程池决定了不配就用无界队列埋 OOM 雷、拒绝策略选错就静默丢任务(必须自定义有界池、按业务隔离、拒绝要有补偿);任务提交模型决定了异常跨不了线程、返回值得靠 CompletableFuture 显式感知(void 就是发后不理);线程切换决定了 MDC/安全上下文全丢、停机时队列里的任务可能蒸发(TaskDecorator 做上下文快照且必须 finally 清理,优雅停机配 waitForTasksToCompleteOnShutdown + awaitTerminationSeconds,且任务自身要有超时)。使用前先回答三个问题:这个任务失败了怎么办(异常处理)、下游扛得住多快(池大小和拒绝策略)、应用重启时在途任务怎么办(优雅停机)——三个问题都有答案,@Async 才算用对了。

给团队的建议

建议
规范@Async 必须显式指定线程池 Bean 名,禁止裸用
线程池有界队列 + 按业务隔离 2~4 个池 + 命名前缀
异常全局 AsyncUncaughtExceptionHandler 接告警;重要任务走 CompletableFuture
上下文统一 TaskDecorator 传 MDC/用户身份,finally 必须清理
停机优雅停机 + 任务内外部调用超时 ≤ awaitTerminationSeconds
监控队列使用率、拒绝次数、活跃线程数接 Prometheus
Review检查清单:public?跨 Bean?有池名?void 可接受丢失?

互动话题:你们被 @Async 的哪个坑坑得最惨?见过默认线程池把服务拖垮的真实案例吗?评论区聊聊。


参考资料


标题:Spring Boot 异步编程实战:@Async 的 8 个坑+正确姿势
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/18/1789225175836.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消