Spring Boot异步操作实战:@Async、线程池与CompletableFuture避坑指南

发布时间:2026/10/3 23:28:09
Spring Boot异步操作实战:@Async、线程池与CompletableFuture避坑指南
先说一个我实际遇到的线上问题一个报表导出接口原来在方法里同步做“查数据、生成Excel、上传OSS、发邮件通知”用户点一次按钮要等十几秒才看到响应高峰期Tomcat线程被这种长任务占得死死的整个应用都跟着变慢。后来我把非核心步骤拆成Spring Boot异步操作接口从十几秒降到200毫秒返回任务在后台线程池里慢慢跑。那段时间我把Async、CompletableFuture、线程池配置、事务传播、上下文传递这些东西重新梳理了一遍踩了不少坑也攒了不少能直接用的方案。这篇文章就把我实际的Spring Boot异步操作经验整理出来适合已经写过Spring Boot、但对异步机制还停留在“加个Async就行”阶段的开发者。1. 先从一次接口超时的线上事故说起1.1 同步调用为什么把Tomcat线程池压垮先说一下事故的背景。这个导出接口内部有三个步骤查询报表数据大概需要300毫秒用POI生成Excel耗时1到2秒把文件传到OSS并发送邮件通知又需要几百毫秒。单看每一步都不算慢问题是它们串行执行一次请求要占住一个Tomcat工作线程3秒左右。Tomcat默认配置下工作线程有限常见是200个。你可以想象成一个银行网点只有200个柜台每个柜台一次只能服务一个人。一旦某个业务需要长时间占用柜台后面排队的请求就得一直等。线上流量稍微上来一点200个线程被导出接口全部占住其他所有接口都开始排队然后监控里就会出现线程池拒绝、请求超时、CPU突然飙高。当时我把接口改成主线程只负责校验参数、把任务丢进线程池、立刻返回。生成Excel、上传OSS、发邮件这些全部放到Spring Boot异步操作里去执行。改造之后接口耗时从十几秒降到200毫秒左右而真正耗时的操作在后台异步线程池里继续跑用户不需要干等着。这里要理解一个关键点绝大多数业务系统瓶颈不是CPU而是IO等待。查数据库要等网络、生成文件要等磁盘、发邮件要等外部服务这些等待时间里线程其实什么都没干只是干等。异步操作的核心价值就是把“等待时间”和“干活时间”分开让有限的线程去处理更多请求。1.2 Spring Boot里异步操作的三种常见形态很多新手以为Spring Boot异步操作就等于一个Async注解实际项目里异步有三种常见形态我列个表说清楚形态实现方式适用场景典型问题方法级异步Async 线程池单个方法异步执行如发邮件、写日志、推送通知自调用失效、异常丢失、事务失效编程式异步CompletableFuture、ExecutorService多个异步任务编排、汇总、并行调用线程池用错、Future.get阻塞跨进程异步ActiveMQ、RabbitMQ、Kafka 等消息队列系统解耦、削峰填谷、需要重试引入中间件成本需要处理消息幂等同一个业务里这三种形态经常是组合使用的。比如接口先快速返回给前端后台用消息队列通知积分服务积分服务里再用CompletableFuture并行查询多个数据源。没有一种异步方案能包打天下关键是根据场景决定异步的边界到底在哪里。我个人的判断标准有两个第一这个任务是不是必须同步返回给用户不是就异步第二这个任务能不能容忍延迟比如发送通知晚几秒没问题那就丢线程池。如果业务要求非常强的可靠性比如订单创建后的对账数据宁可上消息队列也不要只依赖本地线程池因为进程一旦重启线程池里排队中的任务就全丢了。2. Async注解最顺手也最容易踩坑的入口2.1 三步启用开启注解、配置Executor、标注方法使用Async的第一步是在启动类上加EnableAsyncSpringBootApplication EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }第二步是定义线程池Bean。不建议完全不配置就直接用Async因为Spring Boot在没有找到自定义线程池时会走默认策略后面我会单独说这个坑。先看一个我常用的基础配置Configuration public class AsyncConfig { Bean(bizAsyncExecutor) public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(biz-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }第三步是在异步方法上指定线程池名字Service public class NotificationService { Async(bizAsyncExecutor) public void sendEmail(String userId) { // 真正发邮件的逻辑 } }这样调用方调sendEmail时方法会立刻返回真正的方法体在bizAsyncExecutor这个线程池里执行。注意线程名的前缀排查问题时看日志线程名就知道任务是不是真的跑在异步线程里了。2.2 同一个类里的自调用会让异步失效这是Async最经典的坑。看这段代码Service public class OrderService { Async(bizAsyncExecutor) public void asyncNotify() { // 发通知 } public void createOrder() { asyncNotify(); // 同类中直接调用 } }createOrder()执行时asyncNotify()会异步执行吗不会。因为Async是基于Spring AOP代理实现的只有外部调用这个Bean的方法时调用才会被代理拦截然后把方法提交到线程池。而上面这种写法是this.asyncNotify()属于对象内部直接调用根本没有经过代理对象所以结果是同步执行耗时操作照样卡住主线程。我自己排查这类问题时的经验是看代理对象。Spring Boot默认使用CGLIB代理如果你的类是final的或者方法被final修饰CGLIB代理会有问题。但自调用失效和CGLIB还是JDK动态代理没关系只要是同类内部方法调用通通绕过了代理。解决办法有三个把异步方法拆到独立的Service类里让外部调用注入自身代理对象例如Autowired private OrderService self;然后通过self.asyncNotify()调用使用ApplicationContext.getBean(OrderService.class).asyncNotify()不推荐但排查时能应急。建议首选第一种把异步逻辑单独放到一个类里职责清晰也方便测试。2.3 返回值只能选void或者Future/CompletableFuture有人会写这样的代码Async(bizAsyncExecutor) public User getUser(Long id) { // 耗时查询 return userMapper.findById(id); }然后发现调用方拿到的是null。为什么因为代理会把方法提交到线程池然后立即返回方法原来的返回值根本来不及产生。对于Async方法返回值只支持void、Future、CompletableFuture、ListenableFuture等类型不能直接返回普通对象。正确的写法是返回CompletableFutureAsync(bizAsyncExecutor) public CompletableFutureUser getUserAsync(Long id) { User user userMapper.findById(id); return CompletableFuture.completedFuture(user); }调用方再通过future.get()或者thenApply拿结果。这里还有个隐含建议如果主线程只是拿到future后立刻get()那异步就白做了因为get()会阻塞。真正合理的用法是交给CompletableFuture继续编排回调这个我在第4章展开。2.4 异步方法的异常不能扔给调用方同步方法如果抛出异常调用方可以用try-catch捕获。但Async方法不一样方法体在线程池线程里执行主线程早就返回了根本没有捕获异常的机会。如果你在异步方法里不做任何处理异常会被吞掉线上就出现“发了邮件但没发出去、日志里还没有任何异常”的诡异现象。处理异步异常我一般做两层第一层方法内部自己try-catch记录完整业务日志并把失败任务写入重试表。这是业务兜底。第二层实现AsyncUncaughtExceptionHandler兜住那些没处理好的异常Configuration public class AsyncExceptionConfig implements AsyncConfigurer { Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) - { log.error(async method error, method: {}, params: {}, method.getName(), params, throwable); // 发告警、写重试表 }; } }注意返回值是CompletableFuture的异步方法异常不会走到AsyncUncaughtExceptionHandler而是会包装在future里需要通过exceptionally处理。所以说回来异步方法一定要清楚自己怎么处理异常不能指望调用方。3. 线程池配置异步不配Executor等于白做3.1 SimpleAsyncTaskExecutor到底有多不靠谱如果不自定义线程池Spring Boot默认情况下会用SimpleAsyncTaskExecutor来执行Async方法。这个名字看起来人畜无害实际上它每次执行一个任务都会创建一个新线程没有线程复用也没有队列。简单说你的任务越多它给你new的线程越多不设上限。看到这里你应该能想到后果高并发下线程数飙升几万个线程同时跑CPU上下文切换爆炸内存也跟着被吃光最后应用直接宕机。我见过不止一个项目代码里加了EnableAsync和Async但没配线程池压测一上来就挂去掉异步反而没事根因就是SimpleAsyncTaskExecutor。所以使用Async的第一步不是写注解而是先写ThreadPoolTaskExecutor配置。Spring Boot里的ThreadPoolTaskExecutor是对java.util.concurrent.ThreadPoolExecutor的封装适合Spring环境直接注入。3.2 核心线程、最大线程、队列怎么设线程池参数没有标准答案但有决策依据。先看一个相对完整的配置Bean(exportTaskExecutor) public ThreadPoolTaskExecutor exportTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(500); executor.setKeepAliveSeconds(120); executor.setThreadNamePrefix(export-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这里的参数含义corePoolSize核心线程数即使空闲也保留的线程数量。maxPoolSize线程池允许的最大线程数。queueCapacity等待队列容量。keepAliveSeconds非核心线程空闲多久后回收。waitForTasksToCompleteOnShutdown应用关闭时等待任务执行完线上必须开。awaitTerminationSeconds最多等多少秒。rejectedExecutionHandler队列满且线程数达到最大值后新任务怎么处理。线程池的工作逻辑很多人搞反了不是核心线程满了就立刻开新线程而是任务量超过corePoolSize后先放进队列队列也满了才会继续创建线程直到maxPoolSize连maxPoolSize的线程都在忙才会触发拒绝策略。那参数怎么定我通常这样判断IO密集型任务比如读写文件、调用外部接口、访问数据库线程数可以大一点一般取CPU核心数乘以2到4因为线程大部分时间在等待。CPU密集型任务比如复杂的计算、加密解密线程数不宜超过CPU核心数否则抢CPU反而更慢。队列容量要结合任务积压容忍度。例如核心线程4个一个任务耗时1秒队列500意味着最多能积压500秒的工作量。如果你是接口异步写日志500队列没问题如果是用户触发的重要操作队列太大意味着失败感知越慢。不建议照抄某个固定的“核心4最大8队列500”要用压测数据去调整。我一般会在测试环境用JMeter模拟真实流量同时监控线程池活跃数、队列堆积数再来调参数。3.3 多个业务不要共用一个线程池项目里常见的错误是定义了一个Executor所有Async方法都往里面丢。邮件服务堵了会影响报表导出报表导出爆了影响短信通知。不同业务的优先级、响应时间要求、任务量都不同挤在一个池子里互相拖累。我的做法是按业务拆分线程池线程池Bean场景建议参数参考logExecutor异步写日志、埋点核心2最大4队列大notifyExecutor发邮件、短信、推送核心4最大8队列中等reportExecutor报表生成、大数据导出核心2最大4队列500CallerRunsmqExecutor消息消费后的异步处理结合并发消费数和下游能力拆开之后还有一个好处每个线程池的监控指标相互独立看监控时一眼就知道是哪个业务堵住了。3.4 优雅关闭和监控不能省很多人在本地开发时没感觉一到发版就遇到奇怪问题比如任务只执行了一半应用就退出了。原因是JVM关闭时线程池里的线程直接被终止队列里的任务全部丢失。Spring Boot中ThreadPoolTaskExecutor本身会执行shutdown但你最好明确设置上面提到的两个参数executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60);意思是应用关闭时先等已有任务跑完最多等60秒超时再强制结束。这样可以避免发版重启把正在跑的异步任务掐断。监控方面如果你用了Spring Boot Actuator可以自定义一个指标来观察线程池状态Bean public MeterBinder threadPoolMetrics(ThreadPoolTaskExecutor executor) { return registry - Gauge.builder(thread.pool.active, executor::getActiveCount) .register(registry); }哪怕暂时不上监控也建议在日志里定期打印线程池活跃数、队列剩余容量、已完成任务数。不然线程池满了你都不知道只会发现请求越来越慢。4. 从Future到CompletableFuture让异步任务真正编排起来4.1 Future.get()的阻塞会把异步拉回同步Java原生的Future接口很早就支持异步结果获取但有个天然问题get()方法是阻塞的。你调用future.get()时当前线程会一直等到结果返回。如果异步任务耗时3秒主线程在get()那里等于还是等了3秒异步的意义就打折扣了。看个例子FutureReport reportFuture reportService.generateAsync(); Report report reportFuture.get(); // 这里会阻塞直到异步任务完成所以我的经验是Future只适合那种“你先做点别的事最后再来拿结果”的场景比如同时发起两个独立查询先提交第一个再提交第二个最后分别get()。如果你提交完立即get()那和同步调用没什么两样。4.2 CompletableFuture怎么编排多个异步任务真正好用的是CompletableFuture它最大的价值不是拿结果而是编排异步任务任务完成后自动触发下一个动作不需要主线程傻等。举个真实场景一个用户详情接口需要同时查询用户基本信息、订单列表、优惠券数量三个数据源相互独立。用同步写法耗时是三者之和用CompletableFuture并行写耗时是三者中最大的那个。public UserDetailVO getUserDetail(Long userId) { CompletableFutureUser userFuture CompletableFuture.supplyAsync( () - userService.getUser(userId), reportTaskExecutor); CompletableFutureListOrder orderFuture CompletableFuture.supplyAsync( () - orderService.getOrders(userId), reportTaskExecutor); CompletableFutureInteger couponFuture CompletableFuture.supplyAsync( () - couponService.getCouponCount(userId), reportTaskExecutor); CompletableFutureVoid all CompletableFuture.allOf(userFuture, orderFuture, couponFuture); all.join(); return new UserDetailVO(userFuture.join(), orderFuture.join(), couponFuture.join()); }这里用allOf等待所有任务完成再用join取出结果。注意了join和get的区别在于join不抛受检异常在业务代码里更清爽。如果任务之间有依赖关系比如先查用户再用用户ID查订单可以用thenApply把两个CompletableFuture串起来CompletableFuture.supplyAsync(() - userService.getUser(userId), executor) .thenApply(user - orderService.getOrders(user.getId())) .thenAccept(orders - sendToClient(orders));这种写法读起来像流水线主线程完全不阻塞。还有thenCombine可以合并两个独立的结果exceptionally可以处理单个任务的异常handle则可以同时处理结果和异常。在实际项目里我建议把每个异步任务拆成方法级别的CompletableFuture.supplyAsync不要嵌套太深不然可读性会很差。还有一个坑必须提CompletableFuture如果不指定线程池默认用ForkJoinPool.commonPool()。这个是JVM全局共享的线程池如果任务里有阻塞操作比如调用远程HTTP接口是会拖累其他使用commonPool的代码的。所以凡是业务里用CompletableFuture我都习惯显式传线程池参数。4.3 虚拟线程时代的新玩法最近Java生态里讨论很多的是虚拟线程Spring Boot 3.2之后也做了支持。相关热词里那个newVirtualThreadPerTaskExecutor就是指用虚拟线程实现的一种Executor每个任务一个线程但线程很轻创建和销毁代价远比平台线程小。Spring Boot开启虚拟线程很简单spring.threads.virtual.enabledtrue这样Spring MVC处理请求的线程、Async的默认执行器等会用虚拟线程。如果你只想在某个线程池里用虚拟线程可以这样注册Bean public AsyncTaskExecutor virtualTaskExecutor() { return new TaskExecutorAdapter( Executors.newVirtualThreadPerTaskExecutor()); }然后Async(virtualTaskExecutor)就可以用。但我不建议大家为了追新而全量切虚拟线程。虚拟线程的价值主要在大量阻塞IO场景比如同时发起几千个HTTP请求、数据库查询。如果是CPU密集计算虚拟线程并不能提高吞吐量反而可能因为调度开销导致性能下降。我目前的做法是老项目不动新项目里把轻量IO型任务优先放到虚拟线程上重量级计算还是走传统线程池。5. 实战异步操作在项目里常见的落地场景5.1 接口快速返回异步写操作日志和流水最典型的用法是核心业务接口里主流程只保留强一致性的操作非核心操作异步化。比如用户下单后要写操作日志、埋点、发券这些不该阻塞下单接口PostMapping(/order) public Result createOrder(RequestBody OrderDTO dto) { Order order orderService.create(dto); // 同步必须成功 auditLogService.saveAsync(dto, order); // 异步写日志 couponService.sendAsync(userId); // 异步发券 return Result.ok(order); }这里有个需要想清楚的边界如果异步发券失败用户感知不到但业务上是否允许我一般会引入“本地任务表”的补偿机制异步方法开始先把任务状态置为PENDING执行成功改为SUCCESS失败记录错误信息并保留重试次数由定时任务扫描重试。也就是说异步不等于放弃可靠性而是把可靠性从同步调用变成异步补偿。5.2 批量通知推送邮件、短信、WebSocket发送大量通知是异步的经典场景。假设后台要发1万封邮件同步for循环逐封发一个线程要跑很久如果改用线程池分批并行速度会快很多。但要注意邮件服务商一般有限流全量并发很可能触发限流导致大量失败。我建议用“线程池 限流”组合。比如每批50封每批之间加一个小延迟public void sendBatch(ListString emails, String content) { ListListString partitions Lists.partition(emails, 50); for (ListString partition : partitions) { CompletableFuture.runAsync(() - sendPartition(partition), notifyTaskExecutor); } }如果对发送顺序有要求或者不想突然打满下游服务还可以在消息投递前用Semaphore限流。类似地WebSocket推送大量在线用户时也要控制并发避免同时推送导致服务端连接打满。5.3 定时任务与异步让报表生成不拖垮调度线程Spring的Scheduled默认是同步执行的。比如一个定时任务每5分钟跑一次但任务本身执行耗时10分钟我这边的经验是默认同一个定时任务不会并行执行下一次但由于调度线程被占住了其他定时任务也可能受影响。推荐做法是Scheduled方法体里只做“提交任务”真正耗时逻辑放到Async线程池Scheduled(cron 0 */5 * * * ?) public void triggerReport() { CompletableFuture.runAsync(() - reportService.generateDailyReport(), reportTaskExecutor); }如果报表生成任务不能并行跑还需要在任务内部加锁比如基于任务名的分布式锁防止定时触发重叠。5.4 与消息队列整合从本地异步升级为分布式异步本地线程池异步有一个天然缺陷进程宕机或重启队列里未执行的任务全部丢失多个实例部署时每个实例各自为政任务没有统一负载均衡。当业务要求更高的可靠性时就要把异步升级成消息队列比如ActiveMQ、RabbitMQ、Kafka。很多项目已经通过Spring Boot整合ActiveMQ或RabbitMQ。消息队列的消费端本身就是异步的生产者把消息发到队列消费者通过JmsListener或RabbitListener异步接收并处理。这样做的好处是削峰填谷、失败重试、多实例水平扩展。我的选择建议单机、任务轻、允许少量丢失选本地线程池异步涉及资金、对账、核心通知选消息队列。最好不要一开始就给所有异步逻辑都上MQ中间件会带来额外运维成本消息重复消费、顺序消费也都是新的坑。6. 避坑清单与排查思路6.1 异步方法没执行的常见原因排查异步方法不执行、或者看起来像同步执行这类问题我排查过太多次给你一条完整的排查链路第一步看启动类上有没有EnableAsync。漏了这个注解所有Async都会静默失效而且不会报错。第二步看异步方法是不是被同一个类里其他方法调用。自调用会绕过代理这个前面说过。第三步看异步方法是不是private或者final。Async对private方法无效final方法因为CGLIB无法代理也不会正确生效。第四步看调用方注入的是不是Spring代理对象。如果自己new了一个Service方法根本没有交给Spring管理肯定没有异步。第五步看异步方法是不是返回了普通对象。返回非Future类型时代理无法拿到结果方法返回null。排查时我一般先在异步方法入口打印一行带线程名的日志。如果线程名是自己配置的前缀说明异步生效如果线程名还是Tomcat线程说明走了同步路径。6.2 事务异步Transactional为什么失效这是另一个高频坑。先看代码Async(bizAsyncExecutor) Transactional public void asyncUpdate() { // 更新订单状态 }很多人以为这个异步方法里的事务没问题实际上它和调用方的事务是完全两个事务。原因是Spring事务和异步都是基于代理但事务上下文绑定的是数据库连接而数据库连接保存在当前线程的ThreadLocal中。异步方法从线程池里启动主线程的数据库连接不会传递过来所以事务传播REQUIRED也传播不到异步线程。更严重的场景是这样主方法创建订单并开启事务然后调用异步方法去更新一个字段。主方法因为后面校验失败回滚了但异步方法已经在另一个线程执行并提交了事务数据就出现了不一致。遇到这种需求我建议先问一句真的需要跨线程共享一个事务吗大多数情况下不需要更合理的做法是异步方法自己管自己的事务并在业务上接受“独立提交”的现实如果主流程和异步流程必须保证一起成功那就不要用线程池异步改用本地消息表消息队列通过最终一致性解决如果只是要事务完成后执行某个操作可以用TransactionSynchronizationManager.registerSynchronization在事务提交后再投递异步任务。6.3 上下文传递TraceId、用户信息、ThreadLocal线程池异步会把ThreadLocal里的数据隔离开这一点常常被忽略。比如你在拦截器里往MDC里放了一个TraceId进到异步线程后日志里的TraceId就丢了再比如你用RequestContextHolder保存了当前登录用户异步线程里取出来是null。这里我推荐用TaskDecorator解决。它可以在任务提交到线程池时包装任务执行前把上下文复制到线程池线程执行完再清理Bean(contextAwareExecutor) public ThreadPoolTaskExecutor contextAwareExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 省略常规参数 executor.setTaskDecorator(runnable - { MapString, String context MDC.getCopyOfContextMap(); RequestAttributes requestAttributes RequestContextHolder.getRequestAttributes(); return () - { try { MDC.setContextMap(context); RequestContextHolder.setRequestAttributes(requestAttributes); runnable.run(); } finally { MDC.clear(); RequestContextHolder.resetRequestAttributes(); } }; }); return executor; }要特别注意finally里的清理逻辑。线程池的线程是复用的如果不清理ThreadLocal下一次任务复用这个线程时会拿到上一次的用户信息造成严重的数据串号问题。6.4 测试异步代码的姿势写异步代码最怕测不准。单元测试里调用Async方法方法经常立刻返回断言还没执行完就结束了。我的做法分三步第一把异步方法里的核心业务逻辑抽成一个普通方法单独做单元测试。比如发邮件的模板渲染、报表数据的计算这些可以同步测覆盖面也更好。Async入口只保留“提交线程池”的作用不值得花太多精力测。第二集成测试时用CountDownLatch或者Awaitility等待异步任务真正完成。SpringBootTest class AsyncServiceTest { Autowired private AsyncService asyncService; Test void testAsyncMethod() { CountDownLatch latch new CountDownLatch(1); asyncService.doWork(() - latch.countDown()); latch.await(3, TimeUnit.SECONDS); // 断言异步任务确实执行了 } }第三测试结束时要检查线程池有没有泄漏比如是否还有未执行完的任务队列里是不是堆了一堆任务。如果每次测试都新建线程池记得在AfterEach里调用shutdown。最后再分享一个小习惯我现在每写一个异步方法都会在代码注释里写清楚它用的线程池Bean、超时时间、失败后的重试策略。这个习惯救过我很多次因为异步代码出问题的时候最怕的不是问题本身而是你根本不知道这个任务到底跑在哪个线程、有没有人处理失败。看清线程池边界理清异常处理和补偿机制Spring Boot异步操作才能真正成为提高吞吐的利器而不是埋给未来的雷。