
直接说结论Spring Boot里的“异步操作”看着简单实际坑多。网上教程一抓一大把但十个里有八个只讲Async怎么加不讲它为什么失效不讲线程池怎么配更不讲回调、异常、事务、上下文传递这些真正要命的东西。这篇不是复制官方文档是我把实际项目里用Async、CompletableFuture、消息队列做异步的踩坑记录和最终方案整理了一遍你能直接抄作业。1. 内容整体设计与思路拆解1.1 先搞清楚你到底需不需要“异步”很多新手一听到“接口响应慢”第一反应就是“加个异步不就行了” 这个想法很危险。异步的本质是牺牲实时性换吞吐量或者牺牲代码直观性换响应速度。如果用错了场景它带来的问题比它解决的还多。我先给你一个判断标准适合异步发邮件、发短信、写操作日志、调用外部接口且不关心结果、批量处理数据、定时任务拉取数据后再分发。不适合异步用户点击“立即支付”后需要立刻知道结果、查询订单状态后要立刻返回给前端、需要在一个事务里保证强一致性的写操作。我见过最离谱的用法是把用户注册后的“初始化默认角色和权限”做成了异步。结果用户刚注册完前端立刻去查角色查不到报错。这就是典型的“假异步优化”——你觉得响应快了1秒实际上把Bug引入了线上。所以第一件事不是学怎么写异步代码而是判断你的场景该不该异步。1.2 Spring Boot 异步操作的几条路线怎么选Spring Boot里做异步不是只有Async一条路。我做项目时一般按下面的逻辑选型场景推荐方案理由单个业务方法异步执行发邮件、记日志Async 自定义线程池侵入最小代码最简单多个异步任务并行执行后汇总结果CompletableFuture自带编排能力allOf/anyOf/join 好用需要消息削峰、解耦、跨系统通知MQRocketMQ / RabbitMQ / Kafka异步只是副产品核心是解耦和削峰定时任务批量异步执行ScheduledAsync配合使用避免定时任务阻塞线程池需要异步回调、任务状态跟踪SpringApplicationEvent或自研状态机事件驱动比裸异步更可控这篇重点讲前两种Async和CompletableFuture。因为它们是在“不引入额外中间件”的前提下最快落地、最容易出效果的方案。MQ方案在文末单独讲一讲思路。1.3 为什么默认情况下你写的 Async 会失效这是面试高频题也是线上最容易翻车的地方。失效的根本原因是一个词代理。Spring的Async是靠AOP动态代理实现的。Spring容器启动时会为目标Bean生成一个代理对象调用Async方法时实际调用的是代理对象里的增强逻辑——先把任务丢进线程池再返回。问题来了代理只在“外部调用”时生效。如果你在同一个类里写了一个方法这个方法内部又调用了自己类的另一个Async方法这个调用是this.method()直接走原始对象不走代理对象。于是异步注解失效方法变成了同步执行。Service public class OrderService { // 这种调用方式asyncMethod 永远不会异步 public void createOrder() { // 一些业务逻辑 this.asyncMethod(); } Async public void asyncMethod() { // 异步执行的内容 } }解决办法也很简单要么通过注入自己的代理对象Autowired private OrderService self要么直接用ApplicationContext.getBean()拿到代理对象再调用要么就把异步方法拆到另一个独立的Service类里。最推荐最后一种代码结构最清晰。2. 核心细节解析与实操要点2.1 Async 的生命周期从注解到线程池Async执行逻辑拆开来看就三步Spring启动时扫描到EnableAsync注解开启异步支持。容器创建Bean时如果检测到方法上有Async就为这个Bean生成代理对象。代理对象拦截到方法调用从容器里拿一个线程池执行器把方法提交进去执行。注意第3步里的“线程池执行器”。如果你没有自定义线程池Spring默认使用SimpleAsyncTaskExecutor。这个名字很有迷惑性实际上它不是真正的线程池——它不会复用线程每次来一个任务就new一个线程执行任务多时就无限创建线程直接能把内存和CPU拖垮。这就是为什么凡是讲Async的文章都会强调一点必须自定义线程池。2.2 线程池参数到底怎么定网上聊线程池参数上来就是corePoolSize10, maxPoolSize20, queueCapacity100跟背口诀一样。但实际项目里参数要按业务量来估算。我给一个经验公式参考核心线程数 CPU核数 1IO密集型或者 CPU核数 * 2计算密集型最大线程数 核心线程数 * 2不要盲目加大队列容量 核心线程数 * 100 到 1000取决于容忍多少任务排队真实场景里IO密集型任务调外部API、读写数据库、发邮件占绝大多数所以线程数可以放宽。但要注意CompletableFuture的并行编排。我自己的一个标准配置模板长这样Configuration EnableAsync public class AsyncConfig { Bean(businessAsyncExecutor) public ThreadPoolTaskExecutor businessAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数日常请求量估算不要贪多 executor.setCorePoolSize(8); // 最大线程数核心线程满了、队列满了才会扩展到最大 executor.setMaxPoolSize(16); // 队列容量核心线程扛不住时先排队而不是立刻新建线程 executor.setQueueCapacity(200); // 线程名前缀排查问题时一眼看出是哪个线程池 executor.setThreadNamePrefix(biz-async-); // 空闲线程存活时间默认60秒不用改 executor.setKeepAliveSeconds(60); // 拒绝策略核心线程队列最大线程都满了怎么办 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }CallerRunsPolicy的意思是如果线程池满了任务不丢弃而是由提交任务的线程也就是请求线程自己去执行。这个策略的好处是提供一种简单的自我降级能力保证了任务不丢失代价是请求线程会被阻塞。线上对于非核心业务我一般用这个核心业务我宁可任务丢弃后用告警通知也不能拖垮主流程。还有一个细节ThreadPoolTaskExecutor的initialize()方法在Bean返回时会自动执行但如果你手动new了之后直接返回没调用initialize()可能会出现初始化不完整的问题。加上这句没坏处。2.3 为什么我推荐用 CompletableFuture 而不是裸 AsyncAsync适合“点对点”的异步一个任务丢出去不管了。但真实业务里更多是“一组异步任务做完了我才能继续”——比如查订单详情时要同时调商品服务、用户服务、优惠券服务、物流服务都返回了再拼装结果。这种场景用Async就不好写了。你需要CountDownLatch或者自己维护Future的轮询逻辑代码丑且容易出问题。CompletableFuture就是Java 8留给我们的礼物public OrderDetailVO getOrderDetail(String orderId) { // 并行查询多个维度 CompletableFutureOrderInfo orderFuture CompletableFuture .supplyAsync(() - orderService.getOrderInfo(orderId), businessAsyncExecutor); CompletableFutureUserInfo userFuture CompletableFuture .supplyAsync(() - userService.getUserInfo(orderId), businessAsyncExecutor); CompletableFutureCouponInfo couponFuture CompletableFuture .supplyAsync(() - couponService.getCouponInfo(orderId), businessAsyncExecutor); // 等待所有任务完成 CompletableFutureVoid all CompletableFuture.allOf(orderFuture, userFuture, couponFuture); all.join(); // 分别取值返回 OrderDetailVO vo new OrderDetailVO(); vo.setOrder(orderFuture.join()); vo.setUser(userFuture.join()); vo.setCoupon(couponFuture.join()); return vo; }注意supplyAsync默认用的是ForkJoinPool.commonPool()这个线程池是全局共享的被其他框架也在用多个业务都往这里提交任务时可能会有互相影响或者打满资源的问题。所以实际项目中一定要传第二个参数指定自己的业务线程池。这个坑我踩过线上一个促销活动接口把公共线程池打满其他用parallelStream的接口全部遭殃。allOf配合join()是“全部完成再继续”的模式。还有一个anyOf适合“有一个结果就继续”的场景比如同时查多个数据源谁先返回用谁。2.4 异步方法的异常处理是最容易忽略的坑Async方法的异常处理和普通方法不太一样。调用方如果用的是Future或CompletableFuture异常会被封装在返回对象里由get()或join()抛出。但如果你的Async方法返回void情况就尴尬了异常会被吞掉。Spring的处理方式是如果方法返回void异常会交给AsyncUncaughtExceptionHandler处理。默认的处理器只是把异常打印到日志里然后就没了。如果你不写自定义处理器线上就是——“方法没执行完但没人知道”。我的标准做法是Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { return businessAsyncExecutor(); } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) - { // 这里接住所有 void 异步方法的异常 log.error(异步方法执行异常, method{}, params{}, method.getName(), params, ex); // 发告警比如钉钉/微信/邮件 alertService.sendAlert(...); }; } }提示一句AsyncConfigurer接口里getAsyncExecutor()返回的executor会被Async不指定value时全局使用。你在实现这个接口时如果返回nullSpring会走默认的SimpleAsyncTaskExecutor那就踩坑了。3. 实操过程与核心环节实现3.1 完整示例一个可靠的Async配置下面的配置是生产环境可以直接用的版本兼顾了业务线程池隔离、异常捕获、参数可控Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Value(${async.executor.core-size:8}) private int coreSize; Value(${async.executor.max-size:16}) private int maxSize; Value(${async.executor.queue-capacity:200}) private int queueCapacity; Override Bean(businessAsyncExecutor) public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(coreSize); executor.setMaxPoolSize(maxSize); executor.setQueueCapacity(queueCapacity); executor.setThreadNamePrefix(biz-async-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new AsyncUncaughtExceptionHandler() { Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { log.error(异步任务执行失败method{}, params{}, method.getName(), params, ex); } }; } }setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(60)这两个参数很多人不设置。它们的作用是应用关闭时等已经在执行的异步任务执行完再关闭最多等60秒。不设置的话Spring容器一关线程池直接销毁正在跑的任务没了数据可能就丢了。3.2 异步方法怎么给调用方返回结果返回结果的Async方法有两种写法第一种返回FutureAsync(businessAsyncExecutor) public FutureString processAsync() { // 模拟耗时处理 Thread.sleep(1000); return new AsyncResult(done); }调用方FutureString future service.processAsync(); String result future.get(3, TimeUnit.SECONDS);AsyncResult是Spring对Future的一个简单实现。注意get一定要传超时时间不然线程阻塞了接口也跟着一直挂着。第二种返回CompletableFutureAsync(businessAsyncExecutor) public CompletableFutureString processAsync() { String result doSomething(); return CompletableFuture.completedFuture(result); }调用方直接用thenApply、thenAccept、whenComplete注册回调不需要主动join。比如service.processAsync() .thenAccept(result - log.info(异步结果{}, result)) .exceptionally(ex - { log.error(异步异常, ex); return null; });注意一个核心点如果你的Async方法返回CompletableFutureSpring会感知到这个类型直接把方法内的返回值作为异步结果。所以方法体里不需要再用supplyAsync包一层直接同步执行完返回completedFuture即可。3.3 事务和异步不在一个线程里事务就不归你管这一条太重要了。异步方法和事务注解同时使用时事务会失效或者在不同线程里独立开启事务。原因很简单Spring事务是基于ThreadLocal实现的事务上下文是绑定在当前线程上的。Async把方法提交到新线程执行原来的线程的事务上下文根本传递不过去。所以写代码的时候要脑子里有个弦如果你的异步方法是独立的纯操作发邮件、写日志、调外部接口不需要和主流程共享事务那没问题。如果你的异步方法要修改数据库注意它自己会开启一个全新的事务如果方法上有Transactional和主流程的提交/回滚彼此独立。主流程回滚了异步方法可能已经提交了。有一个解决方案是手动把事务上下文传递到子线程比如TransactionTemplateTransactionSynchronizationManager但代价是代码复杂度上去好几倍不建议新手尝试。我通常的做法是异步方法不开启依赖主事务的操作必要的数据在调用前就查好传参传入异步方法内部自己管理自己的事务边界。这里提供一个非常实用的经验如果异步任务需要依赖主流程事务提交成功后再执行可以在事务提交后发布事件。Spring的TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)可以做这件事比在异步方法里猜主事务是否提交靠谱得多。3.4 异步 定时任务的组合拳Spring Boot的Scheduled定时任务默认是单线程串行执行的。如果你有多个定时任务而且其中一个任务执行时间很长其他定时任务会被阻塞到它做完为止。解决方式就是给定时任务加上AsyncComponent public class ScheduledTasks { Async(businessAsyncExecutor) Scheduled(cron 0 0/5 * * * ?) public void task1() { // 每5分钟执行的任务 } Async(businessAsyncExecutor) Scheduled(cron 0 0 1 * * ?) public void task2() { // 每天凌晨1点执行的任务 } }这样不同定时任务之间互不阻塞。但注意如果你有多个定时任务同时跑businessAsyncExecutor的线程池要适当调大别被定时任务占完了。我习惯单独给定时任务分配一个独立的线程池比如Bean(scheduledAsyncExecutor)这样隔离性更好。4. 常见问题与排查技巧实录4.1 问得最多的Async 为什么没有异步效果这个问题的排查路径我整理成了清单按顺序查有没有在启动类或配置类上加EnableAsync没加就是白搭。Async方法是不是被同类内部调用如果是参考前面提到的拆独立Service。Async方法是不是privateSpring代理对private方法无效必须是public。调用方是不是直接new出来的对象必须是Spring容器管理的Bean代理才存在。异步线程池有没有拿对如果多个Bean返回ThreadPoolTaskExecutorAsync(beanName)没指定时用的是AsyncConfigurer里定义的还是默认线程池要确认。其中第5条需要展开说。当你实现了AsyncConfigurer接口并且自定义了getAsyncExecutor()那Async不指定beanName时用的就是你定义的这个。如果没实现AsyncConfigurer而是在配置类里定义了多个ThreadPoolTaskExecutor的BeanSpring会找唯一的一个如果没有唯一的就会用默认的SimpleAsyncTaskExecutor——这种情况最常见代码里配了好几个线程池结果全走默认了。4.2 异步任务一多就报错RejectedExecutionException报错信息长这样java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTaskxxxx rejected from java.util.concurrent.ThreadPoolExecutorxxxx意思是核心线程满了队列满了最大线程也满了任务被拒绝执行。排查思路打日志看一下线程池的活跃度ThreadPoolTaskExecutor有getActiveCount()、getQueue()这些方法可以在监控接口里暴露出来。确认拒绝策略是不是适合你的场景。AbortPolicy默认直接抛异常、CallerRunsPolicy调用者执行、DiscardPolicy丢弃、DiscardOldestPolicy丢弃队列里最老的。如果任务真的很多且不能丢考虑加大maxPoolSize和queueCapacity或者用MQ做缓冲。我建议任何线程池配置里都加上监控和告警线程池拒绝任务数超过阈值就告警别等到线上故障了再发现。4.3 异步方法里的上下文丢失用户信息、请求头全没了这是一个非常容易踩的隐藏坑。比如你在Controller层接收请求然后丢一个异步任务出去异步线程里想通过RequestContextHolder.getRequestAttributes()拿到当前请求的HttpServletRequest拿到的是null。原因不复杂RequestContextHolder默认把请求上下文存在ThreadLocal里子线程拿不到父线程的ThreadLocal。解决方式有人这么做调用异步之前把需要的上下文数据取出来放到参数里传进异步方法。这是最直观、最不掉头发的做法。如果确实需要在子线程里保留请求上下文可以用TaskDecorator给线程池配置一个“装饰器”在执行任务前把主线程的上下文拷贝过去ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); ... executor.setTaskDecorator(runnable - { // 主线程的上下文 RequestAttributes context RequestContextHolder.getRequestAttributes(); return () - { try { // 拷贝到子线程 RequestContextHolder.setRequestAttributes(context); runnable.run(); } finally { RequestContextHolder.resetRequestAttributes(); } }; });注意跨线程传递的对象如果里面有不能跨线程共享的资源比如数据库连接、网络连接拷贝上下文是没用的。拷贝的大多是基础数据比如用户ID、租户ID、traceId。这些是常见的需要穿到异步线程里的。4.4 分布式环境下的异步别用异步注解了如果你的服务部署了多个实例Async只能保证“在当前实例的线程池里异步执行”跨实例是用不了的。比如一个定时任务在A机器上执行把消息通过Async发出去B机器上的业务是无法从A机器的线程池中感知到这个任务的。分布式异步场景的正确做法是引入MQ生产端发送消息不在乎消费端谁来处理。消费端拉取消息处理完成后可以手动ack保证了“至少一次”的投递语义。多个消费实例天然负载均衡。Spring Boot整合RabbitMQ非常顺畅核心步骤我简单说一下引入依赖spring-boot-starter-amqp。配置连接信息。定义一个Queue和Exchange绑定好。生产者用RabbitTemplate.convertAndSend(exchange, routingKey, message)发消息。消费者用RabbitListener(queues xxx)监听队列方法上可以加Async实现多线程并发消费。这里有一个点MQ的消费者本身就是异步的如果只是为了异步发邮件这种需求引入MQ有点“杀鸡用牛刀”了。我的建议是单机应用用Async分布式应用、需要削峰、需要解耦的才考虑MQ。4.5 ThreadLocal 内存泄漏问题异步场景配上ThreadLocal用完了不清理内存泄漏是迟早的事。线程池里的线程会复用ThreadLocal变量如果不清除下个任务就会读到上个任务残留的数据轻则数据错乱重则OOM。比如你在线程里设置了traceId任务执行完了没有remove线程被归还到线程池后下一次别的任务拿到这个线程会带着上一个任务的traceId继续跑。排查问题时你会看到链路追踪的日志混杂在一起非常崩溃。正确用法是private static final ThreadLocalString TRACE_ID new ThreadLocal(); try { TRACE_ID.set(traceId); // 业务逻辑 } finally { TRACE_ID.remove(); }如果用了TaskDecorator记得在finally里释放上下文。这是一个很小的细节但线上踩过一次就长记性了。5. 面试进阶这几个知识点让你说清楚异步原理标题里提到了“springboot面试题”这个主题确实是面试重灾区。简单补充几个能拉开差距的知识点不管你是为了应付面试还是为了把技术细节啃透都会有帮助。为什么EnableAsync是异步的开关不加EnableAsync时容器根本不会创建代理对象Async注解只是一个普通的标记不生效。EnableAsync的作用是引入AsyncAnnotationBeanPostProcessor这个后置处理器会扫描容器里的Bean找到带Async注解的方法并生成代理。什么是proxyTargetClassEnableAsync(proxyTargetClass true)时强制使用CGLIB代理。如果是接口代理JDK动态代理异步方法必须在接口里定义才能被代理到。实际开发中类内部调用和接口设计会影响代理方式理解这个有助于排查“为什么有时候异步生效有时候不生效”。异步和消息队列之间的边界在哪里如果异步操作只是你自己服务内部的一个加速手段用线程池就够了。如果异步操作涉及多个服务之间的协作并且需要保证消息不丢、可重试、可追溯那必须上MQ。一句话总结Async解决的是“线程调度”MQ解决的是“系统间解耦”。CompletableFuture 和 CountDownLatch 怎么选CountDownLatch更像一个“大门闩”等所有线程执行完就放行适合简单的“等所有任务结束”CompletableFuture则支持更丰富的编排比如两个任务都完成了再合并结果、其中一个完成了就先处理、任务失败了如何降级。Spring 5之后的异步方法可以返回CompletableFuture和CompletableFuture的原生能力结合得很紧密。6. 补充几个实际项目里能用上的小经验这一节唠点纯实战细节都是踩过的坑换来的。日志traceId的传递值得多说一嘴。日常开发中为了方便排查问题会给每个请求生成一个traceId放在MDC里。日志配置里加上[%X{traceId}]所有日志都能串起来。但异步线程默认拿不到MDC可以通过TaskDecorator把MDC的上下文传递到子线程。具体思路参考前面的RequestContextHolder装饰器写法只是把操作对象换成MDC.getCopyOfContextMap()。线程池命名的习惯要养成。线程名字用业务前缀区分order-async-、notify-async-、scheduled-async-。出问题时看到线程名一秒定位是哪个线程池不用猜。外呼接口的异步调用要格外注意超时。外部HTTP调用的超时时间要设置得比线程池等待时间更长否则异步线程还在等接口返回调用方已经超时放弃了接口回来了数据也没人处理。异步方法的幂等性设计不要忘。线程池满了拒绝任务时如果采用重试策略要考虑消费者是否重复处理。比如发邮件这种操作重复发一次用户还能忍但如果是扣款操作就要格外小心。好的异步设计默认是带上幂等校验的。Async和Transactional写在一个方法上事务一定会失效吗准确说是“在异步线程里原本的事务上下文不会传递过去”如果异步方法自身标注Transactional会重新开启一个事务。但如果两个注解同时放在同一个方法上Spring的代理顺序会影响实际行为搞不清楚的时候拆开写把调用事务方法的地方和异步方法的边界拆清楚千万别叠在一行里。任务执行结果是null但明明是异步返回了。这种情况先检查方法签名是不是写成了void然后指望get()拿到值还是Future.get()返回值本身就是null。区分这两种情况排查方向完全不一样。最后分享一个我从实战中总结出来的配置经验线程池参数不是一次定死的而是基于观测数据调整的。上线后加上线程池监控观察高峰期核心线程数使用率、队列积压量、拒绝任务数再反推调整corePoolSize和maxPoolSize。别凭感觉配参数先跑一段看数据再定最终值。Spring Boot的异步操作从Async到CompletableFuture再到MQ是一个能力逐渐增强、复杂度也逐渐上升的演进过程。我的建议是团队里所有人先搞清楚Async的代理机制、线程池参数、异常处理这三件事再来讨论业务上要异步什么、怎么异步这样踩坑的次数至少能少一半。