聊到 Spring Boot 异步操作,我脑子里浮现的其实不是 @Async 注解兑现出“秒回”体验的成就感,而是一连串线上踩坑记录。短信通知莫名丢了、接口偶发超时、线程池把内存堆到报警、本想异步处理结果把日志链路全打断——这些问题有一个算一个,都是异步两个字惹出来的。Spring Boot 把异步的入口做得足够简单,加一个 @EnableAsync,再往方法上贴一个 @Async,看起来就结束了。可生产环境真正能用的异步,背后还压着一整套线程池参数、代理机制、异常兜底、事务边界和上下文传递问题,任何一个环节没想清楚,上线的就不是提速,而是埋雷。
这篇文章适合两类人:一类是刚接触异步,打算在项目里引入 @Async 做通知、报表、日志这类旁路任务的开发者;另一类是已经在用 @Async,但遇到过“怎么不生效”“线程池不生效”“查出慢得离谱”这类问题的同行。我会按自己真实踩坑的顺序来讲,从原理到配置再到排障,尽量把每一步“为什么这么做”说透。
1. 先用一个案例说清楚:异步解决什么,不解决什么
1.1 异步的本质:把“等待”变成“交办”
把异步这个概念用生活里的场景类比一下。同步就是你站在餐厅柜台前,等师傅把菜做完端到你手上,中间你什么都干不了;异步就是你先取号,回座位该干嘛干嘛,菜好了叫号你再去取。程序里的异步也是这个逻辑:一个短信发送、一段报表计算、一批数据推送,这些事往往要几百毫秒甚至几秒,如果都堵在业务主流程里,用户看到的就是接口转圈、页面白屏。
Spring Boot 里的 @Async 做的事就是把这类任务提交给另一个线程去执行,调用方立刻返回。举个例子,订单创建成功后要发通知、写积分、推送数据,这些和“订单创建完成”这个核心结果没有强依赖关系。把它们放到异步线程里,接口响应时间立刻从九百毫秒压到一百毫秒。
但这里有个必须一开始就说清楚的认知:异步不代表“更快”,它只是让主线程不用干等。任务的总耗时没有减少,甚至因为线程切换、队列排队还会增加一点。它换来的是主流程快速响应,代价是把一套原本顺序执行、错误立刻可见的逻辑,变成了并行执行、错误藏在后台的逻辑。所以,不是所有业务都适合异步化。
1.2 哪些场景适合异步,哪些千万别碰
我自己的判断标准很简单,往下问三个问题:
- 这个任务的结果,调用方需不需要立刻知道?如果不需要,可以考虑异步。
- 这个任务失败,主流程能接受吗?如果能接受延迟补偿、重试、失败入库,可以考虑异步。
- 这个任务是不是独立的旁路逻辑?比如通知、统计、数据同步,脱离了主流程也能自洽,可以考虑异步。
适合异步的典型场景:短信、邮件、站内信通知;日志上报和审计数据落库;报表预计算;批量数据导入导出;调用第三方接口做资料同步;缓存预热。这些场景的共同特点是:任务可以晚一点完成,甚至偶尔失败一次也能在后续补偿中找补回来。
不适合异步的场景也不少。一个是强一致性的写操作,用户下单后立刻要看到库存扣减、订单状态变更,这种结果必须实时返给前端,就不能拆到异步线程里“有空再说”。另一个是后续业务分支依赖前一个任务的返回值,而且这个依赖发生在同一个事务里,拆开异步就是把原子性拆没了。还有就是任务量极小、执行只要几毫秒、但对响应时间要求也没那么苛刻的场景,引入异步反而是过度设计,线程池调度开销比任务本身还大。
1.3 线程池异步、消息队列、响应式,别混为一谈
广义的异步一共有三种常见形态,项目里经常有人混着用,先分清是好事:
| 方案 | 本质 | 优点 | 合适场景 |
|---|---|---|---|
| 线程池 + @Async | 进程内内存队列,任务交给本地线程 | 实现简单、改造成本低 | 进程内旁路任务、轻量并发 |
| 消息队列 | 独立中间件,消息持久化、可重试、死信 | 可靠、削峰、跨服务解耦 | 大流量削峰、跨系统事件通知 |
| 响应式编程 | 事件驱动、非阻塞 IO | 用极少线程撑高并发 | IO 密集且吞吐要求极高 |
我这里后面要讲的主要是第一种,进程内线程池异步。很多人项目里真正需要的其实是消息队列,但图省事用了 @Async,任务在应用重启的一瞬间全部丢光,后来才回头补 MQ。反过来也有人任务量很小,非要去引入一套队列中间件,运维成本远超收益。我的建议是:任务可以丢、延迟可以忍、量也不大,先用线程池;要求不丢、要削峰、要跨服务解耦,再考虑消息队列。
2. 快速跑通:@Async 的几种写法和经典陷阱
2.1 基础三步:开开关、加注解、看线程名
让 Spring Boot 支持异步,第一步是在配置类或启动类上加 @EnableAsync:
@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }第二步是在需要异步执行的方法上标注 @Async:
@Service public class NotifyService { @Async public void sendSms(String mobile, String content) { // 模拟耗时请求 try { Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("短信发送完成:" + mobile + " " + Thread.currentThread().getName()); } }第三步,也是最容易被忽略的一步:验证是否真的异步。在方法里把当前线程名打出来,同步执行时打印的是请求线程,异步执行后打印的应该是线程池里的线程名。这一步千万别省,后面很多“不生效”的坑,靠这一条就能立刻暴露。
2.2 自调用失效:最经典的“为什么注解没反应”
@Async 不生效的原因有很多,真正常见的其实是同一个:自调用。我在代码评审里至少看到过十几次这种写法:
@Service public class OrderService { public void createOrder(String orderId) { // 前面一堆订单业务 this.sendNotify(orderId); // 期望异步,其实是同步执行 } @Async public void sendNotify(String orderId) { // 短信、推送、积分…… } }出现这个问题的原因要说到 Spring 的代理机制。@Async 和 @Transactional 一样,都是基于 AOP 代理实现的。Spring 在容器启动时生成一个代理对象,外部调用方拿到的是代理,代理在处理请求时会把方法提交给线程池。但 createOrder 方法内部用 this 调用 sendNotify,这个 this 是当前对象本身,不是代理,等于直接绕过代理调用了原始方法,异步自然不生效。
解决办法有三种。最简单的推荐方案是把异步方法拆到另一个 Bean 里,比如单独建一个 NotifyService,让 OrderService 注入它再调用。第二种是自我注入,通过 ObjectProvider 拿到代理对象。第三种是用 AopContext.currentProxy(),但需要额外开启 exposeProxy 配置,比较绕,我一般不推荐。
2.3 异步方法的三种返回值写法
@Async 方法可以声明为 void,也可以返回 Future 或 CompletableFuture,不同写法的语义差别很大。
void 用于“发出去就不管”的任务,调用方不关心结果,也没法知道什么时候失败。比如发送通知、上报日志。
@Async public void sendLog() { // 不管结果 }Future 用于调用方确实想拿结果,但又不想同步死等的场景。配合带超时的 get 方法,既能拿到结果,又不会无限阻塞:
@Async public Future<String> queryReport(String date) { // 模拟报表生成 return new AsyncResult<>("report:" + date); } // 调用方 Future<String> future = reportService.queryReport("2024-01-01"); String result = future.get(3, TimeUnit.SECONDS);CompletableFuture 更高级一点,适合多个异步结果需要编排的场景。比如一个页面要同时查价格、库存、活动信息,串行调用要 250 毫秒,并行只要 120 毫秒。这种情况可以结合 supplyAsync 提交任务,用 allOf 等所有任务完成,再统一收集结果。
3. 真正的分水岭:线程池参数与 Bean 命名陷阱
3.1 为什么“不配置线程池”等于裸奔
网上很多教程只写 @EnableAsync + @Async,完全不提线程池,照着敲完功能也跑得起来,但这恰恰是最危险的地方。Spring Boot 的自动配置确实会在你没有自定义 Executor 时提供一个默认线程池,可这个默认线程池的队列是无界的,任务会一直往队列里塞。一旦生产环境出现短时流量尖峰,任务积压成百上千,内存占用直接往上飙,最后 OOM 或者响应全面劣化。
更隐蔽的坑是:当你在容器里自定义了一个 Executor,但命名和类型没落在 Spring 的查找规则上时,@Async 可能找不到合适的线程池,最终退回到最原始的 SimpleAsyncTaskExecutor。这个实现不复用线程,每次调用都 new 一个线程,用完全部丢弃。流量稍大一点,系统线程数疯狂上涨,程序直接假死。所以我的结论非常明确:用 @Async 的第一步,就是配好自己的线程池,不配等于裸奔。
3.2 线程池参数怎么定:一套可复用的推导流程
线程池参数没有银弹,但它有一套推导思路,我每次配置都按这个流程走。
第一步估算 IO 密集度。绝大多数异步任务都是 IO 密集型,比如调三方接口、读写数据库、发消息。CPU 密集型任务占比很低。经验公式是:IO 密集型核心线程数约为 CPU 核数的 2 倍;CPU 密集型则是核数 + 1。举例,一台 4 核服务器,主要跑 IO 任务,核心线程数可以取 8。
第二步定最大线程数和队列容量。很多人只配了核心和最大,忽略了队列,Spring 默认的队列类型是无界队列,最大线程数永远不会触发。正确做法是显式设置一个有界队列。队列容量怎么算?你可以用“允许积压的任务数 × 单个任务平均耗时”来反推合理延迟。假设单个任务平均耗时 100ms,业务上允许队列里的任务最多等 10 秒,那么队列容量 = 10 秒 / 100 毫秒 = 100。最大线程数可以先用核心线程数的 2 倍起,比如核心 8、最大 16,然后压测调整。
第三步配置线程名前缀、拒绝策略和优雅停机。线程名前缀看着小事,排障时价值极大,日志里一眼看出任务跑在哪个池。完整配置类长这样:
@Configuration public class AsyncPoolConfig { @Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数:IO密集型,核数4,取2倍 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(16); // 有界队列,替代默认无界队列 executor.setQueueCapacity(100); // 非核心线程空闲回收时间 executor.setKeepAliveSeconds(60); // 线程名前缀,排障时靠它认线程 executor.setThreadNamePrefix("async-task-"); // 拒绝策略:由调用线程执行,不丢任务 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 优雅停机:等已提交任务完成,最多等30秒 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }这里有个很关键的细节:custom 线程池的 Bean 名我特意叫 taskExecutor,这不是随手起的。Spring 的异步执行器查找规则是:先找容器中唯一的一个 TaskExecutor;如果没有唯一的,再找名为 taskExecutor 的 Bean;都找不到,才退回默认实现。你如果自定义 Bean 方法名用的是 myExecutor,而容器里没有其他 Executor 时它确实能生效,但一旦项目里又冒出别的 Executor,查找规则就乱了。为了稳妥,要么 Bean 名起成 taskExecutor,要么在 @Async 注解里显式指定池,比如 @Async("reportExecutor"),两者都做最保险。
3.3 拒绝策略选 CallerRunsPolicy 还是直接抛异常
拒绝策略是线程池满员、队列也满时的最后一道防线。常见选择有四种:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛异常 | 任务必须立刻失败告警 |
| CallerRunsPolicy | 由提交任务的线程自己执行 | 不能丢任务,可以牺牲主流程响应 |
| DiscardPolicy | 静默丢弃 | 能接受任务丢失 |
| DiscardOldestPolicy | 丢弃队列头部最老任务 | 只关心最新任务 |
我自己线上默认用 CallerRunsPolicy。理由很直接:业务异步任务大多是通知、报表、数据同步,丢了补不回来,由提交线程执行虽然会让现场慢一点,但任务还在,日志还能追踪。如果任务本身对时效特别敏感,比如实时风控,反而应该选 AbortPolicy 并配合监控告警,让它立刻暴露出压力问题,而不是悄悄拖慢。
4. 异步里的四大隐雷:异常、事务、上下文、监控
4.1 同步调用异常是一条直线,异步异常是一口枯井
同步方法抛异常,调用方能立刻 catch 到,日志里也有完整堆栈。异步方法完全不同:void 方法一旦抛异常,调用方早就返回了,谁来接这个异常?
Spring 提供了一个兜底接口 AsyncUncaughtExceptionHandler,只要方法没有返回值,异常就会交给它。注意,一旦方法声明了 Future 或 CompletableFuture 返回值,异常会被包装进 Future 里,由调用方在 get 的时候抛出来,走不到这个兜底接口。
生产上我建议实现一下这个接口,把异常接到监控告警里:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { // 同上 } @Override public Executor getAsyncExecutor() { return taskExecutor(); } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { // 记录日志并把异常信息送到监控平台 System.err.println("异步任务执行异常:" + method.getName()); ex.printStackTrace(); }; } }这里还要提醒一句:异步异常不像同步异常那么显眼,它默认是“吞噬”掉的,不处理的话线上出了故障你都不知道在哪个环节丢的。一定要在异常处理器里加告警,否则异步就是事故盲区。
4.2 事务边界:异步线程里的事务,和调用方不是一回事
@Async 和 @Transactional 同时出现在一个方法上时,很容易让人误以为还能保持同一个事务。实际完全不是那么回事。
Spring 的事务和线程绑定。调用方业务在自己的事务里执行,@Async 方法被提交到另一个线程,那个线程里默认是没有事务上下文的。所以下面这种写法,异步方法有事务,但它是一个全新开启的独立事务,提交、回滚都和调用方无关:
@Async @Transactional public void syncUserData(String userId) { // 这个事务只管理异步线程里的操作 }更常见的坑还是自调用。同一个类里 this 调用,不仅 @Async 失效,@Transactional 也跟着失效。所以当你看到“异步方法里出异常了数据没回滚”这类问题,先检查是不是自调用,再检查事务方法本身有没有被外部 Bean 调用。
我自己的设计原则是:一个操作如果必须保证原子性,就别拆成异步;拆成异步,就要接受最终一致性和补偿方案。异步加上事务,能解决的是“异步线程内部自己的数据一致性”,解决不了“调用方事务和异步任务一起回滚”,想靠一个注解把两边事务绑在一起,那是做不到的。
4.3 ThreadLocal 与 MDC:跨线程时上下文不会自己跟过去
还有一些业务场景需要在异步线程里拿到主线程的上下文信息,最常见的是用户 ID、语言环境、日志追踪 ID。很多人直接用 ThreadLocal 存用户信息,然后在异步方法里取,结果取到 null,一脸懵。
ThreadLocal 是线程私有的,父线程里 set 的值,子线程默认拿不到。Spring 的 @Async 底层是把任务丢进线程池,执行线程完全可能是另一个线程,所以 ThreadLocal 自然丢了。普通 Executor 子类还能通过 TaskDecorator 做一些包装,ThreadPoolTaskExecutor 正好支持这个扩展。
比较实用的是日志 MDC 传递,ELK 之类的日志系统靠 traceId 串链路,异步线程丢了 MDC,整条日志链路就断了。解决办法是提交任务时把当前 MDC 内容复制一份,在任务执行前 set 回去:
executor.setTaskDecorator(runnable -> { Map<String, String> contextMap = org.slf4j.MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { org.slf4j.MDC.setContextMap(contextMap); } try { runnable.run(); } finally { org.slf4j.MDC.clear(); } }; });注意最后一个细节:finally 里必须 clear。线程池里的线程会被复用,上一次任务留下的 MDC 不清掉,下一个任务继承的就是脏上下文,日志串味,排障能给你带沟里去。
4.4 监控:线程池不会主动告诉你它快扛不住了
线程池的问题往往不是突然发生的,而是慢慢堆积的。任务从核心线程开始跑,满了进队列,队列满了才拉最大线程,最大线程也满了才开始拒绝。如果没有监控,等到你发现接口变慢、任务丢失,已经是拒绝策略触发之后的事了。
我建议至少打一套定时监控日志,每隔一分钟记录线程池的关键指标:
- 当前活跃线程数:executor.getActiveCount()
- 队列积压数量:executor.getThreadPoolExecutor().getQueue().size()
- 已完成任务总数:executor.getThreadPoolExecutor().getCompletedTaskCount()
- 当前线程数:executor.getPoolSize()
最简单的做法是配一个定时任务把这些值打到日志里,性价比很高。更正式的方案是用 Actuator 或者 Micrometer 把线程池指标接入监控大盘,设置告警阈值,比如队列积压超过 80% 就报警。这一项很容易被忽略,但它在关键时刻能救你一次。
5. 三次实战复盘:从超时接口到并发批处理
5.1 案例一:接口超时,靠异步把 900ms 降到 100ms
某商城项目的下单接口,经过服务化拆分后,主流程只剩核心的几笔数据库操作,但接口还是很慢。排查日志发现,订单创建成功后串行调用了三个旁路逻辑:发短信通知、扣积分、往推荐系统推送数据。三方接口响应速度不稳定,平均每个 100ms 到 300ms,三个叠加起来接口就奔着秒级去了。
改造方案是把这三个旁路逻辑全部挪到 NotifyService 的 @Async 方法里,主流程只负责保存订单和提交成功响应。改造后接口从 900ms 左右降到 100ms 上下,效果立竿见影。但这里有个前提:旁路逻辑允许失败。我同时加了一张通知记录表,异步方法里失败时记录失败原因和重试次数,由定时任务扫描重试。异步之后如果不补一个补偿机制,等于把失败的隐患从明处挪到了暗处。
5.2 案例二:批量对账任务,用 CompletableFuture 控制并发切片
另一个项目里有个对账任务,每天要把上万条明细逐条调用第三方查询接口核对状态,串行执行要跑一个多小时。这个场景适合并发切片,但绝不能无脑开一万个线程,而是用线程池的固定并发度来限制。
我当时的做法是把大列表按固定大小切块,比如每块 50 条,提交到线程池里并行处理,再用 CompletableFuture.allOf 等待所有分片完成,最后统一收集失败明细。核心代码如下:
List<List<Record>> partitions = partition(records, 50); List<CompletableFuture<Void>> futures = partitions.stream() .map(batch -> CompletableFuture.runAsync(() -> processBatch(batch), taskExecutor)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();线程池最大线程数设置在 16,等于并发度最高就是 16,而不是把一万个任务全怼上去。改造后整个任务从一个多小时压缩到十几分钟。这个案例里最重要的不是 CompletableFuture 的用法,而是“用线程池本身控并发”这个思路:限流不是靠代码手写,而是靠你给线程池设置 maxPoolSize。
5.3 案例三:一个页面查四处数据,异步编排比想想中顺手
还有一种常见场景是接口内部有多个互不依赖的远程调用。比如商品详情页要查库存、价格、优惠活动和评价摘要,四个接口串行调用,每个 50ms,总耗时 200ms。用 CompletableFuture 把这些调用并发化,总耗时约等于最慢的那个接口,而不是四个之和。
这类改造最大的收益不在第一个版本,而在后续接口越来越多的时候。每多接一个外部服务,串行方案就又增加一段延迟,异步编排方案几乎不增加总耗时。注意这里还是要指定线程池,默认的 ForkJoinPool.commonPool 不适合生产环境,并发度会受 CPU 核数影响,业务隔离也做不到。
6. 异步操作问题速查表与排查顺序
6.1 常见问题清单
| 现象 | 大概率原因 | 解决思路 |
|---|---|---|
| @Async 方法同步执行,线程名不变 | 自调用 / 没加 @EnableAsync / 方法是 private | 拆 Bean、检查注解、改为 public |
| 异步方法莫名丢任务 | 拒绝策略 AbortPolicy / 应用重启 | 换 CallerRunsPolicy、补持久化机制 |
| 线程数疯狂涨,系统假死 | 退回 SimpleAsyncTaskExecutor | 自定义线程池并显式绑定 |
| 队列一直涨,内存告警 | 默认无界队列 | setQueueCapacity 设置容量 |
| 异步方法里事务没回滚 | 自调用 / 误解事务边界 | 确认代理调用,事务独立管理 |
| 异步线程里 ThreadLocal 取到 null | ThreadLocal 不跨线程 | 用 TaskDecorator 显式传递 |
| 日志链路断裂,traceId 丢失 | MDC 未跨线程 | 在装饰器里复制 MDC 并清理 |
| Future.get 一直阻塞 | 任务卡住,无限等待 | 用 get(timeout) 设超时 |
| 线程池参数不生效 | Bean 名不满足查找规则 | Bean 名用 taskExecutor 或显式指定 |
6.2 我自己的排查顺序
遇到异步相关问题时,我一般按这个顺序排:先看日志线程名前缀,确认任务到底跑没跑进自定义线程池;然后看 @Async 方法被谁调用,排查自调用;再看拒绝策略和队列积压;最后查异常处理器和监控日志,定位是被吞掉的异常还是线程池满造成的。
这套顺序排下来,绝大多数“奇怪”问题都能在十分钟内找到方向。实际工作里不要只盯着配置项,先把“任务到底在哪里执行”这个事实确认了,后面就好办了。异步的题,十有八九出在“你以为它在这个池子里跑,其实它根本没进去”。如果让我给刚接触异步操作的同行提一条建议,那就是:写完 @Async 先别急着上线,打开线程名前缀日志,把异常处理器、拒绝策略、补偿任务这三个位置全部过一遍,确认无死角再发布。这个习惯我每次改造异步逻辑都会重复一遍,大小事故都靠它挡住了。