
如果你维护过那种接口一调就是两三秒甚至更久的服务对“异步调用”这四个字一定深有体会。SpringBoot 里实现异步调用的方法并不只是给方法加一个 Async 注解从线程池的配置、CompletableFuture 的编排到事件异步化、消息队列解耦每个方案背后的适用场景和代价差异很大。这篇文章我会从一个真实接口的改造过程出发把 SpringBoot 异步调用的常用姿势、线程池参数怎么定、以及那些文档里不会写的坑一次性聊透。建议正在优化接口耗时的新手通读用过 Async 但被性能问题折腾过的开发者也可以重点看后面两章应该能找到一些共鸣。1. 先搞清楚异步调用到底在解决什么问题1.1 一次接口改造的现场复盘我接手过一个用户下单接口同步串行调用了会员查询、库存扣减、价格计算、短信通知、操作日志五个环节接口平均耗时 1.8 秒。压测时单机 QPS 只有 40 左右很多时间花在等待第三方短信平台和日志落盘上。代码本身没有大毛病纯粹是被同步等待拖住了。后来改造的思路很简单把短信通知和操作日志从主流程里拆出去丢到线程池异步执行库存扣减和价格计算保持同步因为用户必须立刻知道扣减结果。主链路的同步依赖从五个降成三个接口耗时降到 450ms压测 QPS 涨了一倍多。这个例子能说明异步调用最直接的价值它不是在给业务增加复杂度而是把响应路径上的非关键等待移走让请求线程能做更多正事。SpringBoot 的应用模型建立在 Servlet 线程模型之上一个请求占用一个线程线程池容量有限只要同步等待时间长吞吐量就上不去。所以异步在这里是“时间换空间”把会阻塞的耗时不放在请求线程上。1.2 三种最常见的异步场景先看第一类我管它叫“发后即焚型”。发短信、发邮件、站内信、操作日志、审计上报这些任务都不需要立即拿到结果用户也不会为了它多等一秒钟。这类任务直接用线程池执行失败后打一条日志最多加一个重试策略完全不用介入主流程。很多系统里的通知服务就是这么做的先用 SpringBoot 把消息内容构造好再丢到异步线程由第三方通道发出去。第二类是“并行聚合型”。一个商品详情页要同时查商品基础信息、库存、价格、评价如果串行查耗时是四个接口之和并行查耗时约等于最慢的那个接口。这类场景用 CompletableFuture 做编排非常顺手后面我会重点讲。第三类是“流量削峰型”。秒杀、优惠券发放、批量消息推送瞬时流量远大于处理能力需要先把请求写进消息队列再由消费端按自身节奏慢慢处理。这三类场景都能用异步调用的思路解决但用到的技术和设计完全不同。很多人一上来就加 Async没想清楚自己属于哪一类后面遇到线程池耗尽、消息丢弃时就会很被动。我自己的经验是先按业务类型给任务分个类再决定用哪种异步方案这比拿着一个注解到处套重要得多。1.3 也有不适合异步的场景同步强一致的要求最不适合异步。比如用户注册前必须校验手机号是否唯一这种校验结果需要立刻返回异步只会让用户困惑。资金类操作也要非常谨慎转账、扣款即使异步也不能只丢线程池就完事必须有可靠的事务保证、对账机制和补偿方案否则一旦进程崩溃任务丢了很难恢复。还有一类场景依赖返回值比如接口 A 要拿接口 B 的响应来做下一步分支判断强行异步会增加协作复杂度不如保持同步。所以我给自己定的选型原则是非核心、可延迟、允许失败重试的任务可以考虑异步核心链路、强一致、依赖结果才能继续的任务别碰异步。先把“该不该异步”想清楚再谈“怎么实现异步”这一步不能反过来。2. 最基础玩法Async 注解 自定义线程池2.1 为什么 Async 需要先开启开关SpringBoot 的 Async 是基于 AOP 实现的只有被 Spring 容器管理的 Bean 通过代理调用才生效。最常见的误区是在启动类不加 EnableAsync结果方法依然是同步执行而且一点错误都不报看起来像异步成功了一样。开启方式很简单在任意配置类或启动类上加 EnableAsync 就行。SpringBoot 默认使用 CGLIB 代理因为 SpringBoot 动态代理策略里优先选择 CGLIB它不要求目标类实现接口对我们日常写的业务类很友好。如果你之前用过 JDK 动态代理应该知道它要求目标类必须实现接口否则代理生成不了。SpringBoot 2.x 之后默认改成了 CGLIB 优先所以我们在配置类上加一个 EnableAsync 后普通类的方法也能被异步代理接管。这个自动配置的过程和 SpringBoot 自动装配原理是相关的这里不用深究只需要记住不加 EnableAsyncAsync 就是一张废纸。刚开始接触 SpringBoot 异步的人第一节课应该就把这个开关加上而不是先写业务代码。2.2 线程池参数到底怎么定直接用 Async 而不指定线程池Spring 会去找容器里类型为 Executor 的 Bean如果找不到会使用 SimpleAsyncTaskExecutor。这个默认实现每次调用都新建线程没有线程复用并发一高就会疯狂创建线程最终把内存打爆。所以生产环境必须自定义一个 ThreadPoolTaskExecutor并明确把执行器名传给 Async。设置线程池参数时可以先按任务类型粗略估算IO 密集型任务比如大量数据库查询、第三方 HTTP 调用核心线程数可以接近 2 倍 CPU 核数CPU 密集型任务比如复杂计算、加解密核心线程数取 CPU 核数左右即可。但更准确的做法是看请求量和单任务耗时。假设一个异步任务平均耗时 0.5 秒目标每秒要处理 20 个这样的任务按照利特尔法则估算需要的核心线程数大约是 20 乘以 0.5也就是 10 个线程。队列容量看系统能容忍的峰值等待时间如果希望高峰期最多只等 30 秒队列长度可以粗略视为目标并发量乘以等待时间再除以单任务耗时最终数值一定要通过压测再微调。核心线程数不是越大越好维护线程本身也有成本上下文切换更会消耗 CPU。下面给出一个我生产中反复使用的配置类线程池名叫做 bizTaskExecutor后面所有示例都会引用它参数已经按上面的思路做了一轮估算Configuration EnableAsync public class AsyncConfig { Bean(name bizTaskExecutor) public Executor bizTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(biz-async-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(10); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }ThreadPoolTaskExecutor 的排队规则需要特别注意当核心线程数满了新任务会先进入队列队列满了才会创建新线程直到 maxPoolSize如果 maxPoolSize 也满了才触发拒绝策略。很多人把核心线程数和最大线程数设得很大队列也设得很大以为这样最保险结果突发流量时大量任务都堆在队列里任务执行延迟反而暴涨。我通常会把核心线程数按正常流量的 70% 预留最大线程数按峰值流量的 60% 设置队列再留一点缓冲并用 CallerRunsPolicy 兜底。CallerRunsPolicy 的含义是当线程池拒绝任务时由调用线程自己执行该任务这样系统过载时不会被直接丢弃只是把压力转移回调用端比抛异常要温柔很多。2.3 一个可以直接抄的完整例子异步方法要写在 Spring 管理的 Bean 里并且必须是 public 方法。下面是一个消息通知服务专门用来发送用户通知本身不参与订单核心流程Service public class OrderNotifyService { Async(bizTaskExecutor) public void sendMessage(Long userId, String content) { System.out.println(当前线程: Thread.currentThread().getName()); // 模拟调用短信平台 try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(用户 userId 消息发送完成: content); } }然后在业务代码里注入这个 Service 并调用。调用方不会被阻塞sendMessage 的逻辑会跑到 biz-async- 开头的线程上执行RestController public class OrderController { private final OrderNotifyService orderNotifyService; public OrderController(OrderNotifyService orderNotifyService) { this.orderNotifyService orderNotifyService; } PostMapping(/order/create) public String createOrder(RequestBody Order order) { // 同步处理核心业务 Long orderId doCreateOrder(order); // 异步发送通知 orderNotifyService.sendMessage(order.getUserId(), 订单已创建编号 orderId); return success; } }这里有个容易踩的坑如果 sendMessage 方法内部抛异常默认不会抛给调用方需要额外配置 AsyncUncaughtExceptionHandler后面第六部分会专门讲。另外如果直接在同一个类内部用 this 调用自己类里的 Async 方法AOP 代理不生效方法依然是同步执行这个坑也要记住。总的来说Async 加自定义线程池这套组合覆盖了大部分发后即焚型任务也是后面所有异步方案的基础。3. 想要返回值怎么办CompletableFuture 的实操姿势3.1 并行聚合一个商品详情页第一节里提到的并行聚合场景实际需求是这样的详情页要查会员等级、库存、商品价格和最近评价四个接口速度分别是 200ms、500ms、300ms 和 400ms。同步串行总耗时 1400ms用户体验很差。用 CompletableFuture 并行发起四个调用后总耗时下降到约 500ms。代码写起来非常直观每个异步任务通过 supplyAsync 提交最后用 join 收集结果Service public class ProductDetailService { private final Executor bizTaskExecutor; public ProductDetailService(Qualifier(bizTaskExecutor) Executor bizTaskExecutor) { this.bizTaskExecutor bizTaskExecutor; } public ProductDetailVO getDetail(Long productId) { CompletableFutureMemberVO memberFuture CompletableFuture.supplyAsync( () - memberService.getMember(productId), bizTaskExecutor); CompletableFutureStockVO stockFuture CompletableFuture.supplyAsync( () - stockService.getStock(productId), bizTaskExecutor); CompletableFuturePriceVO priceFuture CompletableFuture.supplyAsync( () - priceService.getPrice(productId), bizTaskExecutor); CompletableFutureReviewVO reviewFuture CompletableFuture.supplyAsync( () - reviewService.getReview(productId), bizTaskExecutor); ProductDetailVO vo new ProductDetailVO(); vo.setMember(memberFuture.join()); vo.setStock(stockFuture.join()); vo.setPrice(priceFuture.join()); vo.setReview(reviewFuture.join()); return vo; } }这里必须强调一定要把自定义线程池传进 supplyAsync否则 CompletableFuture 会使用公共的 ForkJoinPool.commonPool。公共池的线程数是 CPU 核数减一适合 CPU 密集任务如果业务里有数据库查询、HTTP 调用等阻塞操作公共池很容易被占满影响整个 JVM 里所有用到公共池的代码。传进线程池以后每个异步任务都走 bizTaskExecutor线程池隔离互不干扰也方便根据业务调整参数。join 方法是阻塞等待四个任务并行执行时整体耗时会近似等于最慢的那个任务不再是串行叠加。3.2 结果编排、超时与异常补偿CompletableFuture 真正强大的地方不止是并发等待而是编排能力和异常补偿。比如会员服务挂了不能让整个详情页也跟着挂掉可以给会员查询任务加一个 exceptionally 兜底返回一个默认会员对象。库存查不到也是一样的逻辑。代码可以写成这样CompletableFutureStockVO stockFuture CompletableFuture.supplyAsync( () - stockService.getStock(productId), bizTaskExecutor) .exceptionally(ex - { log.error(查询库存失败, ex); return new StockVO(0, 未知); });这样某个下游任务异常时整个编排还能继续用户看到的仍是一个可用的页面。如果你需要在多个异步任务返回后做下一步计算可以用 thenCombine 或者 thenApply。比如价格和库存都拿到了再计算促销价。需要注意的是thenApply 等后续回调默认执行线程并不固定当前面任务已完成时它可能直接在当前调用线程上执行如果没指定线程池也可能继续用公共池。所以每个阶段最好显式传入自定义线程池保证执行环境可控。Java 9 之后还可以给 CompletableFuture 加 orTimeout比如 memberFuture.orTimeout(300, TimeUnit.MILLISECONDS)超时后任务会抛出 CompletionException。如果你的 SpringBoot 项目还跑在 Java 8 上需要自己在 CompletableFuture 外面包装一层超时逻辑或者引入第三方异步工具类来处理否则遇到慢接口时异步线程会一直被占着。3.3 别让共享线程池毁掉整个服务用 CompletableFuture 时最容易忽略的问题是线程池粒度。如果整个应用只用一个大线程池所有异步任务混在一起跑核心业务任务可能被日志任务阻塞在队列里。我建议按业务域拆分线程池通知线程池、数据聚合线程池、消息推送线程池各管各的。每个线程池命名都要带上业务前缀比如 notify-exec、detail-exec这会给后续查日志省很多事。线程池数量也不是越多越好太多了会增加线程上下文切换成本也会让监控指标变得碎片化。一个中小型应用准备两到三个线程池就够了核心思想是互相隔离避免某个业务的任务把另一个业务的线程吃光。比如一个大批量历史数据迁移任务如果和用户请求的聚合任务共用一个线程池用户会明显感觉到接口变慢这种事故我已经不止一次见过。4. 事件异步化与响应式编程更优雅但不一定需要4.1 用 Spring 事件把业务发布出去有些场景下我们希望一个业务动作发生后多个模块都能收到通知比如订单创建成功后需要发短信、更新统计、发消息给运营。如果直接在每个模块里调用耦合太重用 Spring 事件发布订阅可以很好解耦。先定义一个事件对象然后在业务代码里通过 ApplicationEventPublisher 发布。监听端可以配合 Async 变成异步执行Spring 提供了 EventListener 和 Async 组合使用的方式但要注意监听方法到底在哪个线程执行取决于 Async 指定的执行器。如果监听方法没有指定线程池同样会走到默认 executor还是会出现线程池被随意创建的问题。比 EventListener 更讲究一点的方案是 TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)这个监听器会等待当前事务提交成功后才触发避免了事务还没提交就异步执行外部操作、结果读到旧数据的问题。比如订单创建后要发通知通知必须看到已提交的订单数据用 AFTER_COMMIT 就很合适。事务回滚时监听器不会触发也就不会产生“订单没建成功却通知用户”的脏消息。4.2 WebFlux 不等于万物异步谈到 SpringBoot 异步调用很多人会立刻想到 Spring WebFlux。这里我需要泼一盆冷水WebFlux 和 Async 根本不是一回事。WebFlux 采用响应式编程模型底层基于 Netty 事件循环用少量线程处理大量请求适合 IO 密集、链路完整采用非阻塞组件的场景。它不是把接口返回值简单地改成 Mono 就万事大吉。如果项目里还在用阻塞式 JPA、JDBC 连接池请求线程依然会阻塞在数据库操作上WebFlux 的优势完全发挥不出来。除非你愿意把数据访问层也换成 R2DBC 或响应式 MongoDB并且保证所有下游调用都是非阻塞的否则整体换成 WebFlux 的投入产出比很低。老项目想优化接口耗时优先考虑 Async、CompletableFuture 或消息队列而不是重写整个技术栈。WebFlux 更适合新写的网关、BFF、中间件服务或者团队本身有响应式开发经验、愿意接受调试复杂度更高的项目。5. 异步调用进阶消息队列的引入和取舍5.1 从本机线程池到跨服务解耦前面说的 Async 和 CompletableFuture都只是在单个应用实例内部完成异步执行。如果服务部署了多实例线程池被分散在多台机器上任务没有统一可靠性保障进程重启可能导致任务丢失。这时候需要用消息队列来承载异步调用比如 SpringBoot 整合 RabbitMQ、Kafka。一个常见的做法是订单服务发布“订单创建成功”消息短信服务、积分服务、统计服务分别订阅处理。订单服务不需要知道下游有哪些消费者也不依赖它们的可用性削峰填谷的能力比线程池强得多。SpringBoot 整合 RabbitMQ 时只需要引入 spring-boot-starter-amqp配置好连接信息和交换机队列。发送端的最小示例很简洁Service public class OrderMessageProducer { private final RabbitTemplate rabbitTemplate; public OrderMessageProducer(RabbitTemplate rabbitTemplate) { this.rabbitTemplate rabbitTemplate; } public void send(Long orderId) { OrderCreatedMessage message new OrderCreatedMessage(orderId); rabbitTemplate.convertAndSend(order.exchange, order.created, message); } }消费端通过 RabbitListener 监听队列异步消费消息并处理业务。跟线程池相比MQ 会多一次网络 IO性能上略低于线程池但它带来的可靠性和扩展性完全值得这个开销。线程池只是 JVM 内的一次执行转移消息队列是系统间的事件传递语义完全不同。所以我的建议是单机内并行聚合和发后即焚任务优先线程池和 CompletableFuture跨服务、需要可靠投递和重试、需要削峰的场景直接上 MQ 更稳妥。5.2 消息方案常见的三个坑第一个坑是重复消费。消费端可能因为网络超时或重启导致同一条消息被投递两次所以消费逻辑必须做幂等。比如用订单号做唯一键处理前先查处理记录表已经处理过就直接跳过。第二个坑是消费失败后的重试导致消息顺序混乱。如果一个订单的“创建”消息和“取消”消息都发到同一个队列消费端第一次处理失败后重试“取消”消息可能反而先被消费顺序就错了。大部分业务场景不要求严格顺序但如果有就必须保证同一类消息通过 hash 路由到同一个队列并且消费端串行处理。第三个坑是消息丢失。生产端要开启发布确认消费端要手动 ack而不是自动 ack否则消息从队列投递到消费者时被立刻确认消费者还没处理完就崩溃了消息也就跟着丢了。SpringBoot 的配置项 spring.rabbitmq.listener.simple.acknowledge-modemanual 可以配合手动确认来用。这三个坑如果不提前避掉异步化改造反而会引入新的线上事故。6. 避坑指南与疑难排查把异步玩稳的细节6.1 自调用导致 Async 失效这是 Async 最经典的失效场景在同一个 Bean 里A 方法调用 B 方法B 标记了 Async但实际执行时 B 并不会异步。原因是 Spring AOP 代理只对进入代理对象的方法生效this 调用走的是原始对象绕过了代理。我自己的建议是第一种最干净把异步方法放到另一个 Spring Bean 里通过注入调用。如果一定要在同一个类里调用可以用 Lazy 注入当前 Bean 的代理对象或者通过 AopContext.currentProxy() 获取代理来调用。前者代码直观后者需要设置 spring.aop.proxy-target-classtrue 且开启 exposeProxy比较复杂。平时写代码时可以留个心眼如果异步方法和调用方在同一个类里八成有坑趁早拆类。6.2 异步方法与事务关系的梳理Async 和 Transactional 同时标在一个方法上时代理顺序决定了事务是否有效。如果先经过异步代理方法会在新线程执行这时候事务代理拿到的线程上下文已经不是调用方线程。Spring 的事务管理器基于 ThreadLocal 保存数据库连接调用方线程的事务和新线程根本没有关系。所以不要指望异步方法里的数据库操作能和调用方在同一个事务里回滚。正确做法是把核心事务留在同步方法里异步方法只处理与事务状态无关的最终一致操作。如果异步方法自己需要事务再单独在这个方法上标注 Transactional让它自己的线程开启一个全新事务。比如订单创建主流程保持事务下单成功后异步发短信、更新统计报表这些操作即使失败也不应该回滚订单事务。6.3 异常要能看到而不是被吞掉Async 方法抛出的异常不会自动由调用线程捕获默认会被线程池吞掉只在日志里留下一行简单输出。如果不配置异常处理器很多线上问题就这样无声无息地消失了。可以自定义 AsyncConfigurer 来统一实现异常处理同时把执行器也配置在这里Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setThreadNamePrefix(biz-async-); executor.initialize(); return executor; } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) - log.error(异步任务执行失败, method: {}, params: {}, method.getName(), params, ex); } }这样异步方法里未捕获的异常就能进入统一日志排查问题有据可依。同时要注意上下文的传递RequestContextHolder、用户信息、MDC 这类 ThreadLocal 数据不会自动带到异步线程需要实现 TaskDecorator 来装饰任务。比如把父线程的 traceId 复制到子线程这样日志链路才能串起来。很多人用 Async 之后发现调用链乱了就是少了这一步。6.4 线程池指标与监控手段异步系统最怕线程池满了却不知道。SpringBoot Actuator 配合 Micrometer 可以暴露线程池指标让运维面板里能看到 active、queue、completed 等数据。实际排查时我会在压测过程里同时观察两个指标active 线程数是否长期接近 corePoolSizequeue 大小是否持续上涨。如果 queue 一直涨而 active 没有波动说明线程池参数配置不合理瓶颈在消费速度上。配合线程名前缀 grep 日志比如 biz-async-能很快定位到任务到底在哪个线程池执行。另一个有效的土办法是写一个定时监控日志每 5 分钟打印一次当前线程池的核心线程数、活跃线程数、队列积压量和拒绝次数。这样即使没有专业监控系统也能在故障发生时快速复盘。写到这里干货差不多都覆盖了。再分享一个我个人的习惯任何异步方法都必须有明确的执行策略要么指定线程池名称要么指定消息队列不指定执行器的 Async 等于裸奔。刚开始使用 SpringBoot 异步时我会先写一个线程池配置类把核心线程、最大线程、队列、拒绝策略和线程名维护好后续所有异步任务都从这套配置里按业务域选择。这样系统踩坑的概率会低很多排查问题时也会轻松很多。希望这些经验能让你少走我在异步调用上走过的弯路。