SpringBoot 异步操作你真的用对了吗先还原一个真实场景一个接口平时响应只要 80ms某天业务方突然说卡到 2 秒。查了一圈发现这个接口里依次调用了三个下游系统耗时分别是 300ms、500ms、400ms加起来 1200ms 基准线。你这接口只是把这些结果简单合并完全可以并行去调。问题就出在当初图省事全写成了同步调用。在 SpringBoot 项目里异步操作四个字几乎人人都听过但真正落地时踩的坑远比你想象的多。很多人知道有个Async注解一加就完事结果线程池用完、事务失效、异常吞掉、请求上下文拿不到——各种问题连环爆。这篇文章不是给你背官方文档而是把我自己在几个真实项目里排查异步问题、重构同步链路、搭建可靠异步体系的完整过程拿出来复盘从原理到实操从单机异步到削峰场景一次讲透。1. 为什么业务代码里需要异步从一次慢接口优化说起1.1 同步调用的瓶颈到底卡在哪一个请求打进来Tomcat 的线程池默认 200 个线程就会分配一个线程来执行你的业务逻辑。如果这个逻辑里有两个相互不依赖的外部调用你用的是clientA.call()再clientB.call()那这 300ms 400ms 的耗时全被当前线程白白睡过去了。更麻烦的是每个请求占用的线程都要等服务端响应才能释放一旦下游响应慢Tomcat 线程池很快被打满后续请求直接排队表现就是接口大面积超时。这时候你说改用异步——不少人第一反应是把整个接口都异步化直接给 Controller 方法加上Async然后返回void。这是灾难性的设计接口变异步后客户端拿不到即时响应体验更差。异步优化的核心不是接口整体变异步而是把一个请求内的多个独立子任务并行执行缩短总耗时或者把那些不需要立刻返回结果的工作放到后台执行快速释放请求线程。1.2 我实际落地的并行方案用 CompletableFuture 做任务编排当时我在代码里做的是这样的改造把三个独立的下游调用分装成三个无返回值的Runnable或带返回值的Supplier然后通过CompletableFuture并发执行。伪代码大致长这样Service public class OrderDetailService { private final OrderClient orderClient; private final UserClient userClient; private final CouponClient couponClient; public OrderDetailVO getOrderDetail(String orderId) { CompletableFutureOrderDTO orderFuture CompletableFuture.supplyAsync(() - orderClient.queryOrder(orderId)); CompletableFutureUserDTO userFuture CompletableFuture.supplyAsync(() - userClient.queryUser(orderId)); CompletableFutureCouponDTO couponFuture CompletableFuture.supplyAsync(() - couponClient.queryCoupon(orderId)); CompletableFuture.allOf(orderFuture, userFuture, couponFuture).join(); return OrderDetailVO.builder() .order(orderFuture.getNow(null)) .user(userFuture.getNow(null)) .coupon(couponFuture.getNow(null)) .build(); } }这个版本的耗时从原来的 1200ms 降到了大概 500ms以最慢的那个下游为准接口 P95 响应时间降了一半多。注意这里我故意没有在getOrderDetail方法上标Async因为调用方需要同步拿到完整的订单详情页。异步化的是子任务不是主流程。1.3 什么时候才该用异步三类典型场景我习惯把 SpringBoot 里的异步需求分成三类分层去考虑第一类是并行编排典型代表就是我上面改造的订单详情聚合场景。多个 RPC/HTTP 调用互不依赖合并结果返回给调用方。这类用CompletableFuture 自定义线程池最合适。第二类是削峰填谷典型场景是秒杀、抽奖、高并发下的用户操作日志。用户点一下立即抢购主线程只需要把抢购请求快速写进一个队列就立刻返回已受理后台线程慢慢消化。这类用消息队列RabbitMQ、Kafka最合适Async只能做单机版缓冲扛不住真正的大流量。第三类是延迟不敏感任务像发送通知短信、清理临时文件、导出报表后生成下载链接。这些任务不要求调用方等待丢了影响也相对小。这类用Async 独立线程池很合适实现成本最低。2. 你以为的 Async 不是你以为的自调用、代理机制和线程池三座大山2.1 自调用失效最经典的注解没生效很多人这样写Service public class NotificationService { public void sendBatch() { // do something this.sendSingle(); } Async public void sendSingle() { // 发短信 } }然后发现sendSingle()还是同步执行的耗时一点没降。这不是运气问题是 Spring AOP 代理机制决定的。Async的原理是通过 Spring AOP 动态代理生成一个代理对象在调用目标方法之前先走拦截器AsyncExecutionInterceptor把方法提交到线程池。但this.sendSingle()走的是this 对象即原始对象本身根本没经过代理。所以注解直接失效。我当时在项目里排查这个问题时确认失效的最快方式是打断点看调用栈直接能看到当前方法是从原始对象进入的没有经历代理类的拦截逻辑。修复方案很实用任选一种注入自身代理Autowired private NotificationService self;然后self.sendSingle()。拆类把Async方法放到另一个 Service Bean 里通过注入调用。使用AsyncUtils之类的工具类包装内部通过ApplicationContext.getBean(Class)获取代理对象再调用。不建议把Async加在private方法上因为代理机制根本挂不上也不建议加到同类内的互相调用方法上除非你很清楚地知道self注入那套写法。2.2 默认线程池是个隐形炸弹Spring Boot 在没有配置TaskExecutor时Async默认使用的SimpleAsyncTaskExecutor有个非常坑的特性它每次调用都新建一个线程任务执行完就丢弃线程完全不重用。在高并发下等于变相无限创建线程很容易把资源耗尽甚至触发OutOfMemoryError: unable to create new native thread。当时我们上线后遇到过几次 CPU 突然飙到 100%线程数疯狂增长就是这玩意儿在搞鬼。正确做法是配置一个专门的异步执行器核心参数照着业务量去定。我通常用一个独立的配置类Configuration public class AsyncConfig implements AsyncConfigurer { Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数通常按 CPU 核心数 * 2 来定IO 密集型的可以多一些 executor.setCorePoolSize(8); // 最大线程数核心线程数 队列容量消化不了时的缓冲 executor.setMaxPoolSize(16); // 队列容量先进入队列队列满了再创建新线程 executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-task-); // 拒绝策略让调用线程自己跑这个任务保证任务不丢 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } Override public Executor getAsyncExecutor() { return taskExecutor(); } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) - log.error(异步任务执行异常, method{}, params{}, method.getName(), params, ex); } }参数不是拍脑袋定的。我当时是用压力测试反过来调的并发 500 个请求每个请求提交 3 个异步任务核心线程 8、队列 200、最大 16能覆盖大部分平稳流量。如果队列经常打满说明任务过多优先调大队列还是调大最大线程数要看任务性质——IO 密集的多开线程收益高CPU 密集的多开会加剧争抢。2.3 自定义线程池却没指定名字另一个低频但高杀伤的问题还有个小坑如果你配置了多个ExecutorBean但Async注解上不写名字Spring 拿不到唯一候选时会报错或者落到兜底策略。如果你只有一个自定义执行器直接写Async(taskExecutor) public void sendSingle() { ... }这样最稳显式绑定不影响后来加别的线程池。3. 事务与异步的撕裂为什么你的事务总是悄悄失效3.1 事务边界与线程边界根本不是一回事Transactional的事务是通过ThreadLocal绑定在当前线程上的。你想在异步线程里继续沿用主线程的事务这本身就是伪命题——ThreadLocal是线程隔离的主线程的事务对象传到子线程里子线程拿到的那个DataSource连接根本不在同一个事务上下文里。最典型的翻车写法是在Async方法内部调数据库操作还加上Transactional然后发现抛异常不回滚。我当时的一个实际场景是主线程做了个订单状态更新然后调Async异步任务去更新下游缓存。异步任务里如果数据库操作失败整个订单状态更新 缓存刷新并不会一起回滚。这不是 bug是设计问题。解决办法通常是两个方向把事务控制在异步方法自身异步任务内部开启独立事务自己提交、自己回滚对主事务无感知。主线程只发指令异步线程读取最新数据再处理比如异步任务里重新查一遍订单状态再决定下一步避免依赖主线程未提交的数据。我在排查这个问题时最典型的失效场景长这样Async方法是void返回内部调了xxxMapper.update()异常被吞了数据库那行数据该更新没更新也没有任何日志。加日志后才发现异常发生在事务拦截器之外。3.2 传播级别选不对子线程里惊魂一跳如果异步任务确实需要保证一致性业界常见的做法是用本地消息表 定时任务补偿或者引入 MQ 的事务消息。单纯靠AsyncTransactional(propagation Propagation.REQUIRES_NEW)只能保证子任务自身是一个独立事务保证不了整体一致性。我自己经历过一次线上事故订单创建后异步发送 MQ结果 MQ broker 抖动消息发送失败但订单已经落库。后续补偿脚本查的是未发送记录因为订单表里根本没有发送状态字段补偿只能靠人工对账。从那之后我给自己定了条规矩跨线程或跨系统的操作必须显式记录状态比如msg_status字段异步任务处理完再更新状态配套定时任务扫描超时未完成的数据重试。一致性这事不能依赖线程池和事务拦截器的巧合。4. CompletableFuture 的正确使用姿势编排、超时、异常兜底4.1 为什么我放弃了 Future 和 ListenableFuture老项目里Future的get()是阻塞的而且只能拿一个结果做不了多个任务都完成再汇总这种组合编排。ListenableFuture是 Spring 对 Guava 的封装有回调但链式组合能力弱。真正让我彻底转投CompletableFuture的原因是它把异步执行 回调 组合 异常恢复全揉到了一起代码写起来像在描述业务流程而不是在处理线程回调地狱。一个典型场景用户下单后需要同时做 3 件事——生成订单快照、扣减库存、记录操作日志。CompletableFutureVoid snapshotFuture CompletableFuture.runAsync(() - snapshotService.createSnapshot(orderNo), taskExecutor); CompletableFutureVoid stockFuture CompletableFuture.runAsync(() - stockService.deductStock(orderNo), taskExecutor); CompletableFutureVoid logFuture CompletableFuture.runAsync(() - logService.record(orderNo), taskExecutor); CompletableFuture.allOf(snapshotFuture, stockFuture, logFuture) .orTimeout(3, TimeUnit.SECONDS) .exceptionally(ex - { log.error(下单后续任务执行异常, ex); return null; }) .join();orTimeout是 Java 9 才有的但我们很多项目还在 Java 8。Java 8 下我自己的兜底方案是get(timeout, TimeUnit)包在 try-catch 里超时后直接记录告警并返回降级结果。每次写异步编排都要想清楚一个问题我等不到结果了怎么办——能降级就降级能重试就重试但绝不能让它静默吞掉异常。4.2 异常处理默认情况比你想象得更隐蔽CompletableFuture异常默认会存储在返回的CompletableFuture内部如果你只调用了join()ExecutionException会被原样抛出来如果没调用.get()也没设置whenComplete回调异常就悄无声息。很多线上问题就是这么来的——异步任务挂了主流程毫不知情数据越来越不一致。我的惯例是给每个异步任务链式加上whenComplete或exceptionally并且统一打印日志重点记录任务标识、异常堆栈、执行业务主键。例如CompletableFuture.supplyAsync(() - riskService.check(orderNo), taskExecutor) .thenAccept(isHit - { if (isHit) { riskService.block(orderNo); } }) .exceptionally(ex - { log.error([异步风控] 订单{} 风控检查异常, orderNo, ex); // 兜底降级比如默认放行或默认拦截视业务而定 return null; });这里有两个关键决定一是exceptionally里的兜底策略必须提前和业务方对过不能自己拍脑袋二是异常日志必须包含业务主键否则事后排查都不知道是哪个订单出了问题。4.3 线程池参数还要再调别把默认 ForkJoinPool 用到底CompletableFuture.supplyAsync()不指定线程池时使用的是全局的ForkJoinPool.commonPool()。这个池子的大小是CPU核心数 - 1在 Web 应用里很容易成为瓶颈而且它被整个 JVM 共享多个业务模块都用它互相干扰。我在重构订单详情时特意建了一个business-task-executor线程池线程数按 IO 密集型配置核心 20、最大 50、队列 500跟默认池完全隔离。这样风控模块、订单模块各自用各自的池子互不抢占。5. 高并发下的异步削峰消息队列与线程池的组合拳5.1 不要用 Async 硬扛秒杀流量前面提过Async本质还是进程内线程池扛不住跨实例的大流量。单机线程池再大也就几百个线程而秒杀流量往往是几万甚至几十万 QPS。这时候正确的异步是引入消息中间件把请求先写到 MQ由消费端按自身能力慢慢处理。我当时负责了一个秒杀预约系统当时的架构是Nginx → SpringBoot 网关 → 预约服务 → RabbitMQ → 库存服务。用户点预约预约服务把orderId userId sessionId快速发到 MQ 就返回预约成功库存服务和订单服务各起一个消费者自行控制消费速率。最大的好处是即使下游库存服务挂了消息还在 MQ 里不会丢等它恢复后继续消费做到系统柔性可用。5.2 消费者的线程池参数设计思路消费者侧我用RabbitListener搭配手动concurrency配置。在 yml 里大致这样配spring: rabbitmq: listener: simple: concurrency: 10 max-concurrency: 30 prefetch: 50prefetch50表示每个消费者一次性从 broker 拉 50 条消息存本地内存消费完一批再拉下一批。这个值不是越大越好太大会导致消息堆积在消费者内存里如果消费者宕机这 50 条消息可能丢失除非开启手动 ack 和 requeue太小则网络往返频繁吞吐上不去。我当时压测下来prefetch50配合 10~30 消费者能稳定吃掉每秒 8000 条预约消息处理耗时均小于 100ms。5.3 消费失败的重试与死信队列消息消费失败必须显式处理否则会陷入死循环。我在项目里的标准做法是消费者先 try-catch业务代码抛出的可重试异常比如下游超时就throw new AmqpRejectAndDontRequeueException配合 Spring 的重试机制不可重试异常比如参数非法直接记录日志并 ack 掉或者转发到死信队列由专门的补偿程序处理。一个常见配置是spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3 initial-interval: 1000 multiplier: 2这种策略在实际故障演练中表现不错下游故障 30 分钟消息积压到几十万条恢复后 10 多分钟全部消费完吞吐没有被打崩。核心思路是宁可让消息多等也不能让消费者被单条坏消息卡死。6. 定时任务与异步的组合Spring Task 线程池调优6.1 定时任务里跑长耗时任务一不小心就重叠执行Spring Boot 的Scheduled默认在单线程调度器中执行。如果你配了多个Scheduled方法默认会排队执行如果其中一个方法跑得慢其他定时任务全部被卡住。更隐蔽的问题是轮询类定时任务处理耗时长于轮询间隔下一轮触发时上一轮还没结束导致任务重叠执行。我当时遇到过一例清点缓存的任务每 5 分钟跑一次但某一次数据量异常增大跑了 12 分钟还没结束新一轮又启动了两个线程同时操作同一批缓存 key直接导致生产缓存雪崩。解决方案有两步。第一步Scheduled方法内部用tryLock比如 Redis 分布式锁保证同一时刻只有一个实例在跑Scheduled(cron 0 */5 * * * ?) public void cleanCache() { String lockKey job:cleanCache; boolean locked redisLock.tryLock(lockKey, 10, TimeUnit.MINUTES); if (!locked) { log.warn([cleanCache] 上一次任务还未结束跳过本次执行); return; } try { // 业务逻辑 } finally { redisLock.unlock(lockKey); } }第二步给Scheduled配置独立的线程池避免一个慢任务卡死其他全部定时任务。配置方式就是在启动类或配置类里提供一个TaskSchedulerBean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(scheduled-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); return scheduler; }6.2 定时任务里再发异步任务注意事务上下文丢失Scheduled方法里调Async方法跟普通主线程调异步是同样的问题如果定时任务方法本身在事务里异步线程里拿不到连接。我一般让定时任务只做扫描 提交两件事具体的业务处理放在异步线程或 MQ 消费者里各自开新事务谁失败都不影响谁。7. 异步任务的上下文传递ThreadLocal、TraceId 和请求信息7.1 为什么子线程拿不到 RequestContextSpring Boot 的RequestContextHolder、SecurityContextHolder默认都是存在ThreadLocal里的。Async线程池里的线程复用时如果不去显式传递这些上下文全部丢失。日志系统里尤其明显主线程有条 TraceId子线程打日志时 TraceId 是空的串联不起来。我做的最多的方案是自定义一个TaskDecorator在线程池提交任务时把主线程的上下文快照复制到子线程。核心代码如下Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-task-); executor.setTaskDecorator(runnable - { // 在主线程保存上下文 MapString, String contextMap TraceContextHolder.get(); // 包装任务在子线程恢复上下文 return () - { try { TraceContextHolder.set(contextMap); runnable.run(); } finally { TraceContextHolder.clear(); } }; }); executor.initialize(); return executor; }这样只要所有异步任务都走这个线程池TraceId 就能跨线程串起来排查问题至少不用满日志翻。7.2 MDC 信息的跨线程传递如果你用 Logback 的 MDC原理一模一样。给线程池加装饰器在wrap里MDC.getCopyOfContextMap()在run里MDC.setContextMap(...)。实测下来对性能影响很小但排查效率提升巨大强烈建议所有生产项目都加上。7.3 别图省事用全局静态变量传请求数据之前有个同事为了省事直接在Async方法里用了一个静态ThreadLocal去拿用户 ID结果因为线程池复用上次请求的用户信息泄漏到了下一次请求。这种事故很吓人——用户 A 的操作日志可能记录成用户 B。所以上下文传递必须遵循两个原则一是快照二是清理。只 set 不 clear 的写法等于埋雷。8. 排查异步问题的完整链路从现象到根因的一次实战复盘8.1 现象接口偶尔超时但 JVM 线程数暴涨某次线上告警核心下单接口 P99 从 200ms 涨到 5s同时机器 Load 飙到 30。看监控发现 Tomcat 线程数涨到 200 满堆内存正常但线程数持续不降。初步怀疑是某个环节线程阻塞但代码里主流程看起来全是同步调用。8.2 线索日志出现大量异步任务异常堆栈翻日志时注意到线程名乱成一团有大量async-task-45、async-task-102的线程在反复打印同一类异常TaskRejectedException。这说明异步线程池的队列满了拒绝策略生效了。但我当时配置的是CallerRunsPolicy任务不该丢只会在调用线程里同步执行——这就是 Tomcat 线程被拖满的原因。8.3 根因队列容量设置过小 下游响应变慢继续深挖发现下游库存预占接口某个时段 RT 从 30ms 涨到了 800ms。异步任务里恰好在等这个接口结果单个任务执行时间拉长线程池里的任务堆积队列 200 很快填满然后新任务就只能用CallerRunsPolicy在主线程同步执行彻底堵死 Tomcat 线程。这个问题的完整修复方案给下游接口加超时熔断RT 超过 500ms 直接降级避免单个慢接口拖死整条链路。异步线程池重新调参队列扩到 800最大线程数提到 32保证突发流量有缓冲。异步任务内部加上超时控制用CompletableFuture.orTimeout(2, TimeUnit.SECONDS)强约束每个任务的最长执行时间。告警规则补上线程池活跃度 70% 持续 5 分钟的监控提前发现线程池打满迹象。8.4 复盘结论异步问题不是加了注解就完事这次事故的根子是我在设计异步执行器时只考虑了平均流量没有考虑下游故障放大效应。异步任务的稳定性高度依赖下游调用的稳定性一个慢依赖就能通过线程池传染给整个服务。所以异步线程池参数、超时控制、熔断降级这三件事必须捆绑设计。9. 落地异步改造的最优顺序从审计现状到灰度上线9.1 第一步梳理现有同步链路里的可并行点拿一个接口的调用链画出耗时分布找出哪些调用之间确实没有依赖关系。比如查订单和查用户信息是独立的可以并行查订单之后再查订单明细是对同一个数据源的连续查询不建议拆。9.2 第二步小流量灰度观察线程池和 Tomcat 指标异步改造影响面不小我习惯用配置开关控制。比如在配置中心放一个async.enabletrue/false灰度阶段先让 20% 流量走异步链路对比 P95、线程池活跃度、异常率。确认指标没问题再逐步放开。9.3 第三步监控告警必须提前布好至少要补四类监控监控项作用线程池活跃任务数看是否接近队列上限线程池拒绝次数拒绝策略触发次数飙高说明容量不够异步任务超时率下游变慢的直接体现异步任务异常数量代码本身和依赖问题的窗口我见过太多项目上线了Async却完全没有监控线程池被打满到服务挂掉才发现。宁可多配几条告警也不要让异常静默生长。10. 几个高频问题的速查答案与避坑建议10.1 Async 方法有返回值怎么办方法有返回值时Async生效后返回的是Future比如CompletableFuture调用方拿到后可以继续thenApply编排。但返回值用void是最省心的因为Async对返回类型的处理有限制void最不会出幺蛾子。需要返回值且要处理结果就用CompletableFuture。10.2 异步任务数量大Executor 怎么选JDK 内置的ThreadPoolExecutor够了Spring 封装过的ThreadPoolTaskExecutor更友好支持TaskDecorator、优雅关闭等。不建议直接用Executors.newFixedThreadPool它的无界队列在任务积压时就是个隐患。10.3 用了 Async 后单元测试不好写Async测试时会真的起线程池导致测试结果不稳定。我的做法是测试代码里注入一个核心线程 1 的同步执行器或者直接在测试配置里把异步执行器替换为SyncTaskExecutor。至少测试逻辑本身不依赖线程调度顺序。10.4 Spring Boot 版本升级时要注意什么不同版本对Async的线程池默认行为有细微差异比如 Spring Boot 2.1 以后默认支持更完善的异步异常处理。升级时我会专门扫一遍项目中所有用到了异步、线程池、Scheduled的地方逐个验证线程池 Bean 是否被正确注入不要想当然认为旧配置能直接搬过去。10.5 一个兜底原则异步不等于丢数据凡是对业务结果有影响的异步任务一定要有补偿机制。本地消息表、定时任务对账、MQ 重试三选一必须有。压测环境可以只测吞吐生产环境必须考虑机器重启掉了、任务执行一半挂了怎么办。最后补一个我用得非常多的小工具视角每次写完异步代码我会手动模拟一次异步线程池拒绝——把队列容量调成 1然后并发提交 10 个任务最后检查业务数据完整性。这个动作花不了几分钟但能及时发现异常被吞事务没生效上下文丢失这一类问题。异步改造不是把Async加上去就结束它要求你对线程池参数、异常兜底、数据一致性、上下文传递有一个整体的掌控。我踩过的那些坑写出来就是希望你能一次跳过。