☰
Spring Boot异步调用实战:@Async、线程池与CompletableFuture详解
2026/10/22 11:50:52 网站建设 项目流程

Spring Boot实现异步调用的方法,我翻了下自己这几个月的项目笔记,发现这块内容踩的坑、填的坑还真不少。很多人一提到异步,第一反应就是new Thread或者搞个消息队列,但Spring Boot其实内置了一套非常完整的异步调用方案,只是要真正用好、用对,中间有不少细节值得好好捋一捋。这篇文章我想把自己在实际项目里用Spring Boot做异步调用的经验完整写出来,从最基础的@Async注解到底层线程池原理,从简单无返回值的异步到带返回结果的CompletableFuture,最后再聊聊那些官方文档里不太会写的坑和排查思路。

我这个项目当时的情况比较典型:一个是报表导出功能,导出一份几万条数据的Excel要七八秒,用户在页面转圈等得很崩溃;另一个是短信和邮件通知,用户提交订单后要触发一系列通知动作,如果同步串行执行,下单接口的响应时间会被拖到三四百毫秒。这两个场景合在一起,基本就是异步调用的标准教科书案例。如果你也遇到过类似的问题——接口响应慢、耗时的非核心逻辑拖垮主流程、并发一高线程就失控——那么这篇文章应该能帮到你。

1. 方案选型与核心思路

先说结论:Spring Boot里面做异步调用,最主流、最省事的方案就是基于@Async注解这套体系。它不是Spring Boot新引入的东西,而是Spring Framework早就有的能力,Spring Boot只是帮你把一些自动配置做掉了。除此之外还有WebAsyncTask、WebFlux、消息队列等方案,但不同方案解决的是不同层面的事情,选错方案会让代码复杂度直线上升。

1.1 为什么优先选@Async而不是手动new线程

我见过不少刚接触异步的同学,第一反应是写一个new Thread(() -> { ... }).start(),简单直接。但这种做法在真实项目里会带来几个麻烦:

第一,线程生命周期完全失控。每来一个请求就new一个线程,不会有复用,线程创建和销毁都是有开销的,高并发下线程数量会暴涨,最终把内存和CPU耗尽,严重时直接拖垮整个应用。

第二,线程管理散落在业务代码里。如果项目里有七八处异步调用,每一处都自己new线程,线程的命名、优先级、异常处理完全没法统一,线上排查问题的时候日志乱成一锅粥。

第三,和Spring的生态没有打通。Spring的事务管理、依赖注入、AOP这些能力在手动创建的线程里不一定生效,特别是事务,你在异步线程里操作数据库,很容易出现“以为有事务实际上没事务”的情况。

而Spring的@Async之所以值得选,核心就是它帮你把线程生命周期管理这件事收敛到了一个统一的线程池上。开发者只需要在方法上标注注解,Spring就会用代理机制把方法调用转交给线程池执行,业务代码里不用关心线程怎么创建、怎么复用、怎么销毁。这就好比你不需要自己写定时器,直接用Spring的@Scheduled一样,框架层面的东西交给框架去管。

1.2 同步调用和异步调用的本质区别

要理解异步调用的价值,得先分清楚同步和异步在Spring里的执行路径。

同步调用的流程是一条直线:调用方发出请求之后,线程进入目标方法执行,执行完拿到结果返回,调用方才能继续往下走。如果目标方法内部有个耗时的数据库查询或者说远程HTTP调用,调用方的线程就阻塞在那里等待。

异步调用则是把这条线劈成两段:调用方把任务丢给线程池之后,自己的线程立刻返回,继续执行下面的代码或者直接给用户返回响应。被丢出去的任务在线程池里排队执行,两部分互不干扰。

这里有个关键点:异步不是“更快”,而是“不等”。任务本身花的时间并没有减少,但调用方不再干等,所以整体表现为接口响应时间降低、系统吞吐量提升。

我那个报表导出的例子就非常直观。同步执行时,接口要等Excel文件完全生成完才返回,前端一直转圈。改成异步之后,接口立刻返回一个“任务已接收”的标识,后台线程慢慢生成文件,生成好了再通过下载链接或者推送通知告诉用户。用户体感从“等8秒”变成了“1秒内收到响应”,后台任务该花多少时间还是花多少时间。

1.3 什么场景适合用@Async

不是所有场景都适合上异步。根据我这个项目的经验,适合用@Async的场景基本有这几个特征:

  • 任务本身耗时较长,但主流程并不依赖它的执行结果。
  • 任务执行失败不影响核心业务流程的正常返回。
  • 任务之间没有严格的先后依赖关系,或者说依赖关系可以用Future机制解决。
  • 任务量可控,不会瞬间产生海量任务导致线程池被打爆。

典型的例子包括:发送通知短信邮件、写操作日志、清理临时文件、调用第三方接口、报表导出、数据同步、缓存预热等。

反过来,不适合用@Async的场景也很明确:如果主流程必须拿到执行结果才能继续,那就不适合无脑异步。这种场景要么用Future的get方法去等待结果,但那本质上又回到了同步阻塞,要么就考虑用CompletableFuture做真正的异步编排,后面我会详细讲到。

2. 基础配置与线程池细节

知道了怎么选型还不够,真正要用好@Async,线程池这一层必须花心思。Spring Boot虽然会给你一个默认的线程池,但那个默认配置在真实项目里基本上是不够用的,我强烈建议自己定义一个专用的线程池。

2.1 开启异步支持

用@Async的第一步,是要在启动类或者某个配置类上加@EnableAsync注解。这个注解的作用是开启Spring的异步处理能力,让它去扫描并处理@Async标注的方法,同时让相关的代理机制生效。

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

还有一个容易被忽略的参数——proxyTargetClass。@EnableAsync默认使用JDK动态代理,如果你的异步方法所在的类不是接口实现,而是普通的类,那么需要设置proxyTargetClass = true来强制使用CGLIB代理。否则在某些情况下,Spring会因为没有接口而无法创建代理对象,启动的时候不会报错,但运行期调用异步方法时会发现压根没走异步逻辑。

@EnableAsync(proxyTargetClass = true)

这个坑我在早期项目里踩过,当时有个Service类没有实现任何接口,直接在方法上加了@Async,结果方法同步执行了,接口响应时间一点没降。查了半天才发现是代理方式的问题。

2.2 自定义线程池的必要性

Spring Boot在没有配置任何线程池的情况下,会使用SimpleAsyncTaskExecutor注册一个默认的异步执行器。这个执行器有个非常坑的特点:它不是真正意义上的线程池,而是每来一个任务就新开一个线程,而且默认不限制并发数。在高并发场景下,线程数会无限膨胀,最终导致OOM或者频繁的上下文切换。

所以不要偷懒,一定要自己定义线程池。我这边的做法是,专门写一个AsyncConfig配置类,用ThreadPoolTaskExecutor来创建线程池,并匹配到@Async注解上。

@Configuration public class AsyncConfig { @Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(10); // 最大线程数 executor.setMaxPoolSize(20); // 队列容量 executor.setQueueCapacity(200); // 线程名称前缀,方便日志排查 executor.setThreadNamePrefix("async-task-"); // 拒绝策略 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }

2.3 核心参数怎么定才合理

线程池的这几个参数,每个都有讲究,不能随手抄个数字填上去。

核心线程数(corePoolSize):正常情况下保持存活的线程数。这个值的设定和任务的类型强相关。如果是CPU密集型任务,核心线程数建议设为CPU核心数加一;如果是IO密集型任务,比如调用远程接口、读写文件、查数据库这类,核心线程数可以设大一点,建议是CPU核心数的两倍左右。

最大线程数(maxPoolSize):当队列满了之后,允许额外创建的线程数上限。这个值不能设太大,否则在突发流量下,线程数猛增会带来巨大的上下文切换开销。我一般控制在核心线程数的1.5到2倍之间。

队列容量(queueCapacity):这是一个很容易被误解的参数。很多人以为队列越大越好,其实队列越大,任务排队等待的时间越长,响应越慢。如果追求低延迟,队列设小一点;如果希望吞吐量高、能承接突发流量,队列可以适当调大。

这里有个关键的执行流程,很多人搞混了:线程池的运行逻辑是这样的——先创建核心线程处理任务,核心线程不够了,新任务进队列排队,队列满了,才创建额外的非核心线程,非核心线程也满了,才触发拒绝策略。所以我那个配置里,核心10、队列200、最大20,意味着在任务量小的时候只有10个线程在工作,队列慢慢积压,积压到200个任务还没处理完,才会扩容到20个线程。

这个参数配好之后,不是一劳永逸的。建议线上压测一下,根据QPS、任务耗时、队列积压情况动态调整。我一般是先观察两周,看一下队列长度和线程活跃度,再决定要不要调参数。

3. 实际实现步骤与编码详解

配置写完之后,就到了真正的编码阶段。这里我把异步调用的几种常见形态都写一遍,从最简单的无返回值,到带返回值,再到并行编排,每种形态都有对应的适用场景。

3.1 最简单的无返回值异步任务

对于发短信、写日志这种不需要返回结果的任务,直接在方法上标注@Async即可。

@Service public class NotificationService { @Async("taskExecutor") public void sendSms(String mobile, String content) { // 模拟短信发送耗时 try { Thread.sleep(500); System.out.println("短信已发送: " + mobile); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }

调用方代码如下:

@RestController public class OrderController { private final NotificationService notificationService; public OrderController(NotificationService notificationService) { this.notificationService = notificationService; } @PostMapping("/order") public String createOrder() { // 核心业务逻辑 System.out.println("订单创建成功,主线程: " + Thread.currentThread().getName()); // 异步发送短信 notificationService.sendSms("13800000000", "您的订单已创建"); System.out.println("接口返回,主线程继续执行"); return "订单创建成功"; } }

运行之后观察日志,你会看到主线程名是类似http-nio-8080-exec-1,而异步方法的线程名是async-task-1,这就说明调用确实被转到线程池里执行了。

这里有个很重要的细节:@Async注解加在方法上之后,Spring会通过AOP代理来拦截这个方法调用。也就是说,方法必须是public的,并且不能是static。如果方法是private或者包私有的,注解会被静默忽略,方法还是同步执行。这个问题隐蔽性很强,代码不会报错,但不生效,排查起来很费时间。

3.2 带返回值的异步任务:Future与CompletableFuture

有些任务虽然不是核心流程,但最终还是要拿到执行结果,比如异步生成报表之后,需要拿到生成的报表ID;异步同步数据之后,需要拿到同步的条数。

这时候可以用Future来接收异步执行的结果:

@Service public class ReportService { @Async("taskExecutor") public Future<String> generateReport(String reportId) { // 模拟长时间生成报表 try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new AsyncResult<>("report-" + reportId + "-done"); } }

调用方可以调用future.get()来阻塞等待结果,也可以循环调用isDone()做轮询。前者又变成了同步等待,后者代码写起来也比较笨拙。所以,当异步任务之间有依赖关系、需要并行编排的时候,我推荐直接用CompletableFuture。

CompletableFuture是Java 8引入的异步编程利器,它和@Async搭配起来能干很多事。Spring的@Async方法可以直接返回CompletableFuture,Spring会帮你把任务丢到线程池执行,同时返回一个可以自行编排的CompletableFuture对象。

@Service public class DataAggregationService { @Async("taskExecutor") public CompletableFuture<String> fetchUserData() { // 模拟耗时操作 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("用户数据"); } @Async("taskExecutor") public CompletableFuture<String> fetchOrderData() { try { Thread.sleep(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("订单数据"); } }

调用方可以并行发起两个任务,然后用thenCombine把两个结果组合起来,整体等待时间从2.5秒压缩到1.5秒左右:

@GetMapping("/aggregate") public String aggregate() throws ExecutionException, InterruptedException { CompletableFuture<String> userFuture = dataAggregationService.fetchUserData(); CompletableFuture<String> orderFuture = dataAggregationService.fetchOrderData(); CompletableFuture<String> combined = userFuture.thenCombine(orderFuture, (userData, orderData) -> userData + " + " + orderData ); return combined.get(); }

完成一个实际能达到减少整体响应时间效果的并行编排,用CompletableFuture是最直观的方式,比你自己写CountDownLatch要省事得多。而且CompletableFuture的API覆盖了串行、并行、聚合、异常恢复等各种模式,基本可以满足日常开发里九成以上的异步编排需求。

3.3 异步方法调用注意事项

如果不在同一个类里调用异步方法,情况会简单很多,但一旦涉及类内部调用,就有一个超级经典的坑:自调用问题。

Spring的异步是靠代理实现的,当你在一个类的方法A里调用同一个类的异步方法B时,实际上是通过this直接调用的,并没有经过Spring生成的代理对象,所以@Async注解不会生效,方法B会同步执行。

解决这个问题有几种思路:

第一,把异步方法拆到另一个Bean里面。这是最简单直接的方式,让异步方法所在的类单独作为Spring的Bean,调用方注入这个Bean再来调用。

第二,注入自身代理。通过@Autowired注入自己,然后用注入的实例来调用异步方法。

第三,使用AopContext.currentProxy(),但前提是配置了@EnableAsync(proxyTargetClass = true)并且开启了exposeProxy属性。

我在项目里最常用的是第一种,把异步的细粒度操作拆到一个独立的Service里,既保持了代码清晰,又规避了自调用问题,一举两得。

另外还要注意,在异步方法里如果使用了@Transactional,需要想清楚事务边界。因为事务和异步都是基于代理实现的,方法被异步丢到线程池之后,事务的传播行为和作用范围可能和同步情况下完全不一样。特别是你从外部调用一个带有@Transactional的异步方法时,事务是在异步线程里开启的,如果异步线程里抛了异常,事务回滚是没问题的;但如果你在调用方又包了一层事务,两边的事务并不共享同一个上下文,这一点一定要格外小心。

4. 异常处理与可观测性建设

异步任务跑在独立的线程池里,出现异常的时候不会像同步调用那样直接抛到调用方堆栈里,也不会被全局异常处理器捕获。这就导致一个问题:异步任务挂了,你往往毫不知情。所以异常处理和可观测性,是异步调用里绝对不能省的一环。

4.1 异常处理器的配置

对于无返回值的异步方法,异常默认会被吞掉,日志里基本看不到。Spring提供了AsyncUncaughtExceptionHandler接口,你可以自定义这个处理器来捕获异步方法抛出的异常。

@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setThreadNamePrefix("async-task-"); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, obj) -> { System.out.println("异步方法执行异常: " + method.getName()); System.out.println("异常信息: " + throwable.getMessage()); throwable.printStackTrace(); }; } }

这样可以做到所有无返回值的异步方法,一旦抛出未捕获异常,就统一走这里记录日志。如果你的项目里接了告警系统,在这里把异常上报出来,异步任务的可靠性就能大幅提升。

4.2 用日志追踪异步任务的完整链路

异步任务的问题在于,主线程返回之后,后续操作都在别的线程里发生,日志分散在不同的线程名和时间点。如果你只看普通的日志输出,很难把一个请求从主线程到异步任务的调用链串起来。

我的做法是在发起异步调用之前,生成一个链路ID,把它塞进上下文里,然后在异步方法里面捞出来打日志。

public class AsyncContextUtil { private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>(); public static void setTraceId(String traceId) { TRACE_ID.set(traceId); } public static String getTraceId() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }

有一个很关键的点:ThreadLocal在线程池场景下根本传不过去,因为任务被丢到另一个线程执行,线程上下文是独立的。要想把链路ID传递到异步线程,需要自己实现TaskDecorator来做上下文传递。

executor.setTaskDecorator(runnable -> { String traceId = AsyncContextUtil.getTraceId(); return () -> { try { AsyncContextUtil.setTraceId(traceId); runnable.run(); } finally { AsyncContextUtil.clear(); } }; });

TaskDecorator的作用就是在线程池执行任务之前,先把上下文信息从提交线程传递到执行线程,这样就能在日志里用同一个链路ID把主流程和异步流程串起来了。日志系统本身如果支持MDC,也可以用类似的方式处理,思路完全一致。链路ID是我在做异步调用的可观测性时最值得投入的一件事,它能让排查问题的效率翻倍。

4.3 线程池监控

线程池的参数不是配完就完事的,运行期监控同样重要。我自己在线上环境主要通过几个指标来观察线程池健康度:

  • 活跃线程数:线程池当前存活且正在处理任务的线程数。
  • 队列大小:排队等待执行的任务数。这个数值如果持续增长,说明任务处理速度跟不上提交速度。
  • 拒绝任务数:触发拒绝策略的任务数量。如果这个值大于零,说明线程池已经扛不住了,需要扩容或者优化任务。

ThreadPoolTaskExecutor提供了getActiveCount()、getQueue()、getThreadPoolExecutor()等方法拿到原生线程池,然后可以做定时采集,接入监控系统。

另外一个很实用的技巧是在线程池里通过setWaitForTasksToCompleteOnShutdown(true)设置优雅关闭,同时设置setAwaitTerminationSeconds(60)来给正在执行的任务一个缓冲时间。否则应用重启的时候,线程池里的任务可能会被强制中断,导致数据丢失。

5. 常见问题与排查技巧实录

这一章我整理了自己项目中真实踩过、以及帮别人排查过的一批高频问题,每个问题都有对应的症状、排查思路和解决方式,你可以直接当作速查表来用。

5.1 异步注解不生效,方法还是同步执行

这是我最常被问到的问题。症状很明确:方法上加了@Async,但接口响应时间没有变化,日志里线程名也是同一个。

排查顺序:

  1. 检查启动类或者配置类有没有加@EnableAsync。漏掉这个注解是最常见的原因。
  2. 检查异步方法是不是private、static或者final的。这两种修饰符都会让Spring的代理机制失效。
  3. 检查是不是自调用问题。同一个类内部方法调用,绕过代理。解决办法是把异步方法拆分到另一个Bean。
  4. 检查代理方式。如果类没有实现接口,需要@EnableAsync(proxyTargetClass = true)。
  5. 检查Spring是否真的把你自定义的线程池绑定到@Async上了。如果在@Async注解里指定了线程池名称,比如@Async("taskExecutor"),那么配置类里的Bean名称必须一致。

5.2 线程池拒绝任务

如果你的队列和最大线程数都配置得比较小,而提交的任务量突然猛增,就会触发拒绝策略。默认的拒绝策略是AbortPolicy,它会直接抛出TaskRejectedException,这个异常会直接抛给调用方。如果你没处理,接口就会报错。

我选择的是CallerRunsPolicy,它不会抛异常,而是让提交任务的线程自己执行这个任务。这种策略的优点是保证任务不被丢弃,缺点是你的主线程可能会被这个任务拖慢。对于要求不丢失任务的场景,这个策略很合适。

排查线程池拒绝问题的办法很简单:给RejectedExecutionHandler加日志,或者直接在拒绝策略里打点告警,这样能第一时间感知到线程池压力。

有一种更容易被忽略的情况是,虽然你配了最大线程数为20,但任务提交速度太快,队列瞬间填满,然后触发扩容,但扩容后的线程处理不过来,依然会被拒绝。这种情况的解决方向不是单纯扩大线程池,而是要评估任务本身的耗时和提交频率,看看有没有优化空间,比如把不需要实时处理的任务丢到消息队列里慢慢消费。

5.3 Future.get()导致异步变成同步

这不是一个bug,而是使用方式的问题。很多人在异步方法返回Future之后,紧接着就调用future.get()去拿结果,这样主线程照样会阻塞,异步等于白做。

如果确实需要等待结果,并且主线程不能被长时间阻塞,我的建议是:

  • 超时控制。future.get(3, TimeUnit.SECONDS)加超时时间,避免无限等下去。
  • 异步回调。用whenComplete或者thenAccept把对结果的处理也放在异步线程里,不要让主线程去等。
  • 非阻塞轮询。用isDone判断任务是否完成,完成之后再取结果,但这种方式在循环里会造成CPU空转,不建议大量使用。

5.4 事务在异步方法里失效

这个问题前面提过,但值得单独列出来。异步方法和事务同时使用的时候,事务是开在异步线程里的,和主线程的事务不在同一个事务上下文里。

举个例子:你有一个方法,先插入一条数据到数据库,然后异步调用另一个方法去更新另一张表。如果异步方法里抛出异常,它只会回滚异步方法自身的事务,不会回滚主线程已经插入的数据。

如果你需要让异步操作和主操作处于同一个事务里,那就要重新审视设计了,因为跨线程时不建议用同一个事务。这种情况下,我通常的思路是:主流程只做最核心的数据变更,异步流程去处理那些可以容忍失败或者需要补偿的操作。如果真的需要强一致,那就不该拆成异步,而是应该考虑别的架构方案。

5.5 并发场景下的线程池参数调整

我刚开始配置线程池的时候,参考了很多网上的例子,设了一个自认为合理的参数,结果线上跑了一段时间,发现任务堆积越来越严重,但是线程数始终没有扩容。

后来仔细研究才发现问题:核心线程数设了10,队列200,最大线程20,看起来没问题,但任务的执行时间太长,每个任务平均要2秒左右,以至于10个核心线程已经把CPU资源占满了,新的任务一直在队列里排队,队列永远不为空,非核心线程也就一直没有被创建。

这个现象解释了线程池扩容的重要条件:只有当任务被提交执行、核心线程全忙、队列也满的时候,非核心线程才会创建。如果队列没满,即使核心线程已经忙不过来了,线程池也不会扩容。

调整的方向有两个:一是把核心线程数调大;二是把队列调小,让任务更快触发扩容。但每个方案都有代价,核心线程太多会让空闲线程占资源,队列太小则扛不住突发流量。这个平衡需要结合业务的实际负载慢慢调,不要指望一次到位。

写在最后

Spring Boot的异步调用,表面上看只是一个注解的事,但往深了走,涉及线程池参数调优、代理机制、上下文传递、异常处理、链路追踪这些内容,每一步都有不少细节。做完这个项目之后,我对异步编程的理解也从“会用@Async”提升到了“能设计一套可靠的异步任务体系”。

个人的实践经验是:配一个专用线程池、做好异常兜底、加上链路ID和监控,这三件事只要做扎实了,异步调用在绝大多数业务场景里都不会出大问题。如果你项目的异步场景比较复杂,涉及任务优先级、定时延迟、分布式协调这些,那可能就要考虑更重的方案了,但那是另一个话题。

最后再分享一个小技巧:每次上线前,我都会用压力测试工具模拟一波异步任务的峰值流量,专门看线程池的活跃线程数、队列长度和拒绝次数这三个指标,如果拒绝次数不为零,就重新调参数再测,直到峰值流量下不丢任务为止。这个流程虽然看起来朴素,但帮我挡住了不少线上事故,建议你也试试。

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

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

立即咨询