☰
Spring Boot异步调用实战:从@Async到线程池、事务与监控的完整指南
2026/10/12 11:11:34 网站建设 项目流程

异步调用这事,好多项目一开始都不当回事。等哪天线上接口突然变慢、调用链路上某个环节卡了五秒,才想起来:这活儿不该同步干。我这两年经手过好几个项目,最典型的一个场景是下单成功之后的通知组合拳——发短信、推消息、记流水、扣库存。全部同步跑完,主流程接口直接奔着三秒去了,用户端转圈圈,后台一堆超时报警。

说白了,Spring Boot 里做异步调用,不是为了炫技,是为了把“非关键路径”的活儿从“关键路径”上挪走,让请求线程早点释放,让用户早点儿拿到响应。这篇我就把实操里验证过、踩过坑的方案完完整整梳理一遍,从最简单的 @Async 注解到自定义线程池的参数测算,再到异常处理和事务组合的那些坑。不管你是刚开始接触 Spring Boot,还是已经在项目里用过异步但被莫名问题坑过,这篇应该都能给你一些直接能抄作业的参考。

1. 先搞清楚:异步到底解决的是什么问题?

很多入门的教程一上来就贴 @Async 注解,告诉你这样就是异步了。但如果你不清楚这玩意解决的是什么问题,很容易在错误的地方用、在正确的地方不敢用。

1.1 同步链路里的“隐形浪费”

想象一下你公司食堂打饭的窗口。打饭阿姨既要收钱、又要打菜、还要抽空回答“这个菜辣不辣”。每个排队的人都觉得阿姨服务自己花了二十秒,其实真正盛菜只要八秒。系统里的请求线程就是阿姨,业务逻辑里那些耗时的非核心步骤就是“回答辣不辣”。

传统同步调用里,一个 HTTP 请求进来,容器从线程池里分配一个线程给你,这个线程要一路执行完 controller -> service -> dao 的全部逻辑,最后把响应返回。如果 service 里有一个短信接口,正常耗时 800 毫秒,但对方服务偶发延迟到 3 秒,你这 3 秒就白白夯在线程上。用户感受是接口慢,后台的感受是线程被占着不放,后续请求只能排队等线程。线程池耗尽,雪崩就来了。

异步要解决的问题本质就两个:一个是响应时间,让调用方不需要等你不重要的步骤跑完;一个是吞吐量,别让有限的线程被 IO 等待拖死。

1.2 Spring Boot 里异步的三个层级

Spring Boot 里做异步,从轻到重大致可以分三个层级,这篇会逐个展开:

  • 方法级异步:@Async 注解 + 线程池,适用于服务内部把某个耗时的 Bean 方法甩给后台线程去执行。
  • Web 层异步:DeferredResult、WebAsyncTask、Callable,适用于把 HTTP 请求本身变成异步,让 Tomcat 线程先归还,等业务结果出来了再返回。
  • 应用间异步:消息队列。这不是 Spring 注解层面的事,而是把任务彻底抛给另一个系统去消费,适合重试、削峰、解耦。

选哪个,取决于业务需要。别一上来就上 MQ,很多时候 @Async 就够用了。

2. 最基础的姿势:@Async 三分钟接入

如果你的需求只是把方法内部某个非核心步骤拎出去异步执行,@Async 是最快的方式。但我得提醒一句:三分钟接入是骗人的,真正接得好,还要解决线程池、异常、事务等问题。先看基础。

2.1 开启异步能力

启动类或者配置类上加一个注解:

@EnableAsync @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

@EnableAsync 是总开关,不加这个,下面的 @Async 一概不生效。另外 Spring Boot 2.1+ 之后,如果你没有自定义线程池,默认会去查找容器中的 TaskExecutor 或者名字为 “taskExecutor” 的 Bean,都找不到的话,它会用 SimpleAsyncTaskExecutor。

2.2 一个简单的异步方法

@Service public class OrderNotifyService { @Async public void sendSmsAsync(String orderId) { // 模拟短信发送 Thread.sleep(1000); System.out.println("异步执行发送短信:" + orderId + ", 线程: " + Thread.currentThread().getName()); } }

调用的时候,注意必须从另一个 Bean 里注入调用,或者通过 ApplicationContext 拿代理对象来调用。

@Service public class OrderService { @Resource private OrderNotifyService notifyService; public void createOrder(String orderId) { // 核心业务逻辑 System.out.println("创建订单:" + orderId); // 异步发送短信,不阻塞主流程 notifyService.sendSmsAsync(orderId); System.out.println("主流程结束"); } }

跑起来你会发现主流程不会等 Thread.sleep(1000),直接打印“主流程结束”,短信线程在后台慢慢执行。看起来很简单,对吧?

2.3 为什么用 Spring 而不是自己 new 线程

有人会说,我不就是想让代码别阻塞吗?我自己 new Thread 扔后台不行吗?行,但问题一堆:线程生命周期没人管,项目里如果每个接口都 new 一个线程,几百个请求下去就是几百个游离线程,无上限、无复用、无监控。用 Spring 的线程池,至少能统一管理、复用线程、控制最大并发,接入监控也方便。

提示:从 Spring 6.1 / Boot 3.2 开始,@Async 在相同类内部方法调用会直接走 this 引用,注解失效。这个坑我后面单独讲。

3. 核心难点:自建线程池与参数测算

框架给你默认的线程池,就像公司给你配了台默认配置的办公电脑。日常 word 打字肯定够了,但你要搞视频剪辑,它就发烫给你看。异步任务只要稍微带一点 IO 或者并发量上来,默认线程池就是第一个要换掉的。

3.1 默认的 SimpleAsyncTaskExecutor 为什么不能用

Spring Boot 文档里写得很清楚,只要容器里没有自定义线城池,默认会退到 SimpleAsyncTaskExecutor。这个名字翻译过来是“简单异步”,但说得难听点叫“裸奔异步”。它每次调用都会新建一个线程来执行,不复用线程池。高并发场景下线程数直接暴涨,很快资源耗尽、OOM 或者服务僵死。

所以接异步的第一件事不是写 @Async,是先把线程池定下来。下面给一个我在项目里常用的配置类,关键参数我都加了注释说明。

3.2 线程池参数怎么定

@Configuration public class AsyncThreadPoolConfig { @Bean("customAsyncExecutor") public ThreadPoolTaskExecutor customAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(16); // 队列容量 executor.setQueueCapacity(1000); // 最大存活时间 executor.setKeepAliveSeconds(60); // 线程名前缀,方便排查日志 executor.setThreadNamePrefix("async-order-"); // 拒绝策略:由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 交给容器管理生命周期 executor.initialize(); return executor; } }

参数怎么定,这是个值得说道的问题。网上流传的公式是 CPU 核心数 + 1(CPU 密集型)或者 CPU 核心数 × 2 + 1(IO 密集型)。但我跟你讲,这个公式只是一个起点,真实生产里最好通过压测来回调。

我的做法一般是这么推的:先估算核心业务指标,比如“每秒需要处理多少个异步任务”和“单个任务的平均耗时”。举个例子,假设系统目标是每秒处理 300 个异步通知,单个任务平均耗时 200 毫秒,那么需要的并发执行线程数至少是 300 × 0.2 = 60 个。再考虑线程本身有一些创建开销、任务产生的不均匀,我会先定核心线程 32、最大线程 64、队列容量 500,然后用压测去验证。

线程池的线程数和队列是跷跷板。核心线程数定得大,响应快但资源占用高;队列定得大,突发流量都能缓冲,但积压任务过多时实时性变差。我的习惯是:一个任务的耗时越短,越不该依赖队列缓冲,直接把线程数调大,让任务尽快跑完;一个任务耗时越长,越要控制并发量,防止线程全被长任务占住,短任务排队干瞪眼。

3.3 拒绝策略选择与队列容量推导

拒绝策略要在“任务满到装不下”的时候才触发。ThreadPoolExecutor 自带四种:

策略行为适用场景
AbortPolicy直接抛 RejectedExecutionException不推荐用于业务异步,会导致调用方感知异常
CallerRunsPolicy任务退回调用者线程执行常用,牺牲调用线程,但保证不丢任务
DiscardPolicy静默丢弃可用于日志等可丢任务
DiscardOldestPolicy丢弃最老的任务某些场景下可用,但要注意业务丢失

我个人在异步通知类任务里,倾向于 CallerRunsPolicy。理由很简单:任务不能丢,丢了用户就收不到通知。退回调用者去执行,虽然会拖慢调用方,但至少是“慢”而不是“丢”,而且这种状态本身就是信号,说明线程池压力已经到顶,该扩容或降级了。

队列容量这块,我踩过一次坑。当时图省事把队列设置成 99999,想着能缓冲掉所有峰值。结果某天上游系统疯狂打了几个小时的流量,队列把积压的任务全装下了,看似没拒绝,但等真正消费到的时候,任务已经过期、用户已经下单了三天,通知才姗姗来迟。所以队列不是越大越好。核心线程数 + 队列的容量 = 常规情况下系统能吃的并发量,超出这个量才动用最大线程数。我一般按预估峰值 QPS × 任务耗时 × 2(余量系数)来设队列。

4. 异步方法内部的“魔鬼细节”

基础功能跑通不难,真正让开发从“自以为懂了”到“被生产环境教做人”的,是下面这几个细节。每一项我都见过真实案例。

4.1 this 调用为什么会失效

这是 @Async 失效的头号原因。你在同一个类内部,方法 A 调用方法 B,B 上标了 @Async,但根本不生效。

@Service public class OrderService { public void handleOrder(String orderId) { this.sendNotifyAsync(orderId); // 不生效!其实是 this 调用的自己 } @Async public void sendNotifyAsync(String orderId) { // 异步逻辑 } }

原因很简单:@Async 是基于 Spring AOP 做代理实现的。Spring 会为你的 Bean 生成一个代理对象,调用异步方法时,实际上会经过代理,由代理把任务丢给线程池。但 this 调用是直接调目标类自身的方法,根本没经过代理对象,注解自然就成了摆设。

解决方式有三个:

  1. 把异步方法单独放到一个 Service 类里,从外部注入并调用。
  2. 在类内部注入自己的代理,比如@Autowired或者通过ApplicationContext.getBean()拿到代理对象再调用。
  3. 在方法内部使用self()那套方案,但实现复杂,不如拆分类干净。

我推荐的从来都是第一方案。把异步逻辑拆成独立的 Bean,职责更清晰,也能规避所有失效问题。

4.2 异常处理:三选一,别裸奔

@Async 方法有个区别,返回值是 void 的时候,异常不会抛给调用方,而是会被吞掉,直接打到默认的异常处理器上。Spring 默认的异常处理器会输出一行日志就完事,问题是你根本不知道它有没有打,打到哪里了,经常线上出了事连日志都找不到。

第一种方案,方法返回 Future:

@Async public Future<String> sendSmsAsync(String orderId) { try { Thread.sleep(1000); return new AsyncResult<>("success"); } catch (Exception e) { return new AsyncResult<>("failed: " + e.getMessage()); } }

调用方接收 Future,调用 get() 的时候可以拿到结果,或者捕获 ExecutionException。注意,一旦调用 Future.get(),主线程就会等待,失去异步的意义,所以一般不建议同步等结果。

第二种方案,实现 AsyncConfigurer 接口,自定义全局异常处理器:

@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { return new ThreadPoolTaskExecutor(); } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, objects) -> { // 自定义记录日志、告警 System.out.println("异步任务异常,方法:" + method.getName() + ",异常:" + throwable.getMessage()); }; } }

第三种方案,在异步方法内部自己 try/catch,兜底处理好。这个看着最土,但最好用,尤其是对业务有要求的场景。异步任务里的异常你本来也不指望调用方处理,自己 catch 掉,记录日志,该补偿的补偿,该告警的告警。

注意:如果你没实现任何异常处理器,@Async void 方法的异常会走 SimpleAsyncUncaughtExceptionHandler,它的默认日志级别是 WARN 并且只在类路径存在 log4j 或 SLF4J 的时候才输出。现实情况是很多团队根本没看这行日志。

4.3 事务与异步的组合坑

这个坑得单独拎出来讲,因为它很隐蔽。假设你在一个事务方法里调用了异步方法,异步方法内又读取了刚写的数据,你会发现异步方法里查不到主流程刚提交的数据。

原因很好理解:事务是绑定在调用线程上的。主方法事务没提交之前,异步线程去读数据库,要么读不到(READ_COMMITTED 下),要么读到旧快照(REPEATABLE_READ 下)。你以为事务已提交,其实异步线程启动的时候,主事务可能还在执行中。

更隐蔽的坑在事务注解本身:

@Transactional @Async public void doSomething() { // 这里的事务可能没有生效! }

当一个 @Async 方法在代理层面被调用的时候,事务拦截器和异步拦截器的执行顺序极其容易出错。如果异步拦截器先执行,事务代码就跑在异步线程里,那倒是能生效;但如果事务上下文在异步线程里传播,很多场景会报“No thread-bound request found”或者事务静默失效。我的建议是:异步和事务不要直接叠在同一个方法上。正确的姿势是外层方法写 @Async 调内部事务方法,或者把事务逻辑拆到一个独立方法,让事务切面失效的几率和上面讲的 this 调用一样降低。

真实项目里,我见过一次事故:订单完成后,主流程先调了异步任务去发积分,但积分发放逻辑需要读取订单状态,异步任务读太快,订单事务还没提交,结果发了空积分。排查了半小时,最后把异步调用移到事务提交之后,用TransactionSynchronizationManager.registerSynchronization注册提交后回调,才算解决。这个做法值得记下来。

5. Web 层异步与消息队列的适用边界

方法级异步讲完了,再说说另外两层。很多人分不清“方法异步”和“请求异步”,经常混淆。

5.1 DeferredResult / WebAsyncTask:把 Tomcat 线程让出来

如果你的需求是:接口本身要等到一个耗时的计算结果才能返回响应,但这个等待过程很长,比如两秒甚至十秒,那 @Async 不适合直接把整个接口甩给后台线程,因为用户还在等着,你需要的是“让出 Tomcat 线程,等结果好了再异步写回响应”。

DeferredResult 的思路是:请求线程进来之后,立即把 DeferredResult 对象返回给容器,容器释放线程。另一个线程执行长任务,完成后调用 deferredResult.setResult(),容器再把结果写回客户端。

下面是一个示例代码框架:

@RestController public class LongTaskController { private final DeferredResultQueue queue = new DeferredResultQueue(); @PostMapping("/submit") public DeferredResult<String> submit(@RequestBody String taskParam) { DeferredResult<String> deferredResult = new DeferredResult<>(5000L); // 放进队列,由后台线程处理 queue.add(deferredResult); // 模拟把任务提交给其他地方执行 taskExecutor.execute(() -> { try { Thread.sleep(2000); deferredResult.setResult("处理完成: " + taskParam); } catch (Exception e) { deferredResult.setErrorResult(e); } }); return deferredResult; } }

这种做法常见的应用场景包括:第三方回调通知、长轮询、异步文件处理进度查询、服务器推送等。本质上是为了在高并发长耗时场景下,保住 Tomcat 的线程不被占死。

但我要泼一盆冷水:如果你只是想让接口响应变快,而不是真的要把可控线程归还给容器,那不要用这个。DeferredResult 会让代码复杂度上一个台阶,超时、异常、连接断开都要做处理,得不偿失。

5.2 什么时候该升级到消息队列

@Async 本质上是进程内线程池调度。如果你有以下几个信号,就该考虑升级到消息队列了:

  • 任务需要可靠投递,一旦线程池重启、服务宕机,任务就丢了,业务不可接受。
  • 任务执行时间特别长,而且上游根本不关心结果,更适合削峰填谷。
  • 任务需要多个服务各自处理,也就是发布订阅模型。
  • 需要重试、延迟消费、死信队列等复杂特性。

我自己判断的口径很简单:先问一句,这任务如果真的丢了,老板会不会骂你?如果会,就上 MQ;如果只是发个欢迎语、记录个日志,丢了也无伤大雅,@Async 就够了。不要过度设计,微服务和中间件都是有运维成本的。

6. 实测与监控:线上出问题怎么排查

写完了配置,跑起来了,测试通了,不代表上线就稳了。异步任务最难查的点在于:你不知道任务到底在哪个环节卡住了。所以从项目一开始,就要把观测能力埋进去。

6.1 自建一套异步任务的全链路观测

第一步,线程池必须设置线程名前缀,这一步看起来无所谓,排查问题的时候是救命稻草。上面配置里的async-order-,一看到日志里这个名字,你就知道这个日志属于异步任务。

第二步,给任务加 traceId 或者业务唯一标识。异步方法最好带上业务参数,日志也一定要把参数打出来。比如“发送短信 task start orderId=xxx”和“发送短信 task end orderId=xxx”,这样处理到一半的时候就能定位到具体是哪个任务卡了。

第三步,用 Actuator 或者监控中间件盯线程池指标。下面这个配置可以暴露线程池指标:

management: endpoints: web: exposure: include: health,info,metrics

再把自定义线程池注入到 Metrics,可以监控 active 线程数、queue 大小、completed 任务数。我给过自己的一个粗标准,持续观测下来如果队列积压超过容量的 70%,就要告警。如果活跃线程长期贴着最大线程数,代表线程池设置不合理或下游处理太慢。

6.2 常见问题速查表

现象可能原因解决方案
@Async 不生效,还是同步执行Spring 容器代理没走,this 调用拆到独立 Bean,外部注入调用
异步方法里没日志默认异常处理器吞了日志实现 AsyncUncaughtExceptionHandler
主流程事务没提交,异步就读数据两个线程事务隔离使用注册事务提交后的回调来触发
线程池任务堆积,接口正常但通知很慢队列过大,执行线程被长任务占满调小队列,调大核心线程数
服务重启后异步任务全部消失@Async 是内存线程池,不持久化换 MQ,或落库做补偿
一个异步任务抛异常,主流程感知不到void 方法的异常天生不外抛自行 catch,或方法签名加返回值

上面这些问题,我在各个项目里基本都见过一轮。排查的思路万变不离其宗:先确定是“没进线程池”还是“进了线程池但执行慢”,再往下查。前者看代理是否生效、配置是否加载;后者看队列、活跃线程数、下游服务耗时。

最后再分享两个我自己的偏好。第一,我习惯在异步方法上显式指定线程池名字,不依赖全局默认,这样多个业务可以各自隔离,互不影响:

@Async("customAsyncExecutor") public void sendSmsAsync(String orderId) { // ... }

第二,异步任务的代码里,我会尽量不让它去触碰主线程的 ThreadLocal 上下文,尤其是用户信息、token 这类数据。跨线程传递这些值要额外用 TransmittableThreadLocal 之类的方案,不然容易出现后台任务拿着空身份去调下游接口,被告警搞到崩溃。这个坑等你要做的时候自然能体会到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询