
1. 为什么要做异步调用从一次接口超时说起先讲一个真实的场景。之前我给某个后台管理系统做导出功能用户在前端点了一个“导出报表”后端同步去数据库里查几十万条数据再拼装Excel最后返回文件流。单次执行时间普遍在15秒以上前端请求早超时了用户反复点了几次后台直接堆了好几个线程在处理同一件事。这个问题的根源不在代码性能而是同步阻塞的调用模型。请求线程从接收到返回全程霸占着不放导出的耗时越长能用线程就越少数据库连接池、Tomcat线程池都被拖累服务整体吞吐跟着掉。SpringBoot下的异步调用就是面向这类场景的标准解法把耗时操作丢给另一个线程去执行请求线程立刻返回调用方不需要干等。本文会从三种主流实现方式展开基于Async注解的线程池异步、基于WebFlux的响应式异步、以及消息队列的最终解耦同时把实际开发中容易踩的失效、线程耗尽、上下文丢失这几个坑一并讲清楚。2. SpringBoot异步调用整体方案盘点2.1 同步、异步、阻塞、非阻塞几个概念先理清聊异步之前得先把概念统一一下不然下面代码容易看混。同步和异步说的是调用方的处理方式。调一个方法必须等它跑完才能继续做别的事就是同步调用后立即返回结果通过回调、轮询、Future或事件来获取就是异步。阻塞和非阻塞说的是线程的状态。线程在等待结果期间被挂起、不能做别的事就是阻塞线程发起操作后不等待继续往下执行就是非阻塞。“异步”不等于“快”它只是让当前线程不被拖住。真正省时间的是并行的计算资源所以异步方案几乎必须配套线程池否则异步任务全堆在单线程里反而更慢。2.2 三套方案怎么选方案核心机制适合场景需要注意的点Async 线程池AOP代理 独立线程池执行方法业务代码里局部耗时的操作比如发通知、写日志、生成报表自调用无效、线程池参数要配好WebFlux 响应式异步基于Netty事件循环 响应式流高并发IO密集型接口网关层、数据聚合接口全链路要反应式不能混用阻塞调用消息队列异步生产者发消息消费者独立处理跨系统解耦、削峰填谷、可靠投递场景需要额外中间件要考虑消息幂等这三套方案不是互相替代的关系。一个大型项目里接口内部会用Async处理局部任务核心入口用WebFlux扛流量系统之间再用消息队列解耦各管一段。3. Async注解最常用的异步实现方式3.1 第一个Demo从配置到生效Async是Spring框架自带的能力SpringBoot里用起来很方便。先在启动类或者配置类上加上EnableAsync意思是开启异步调用的注解扫描SpringBootApplication EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后写一个异步服务把需要异步执行的方法用Async标记Service public class NotificationService { Async public void sendSms(String mobile, String content) { // 模拟短信接口耗时 try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(短信发送完成: mobile); } }调用方正常注入Service调用方法就行RestController RequestMapping(/api/order) public class OrderController { private final NotificationService notificationService; public OrderController(NotificationService notificationService) { this.notificationService notificationService; } PostMapping(/create) public String createOrder() { // 订单业务逻辑 notificationService.sendSms(138****1234, 您的订单已创建); return 下单成功; } }运行起来请求接口后响应立刻返回不会等那3秒。短信任务在独立的线程里执行这就是最基础的Async用法。3.2 为什么Async能生效代理机制要明白这是理解Async的关键点也是后续排查失效问题的理论基础。Spring容器在启动时扫描到Async注解的方法会给Bean生成一个代理对象。当我们从容器里拿到这个Bean并调用方法时实际调用的是代理对象代理对象会先把任务提交给线程池然后返回。也就是说异步逻辑是被“包装”进去的而不是你写的那个方法本身变成了异步。基于这个原理有两个经典结论第一Async方法必须被Spring容器管理也就是放在Service、Component这些Bean里。第二同一个类内部调用带Async的方法不会走代理等于直接调用原方法异步失效。后面第6部分我会专门讲这个坑。3.3 自定义线程池让异步任务跑在正确的线程上默认情况下Async用的是Spring自带的SimpleAsyncTaskExecutor这个类每次执行都会新建一个线程没有复用机制。高并发场景下线程数无上限增长系统资源很快耗尽。生产环境必须自定义线程池。推荐用Java配置类创建ThreadPoolTaskExecutorConfiguration public class AsyncConfig { Bean(taskExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数正常情况下常驻的线程数量 executor.setCorePoolSize(10); // 最大线程数高峰期最多能扩展到的线程数量 executor.setMaxPoolSize(50); // 队列容量核心线程全忙时新任务先放队列 executor.setQueueCapacity(200); // 线程名前缀排查问题时好识别 executor.setThreadNamePrefix(async-task-); // 拒绝策略队列也满的时候怎么处理新任务 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }参数大小怎么定这是被问得最多的。核心线程数参考机器的CPU核数和任务类型。IO密集型任务调远程接口、读写文件等待时间占比高核心线程数可以设成CPU核数的2倍以上CPU密集型任务大量计算设成CPU核数加1到2个就够。设核心线程10、最大线程50、队列200意思是正常情况下10个线程在跑任务多的时候先排队队满之后才会把线程数往上提最多50个还是处理不过来就触发拒绝策略。使用自定义线程池时Async注解里要指定名字Async(taskExecutor) public void sendSms(String mobile, String content) {如果不指定Spring会按类型查找默认执行器找到唯一实现类就自动使用。建议还是显式写名字多个线程池并存时不容易混。3.4 有返回值的异步调用Future与CompletableFuture现实中很多异步任务需要带结果回来。Async支持Future类型的返回值最常用的是CompletableFuture。Service public class ReportService { Async(taskExecutor) public CompletableFutureString generateReport(Long userId) { // 耗时逻辑比如统计数据 String reportContent 用户 userId 的报表数据...; return CompletableFuture.completedFuture(reportContent); } }调用方可以主动组合多个异步任务public String handleReportRequest() throws Exception { CompletableFutureString reportFuture reportService.generateReport(1001L); CompletableFutureString otherFuture otherService.generateOtherData(1002L); // 等待两个任务都完成并合并结果 String result CompletableFuture.allOf(reportFuture, otherFuture) .thenApply(v - reportFuture.join() otherFuture.join()) .get(30, TimeUnit.SECONDS); return result; }allOf会等所有任务完成后再执行合并逻辑get方法设置超时时间避免任务卡死导致调用方无限等待。这里有一个细节CompletableFuture.join和get的区别。get抛受检异常需要try-catch或向上抛出join不抛受检异常直接在RuntimeException里包装。业务代码里我更习惯用get带超时至少不会把控制权无限交给异步任务。3.5 异步任务的异常处理Async方法默认的异常处理方式是如果方法没有返回值异常会被吞掉日志里可能什么都看不到。这是新手最容易忽略的点。处理方案有两种。第一种方法返回Future异常会封装在Future里调用get时抛出由调用方处理。这种方式能让异常回到业务链路但调用方需要等待结果有些场景不算理想。第二种实现AsyncUncaughtExceptionHandler配置成自定义的异步异常处理器Configuration public class AsyncExceptionConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-); executor.initialize(); return executor; } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) - { // 这里统一记录异常日志或者接入告警系统 System.err.println(异步方法执行异常: method.getName()); ex.printStackTrace(); }; } }注意如果实现AsyncConfigurergetAsyncExecutor里返回的Executor就是全局默认的线程池所有没指定名称的Async都会用它。如果类里还有其他配置方式要避免两个配置源互相覆盖。异常处理选哪种取决于对这个异步任务结果的依赖程度。纯通知类任务用自定义Handler记录异常就够了核心流程的分支任务建议返回Future让主流程能感知失败。3.6 Async与事务的叠加效果Async和Transactional同时使用时很多人以为事务会正常工作实际上需要分情况。Spring的事务也是通过代理实现的Transactional默认只在当前线程内生效。Async方法把逻辑推到了另一个线程事务注解如果只写在异步方法上事务还是有效的——只要事务管理器能拿到对应的连接。但组合时的经典问题是事务传播。假设主方法开启事务调用本类里一个被Async标记的方法这个方法又写了Transactional此时它跑在另一个线程里主线程的事务上下文传不过去两个事务互相独立。如果异步方法内部对同一数据做了修改主事务提交失败时异步事务可能已经提交了数据一致性就出问题了。实操建议把异步和事务当成两个独立的边界。事务管理交给主流程异步方法里的数据一致性靠消息确认、状态机或者定时补偿来解决不要指望跨线程的两个代理还能保持完整的ACID语义。4. WebFlux响应式异步换一种执行模型4.1 WebFlux不是“异步版的SpringMVC”有些人把WebFlux理解成“用起来更异步的MVC框架”这是不对的。WebFlux改变了底层的执行模型。SpringMVC基于Servlet规范每个请求占用一个Tomcat工作线程线程阻塞时这个线程什么都不能干。WebFlux基于Netty事件循环用的是少量线程配合事件回调来处理海量并发请求。线程不阻塞等待而是注册回调数据来了再触发后续逻辑。所以WebFlux的高并发能力来自它极低的线程占用。同样一台机器MVC能扛几百并发时WebFlux往往能扛几千甚至更高前提是代码里不能有阻塞调用。引入WebFlux在pom里加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency注意如果项目里同时引入了spring-boot-starter-webMVC要移除其中一个或者明确端口配置否则两个容器会冲突。4.2 响应式写法的基本形态接口返回Mono或Flux类型表示这个结果是“未来某个时刻才会出现的”RestController RequestMapping(/api/user) public class UserReactiveController { private final UserService userService; public UserReactiveController(UserService userService) { this.userService userService; } GetMapping(/{id}) public MonoUser getUser(PathVariable Long id) { return Mono.fromCallable(() - userService.findById(id)) .subscribeOn(Schedulers.boundedElastic()); } }这个接口里findById这个阻塞型操作被丢到boundedElastic线程池里执行而Netty事件循环线程立刻返回不阻塞。等数据查到了再通过回调把响应写回客户端。4.3 响应式链路的写法规范响应式异步的关键在于整条链路都要保持响应式。如果Service层用的是JPA的findById这本身就是阻塞调用。把它包在Mono.fromCallable里的效果是异步线程执行了阻塞查询这确实能提升并发但底层依赖的还是线程池本质上是“用线程池隔离阻塞”而不是真正的全链路响应式。真正做到全链路响应式要求数据访问层也返回响应式类型。比如用R2DBC访问数据库public interface UserRepository extends ReactiveCrudRepositoryUser, Long { FluxUser findByStatus(String status); } RestController public class UserController { private final UserRepository userRepository; public UserController(UserRepository userRepository) { this.userRepository userRepository; } GetMapping(/active) public FluxUser getActiveUsers() { return userRepository.findByStatus(ACTIVE); } }这种写法下从HTTP入口到数据库驱动全程没有阻塞等待。每个环节都是“准备好了就通知下一个环节”线程利用率极高。4.4 实际项目里的语句WebFlux适合网关层、聚合层比如给App提供统一的聚合接口同时调用订单服务、支付服务、用户服务的接口把它们的结果合并返回。每个下游调用都返回响应式结果合并时用zippublic MonoOrderDetailVO getOrderDetail(String orderId) { MonoOrder orderMono orderClient.getOrder(orderId); MonoListPayment paymentMono paymentClient.getPayments(orderId); MonoUser userMono userClient.getUserByOrderId(orderId); return Mono.zip(orderMono, paymentMono, userMono) .map(tuple - { Order order tuple.getT1(); ListPayment payments tuple.getT2(); User user tuple.getT3(); return buildVO(order, payments, user); }); }三个下游调用并行执行全部返回后组装成VO发给客户端。如果是MVC写法这种聚合要么串行请求要么手动管理线程池代码冗长且容易出错。4.5 WebFlux的代价响应式不是免费的午餐。它最大的问题是调试困难。传统代码的堆栈在线程切换后变得支离破碎异常信息经常不会指向真正出问题的位置。响应式代码的学习曲线也陡峭flatMap、concatMap、switchIfEmpty这些操作符的语义不熟悉的人很容易写出性能极差的代码。我的个人结论是中小型项目业务逻辑复杂团队也没有响应式经验不要强行上WebFlux。用Async配合线程池改造局部痛点性价比高得多。WebFlux适合高并发、IO密集、业务链路清晰的下游聚合或网关项目。5. 消息队列跨系统异步解耦5.1 什么时候需要消息队列Async解决的是进程内的异步消息队列解决的是跨进程的异步。订单创建后要通知积分系统、短信平台、物流系统如果通过RPC同步调用任何一个下游出问题都会拖累下单主流程而且下游流量波动还会反过来挤压订单服务的资源。消息队列的方案是订单服务把“订单创建完成”这个事件以消息的形式写入队列下单请求到这里就结束了。积分服务、短信服务分别订阅这个主题各自来消费。订单服务不再依赖下游的可用性下游也通过订阅的方式解耦了与订单服务的强绑定。流程订单服务作为Producer发送消息到Topic消息队列Broker持久化消息确认收到积分服务、物流服务作为Consumer订阅Topic各自消费消息更新自己的数据5.2 消息队列和Async怎么配合消息队列两侧都会用到Async。Producer侧发送消息本身是快速操作但网络异常重试时可能耗时所以发送逻辑可以用Async异步化避免阻塞业务主流程。Consumer侧如果一次拉取到多条消息单条消息处理耗时较长可以在消费者内部再用Async把每条消息的业务处理丢到线程池并发执行提升消费吞吐。要注意的是消费端的并发度控制。如果消费者线程池开得太大消息处理太快可能压垮下游数据库。一般建议消费线程池的最大线程数跟数据库连接池大小挂钩保证线程数不超过连接数的上限。5.3 消息队列的可靠性问题消息队列把同步调用变成了异步通知同时也引出了一个新的问题怎么保证消息不丢、不重、不乱序。不丢Producer发送时开启确认机制Broker持久化完成后再返回成功Consumer消费后处理完业务再提交位点。不重消费端要做幂等因为消息队列的语义是“至少一次”重复消费是正常现象。常见做法是消费端先查本地状态已经处理过的直接返回成功或者用唯一业务ID去重。不乱序同一业务的消息要保证分区键一致让它们进同一个分区消费者单线程消费这个分区。这部分的坑主要集中在幂等上。很多项目刚开始只做了“不丢”没做“不重”线上跑一段时间就发现重复数据。做消息队列异步第一件要设计好的事就是幂等方案而不是消息发送本身。6. 三大高阶问题失效、线程耗尽、上下文丢失6.1 Async失效的几种常见场景熟悉了代理原理之后失效问题基本都能自己排查。最常见的失效场景有四种第一种方法通过this调用。比如一个Service里面methodA调用methodBmethodB标了Async。因为this指的是原始对象而不是Spring代理所以Async不生效。解决办法注入自身代理或者把异步方法拆到另一个Service里。第二种异步方法不是public。Spring的代理默认只能拦截public方法private和protected方法不会进入代理逻辑。第三种异步方法所在Bean没有被Spring管理。比如自己new出来的对象Spring根本不知道它的存在。第四种配置类上没有EnableAsync。这个看起来低级但项目里多模块化之后经常出现主启动类扫描不到配置类的情况。6.2 线程池被打满的排查方法异步任务量大时线程池可能被打满任务进队列队列也满了触发拒绝策略。CallerRunsPolicy会把这个任务交给调用方线程执行看起来是“降级”但调用量巨大时会把请求线程拖死。排查时主要靠日志。所以自定义线程池时setThreadNamePrefix非常重要executor.setThreadNamePrefix(async-notify-);日志里搜async-notify线程能看到线程数是否接近maximumPoolSize队列深度是否持续增长。如果队列长期堆积说明核心线程数偏低或者任务量超过预期要调整参数而不是无限加线程。另外一个容易忽略的点是如果任务里都有数据库查询线程池配置不能只看服务本身的指标还要看数据库连接池的可用连接数。线程数设50数据库连接池默认只有1050个线程同时查库10个持有连接剩下40个在等待这比不异步还要糟糕。线程池参数必须与会话外资源联动评估。6.3 异步线程里丢失请求上下文Async方法里需要拿到当前登录用户信息、请求头、traceId时经常发现是null。这是因为请求线程的数据存在ThreadLocal里而异步任务跑在不同的线程上ThreadLocal不共享。常用解决办法是装饰器模式主线程在提交任务前把关键上下文拷贝一份传给目标线程。Spring提供了TaskDecorator接口可以优雅地实现上下文传递Bean(taskExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-); // 任务提交时将主线程的上下文复制到子线程 executor.setTaskDecorator(runnable - { // 实际项目中这里会把请求上下文对象暂存 MapString, String contextMap copyContextFromCurrentThread(); return () - { try { setContextToCurrentThread(contextMap); runnable.run(); } finally { clearContext(); } }; }); executor.initialize(); return executor; }如果是Spring Cloud链路追踪的场景也可以用类似方式把traceId从主线程传到异步线程保证全链路日志能串起来。具体到逻辑执行这段decorator是为了展示结构实际会把traceId、用户信息请求上下文做成一个线程私有对象。6.4 异步事务隔离与资源共享异步任务共享数据库连接池、Redis连接池时要考虑资源竞争问题。之前帮别人排查过一个线上故障异步线程池核心线程从5调到了20数据库连接池没动结果每个异步任务里都有大约300ms的数据库查询20个线程同时查询连接池不够用主业务的请求也开始等连接整个服务响应变慢。这类问题的排查步骤是看监控里线程池活跃线程数看数据库连接池活跃连接数看两者是否存在明显的共同增长趋势计算单个任务的平均IO时间反推需要的并发度不要盲目调高线程池资源配置是联动的关系。7. 常见问题速查与避坑清单下面这个表是根据我在项目中遇到过的真实情况整理的方便直接对照排查。问题可能原因解决思路Async方法同步执行this自调用、方法非public、Bean未被Spring管理拆Service、改public、确认包扫描异步异常无日志输出方法无返回Future且未配置异常处理器自定义AsyncUncaughtExceptionHandler线程数暴增使用了默认SimpleAsyncTaskExecutor自定义线程池并指定名称队列任务堆积核心线程数过少或任务量突增压测后调参结合队列容量一起评估异步方法内用户信息为空ThreadLocal无法跨线程传递使用TaskDecorator拷贝上下文接口偶尔变得很慢异线程池触发了拒绝策略CallerRunsPolicy监控线程池指标动态调参异步事务数据不一致跨线程事务隔离导致主流程管事务异步靠补偿WebFlux接口里用了Thread.sleep阻塞了事件循环线程改为非阻塞延时或换到boundedElastic补充几个容易忽略的实践细节每个异步任务都要有业务标识。日志里把业务ID、用户ID、traceId全部打出来排查问题的时候能直接定位到是哪个任务、哪个线程在跑。线程池参数要支持动态调整。生产环境用配置中心管理核心线程数、最大线程数压测发现问题后可以在不重启服务的情况下修改效果比改代码快很多。异步任务要有熔断或者降级方案。线程池满时的拒绝策略不要只选一种结合业务的容忍度来选。可以丢弃不重要任务DiscardPolicy也可以让调用方自己跑CallerRunsPolicy但要做到心里有数知道每种策略的副作用。8. 异步调用的监控指标与可观测性设计用了异步之后问题排查难度确实会上升。同步模式下一个请求从开始到结束是一条清晰的调用链异步线程切来切去传统日志就断了。所以异步用得越多可观测性越要跟上。至少要监控四个指标线程池活跃线程数队列积压数量任务执行耗时P99分位被拒绝的任务数量活跃线程数能看出线程池是否持续处于高水位队列积压数量能提前预警任务堆积执行耗时能看出单个任务的健康状况被拒绝的任务数量直接反映系统是否已经超出容量。日志配合MDC来做traceId传接我在上一部分提过用TaskDecorator传上下文就是为了让异步线程的日志也带上同一个traceId。每个异步任务开始和结束时各打一条日志记录线程名、任务名、业务ID、耗时自己画一条时间轴基本能还原出任务执行的完整路径。监控指标都接好之后判断线程池参数是否合理的依据就变得具体了。线程池稳定在核心线程数附近、队列偶尔积压但能在几秒内排空这是比较健康的状态。如果线程数长期贴近最大线程数、队列持续有积压说明系统已经满负荷了加线程不如削任务。9. 异步改造的实际落地顺序不管项目里选择哪种异步方案都不建议一次性大规模改造。我见过的所有改造失败案例几乎都是因为“把异步当成了万能的性能开关”一口气把所有服务都标成Async结果线上问题此起彼伏又不知道从哪里排起。比较稳的推进方式是分四步走。第一步盘点场景。把项目里所有耗时超过1秒的操作都列出来标注它们是否依赖主流程的结果。比如发送短信、生成日志、统计数据这些明显是分支任务适合异步化。第二步先做Async打底。给这些分支任务加上线程池和异步注解同时把异常处理器、日志、监控配套做好。这一步投入小见效快也是积累异步经验最快的方式。第三步局部引入WebFlux。当某个入口的并发瓶颈特别明显比如聚合接口、网关入口而这个服务的业务链路又相对清晰这部分可以尝试换成响应式编程。不要一开始就把所有Service都改成Mono和Flux链路过深会让改造成本成倍增长。第四步涉及跨系统的异步再上消息队列。消息队列引入的是整个中间件的运维成本如果没有明确的削峰、解耦需求就不要为了用而用。10. 一点个人经验收尾做异步调用这些年最大的体会是异步方案从来不是技术问题而是取舍问题。它把当前线程的“等待时间”转移给了别的线程换来的是接口响应速度的提升和系统吞吐量的改善代价是代码复杂性、排查难度的上升以及分布式场景下一致性问题的新增。实际动手时我的习惯是先画一张简单的调用链路图标出每个环节的耗时、线程、依赖再考虑在这个链路的哪个环节插入异步。不看链路就动手加注解往往是在给系统埋雷。如果你刚上手SpringBoot异步调用不用急着把三套方案都吃透先在自己项目里找一个实实在在的痛点比如每次下发通知都阻塞请求、导出数据超时、日志写入拖慢业务用Async配合线程池改造一遍把代理失效、上下文传递、异常监控这几个点亲手踩一遍比看再多文档都有用。工具选型也一样没有绝对最好的方案。进程内的异步优先Async高并发聚合优先WebFlux跨系统解耦优先消息队列。每个方案的代价不一样选适合自己项目阶段的已经足够。