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 参数怎么定
| 参数 | 经验值 | 依据 |
|---|---|---|
| corePoolSize | IO 密集 ≈ CPU 核数 × 2,CPU 密集 ≈ 核数 | IO 任务大量时间在等待,可多开 |
| maxPoolSize | core 的 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 Framework 官方文档:@Async 与异步方法
- Spring Boot 官方文档:Task Execution 自动配置
- ThreadPoolExecutor 源码解析(Oracle)
- Spring Security 异步上下文传递
- MDC 官方文档(SLF4J)
标题:Spring Boot 异步编程实战:@Async 的 8 个坑+正确姿势
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/18/1789225175836.html
公众号:服务端技术精选
- 引言
- 一、前置:@Async 的工作原理
- 二、坑 1:方法不是 public,注解静默失效
- 2.1 翻车代码
- 2.2 原理
- 2.3 修复
- 三、坑 2:同类内部调用,this 绕过代理
- 3.1 翻车代码
- 3.2 修复方案(三选一)
- 四、坑 3:没自定义线程池,行为不可控
- 4.1 先厘清一个常见误传
- 4.2 生产级线程池配置
- 4.3 参数怎么定
- 五、坑 4:异常被线程"吞掉",主流程毫无感知
- 5.1 翻车代码
- 5.2 修复:分两种返回值处理
- 六、坑 5:需要返回值却写了 void,或 Future 用法错误
- 6.1 正确的三种返回签名
- 6.2 CompletableFuture 编排:并行化的正确姿势
- 6.3 两个常见错误
- 七、坑 6:ThreadLocal/MDC 上下文丢失
- 7.1 翻车现象
- 7.2 修复:任务装饰器传递上下文
- 八、坑 7:拒绝策略选错,任务静默丢弃
- 8.1 四种拒绝策略对照
- 8.2 翻车场景
- 九、坑 8:停机时任务丢失或应用挂起
- 9.1 两种翻车
- 9.2 修复:优雅停机三件套
- 十、常见问题
- 10.1 @Async 和 @Transactional 同时用有什么坑?
- 10.2 一个项目里应该建几个线程池?
- 10.3 怎么监控 @Async 线程池?
- 10.4 @Async 失效还有哪些冷门原因?
- 10.5 虚拟线程(Java 21)下还需要配线程池吗?
- 10.6 AsyncConfigurer 实现后不生效?
- 十一、总结
- 8 坑速查卡
- 一句话
- 给团队的建议
- 参考资料
评论