很多人在 SpringBoot 里做异步调用上来就搜“Async 怎么用”加个注解发现好像能跑就以为完事了。直到线上接口莫名其妙超时、线程池被打满、或者异步任务丢了异常连日志都找不到才开始回头排查。这篇文章我就把 SpringBoot 异步调用这摊事从头到尾讲清楚——不只是注解怎么加而是从线程模型、线程池配置、任务编排到真实项目里最容易翻车的地方完整过一遍。适合刚接触异步调用、准备改造接口性能的同学也适合已经用了 Async 但总感觉哪里不对、想系统排查一遍的老手。1. 为什么明明有线程池还要单独聊异步调用——先分清两个并发1.1 Tomcat 线程池不等价于业务线程池SpringBoot 的 Web 项目跑起来之后内嵌的 Tomcat 会维护一组工作线程。每个 HTTP 请求从进入到返回会独占一个 Tomcat 工作线程。默认配置下这个值一般是 200也就是说这一刻最多同时处理 200 个请求第 201 个请求就得在队列里等着。很多团队一开始觉得我有 Tomcat 线程池就认为并发不是问题。但这里有个容易被忽略的细节Tomcat 线程只负责从接收到响应这个完整生命周期。如果你的接口里同步调了另一个远程服务比如查数据库、调网关、打第三方 API这一整段时间里Tomcat 线程就卡在那里干等什么都不干。打个比方你开了一家快递站每个快递员送一个件如果这个件需要去仓库取、再送到客户手里、再等客户签收那这个快递员全程只能服务这一单。仓库再忙快递员也腾不开手。这就引出了异步调用的本质场景把必须耗时的操作从请求线程上摘走让请求线程立刻返回空闲出来服务下一个请求。1.2 什么是真正意义上的异步调用在 SpringBoot 里异步调用指的是调用方把任务丢给另一个线程去执行自己不等待这个任务的结果立刻继续往下走。从调用方视角看方法调用的返回时间不取决于任务本身的耗时任务的实际执行发生在另一个线程上。这里要澄清一个常见的误解。有人觉得我用了多线程就是异步了。不对。很多人在 Controller 里new Thread(() - xxx).start()这确实新建了一个线程但严格说这是最原始的、不可控的线程使用方式。SpringBoot 层面的异步调用核心价值在于把线程管理收拢到统一线程池里让任务的提交、执行、排队、拒绝、监控都有一套标准机制而不是每次请求都 new 一个裸线程。1.3 哪些场景值得用、哪些场景千万别用先说什么场景值得用异步远程调用类调用第三方接口、RPC 服务、外部网关耗时通常不可控几百毫秒到几秒都可能。IO 密集类读写文件、操作大数据量报表导出、发送邮件短信。不需要同步结果的逻辑用户下单成功后发通知、写埋点日志、刷新缓存这种可以稍后再说的旁路逻辑。定时任务与异步配合定时任务本身是串行的但任务内部多个子任务可以异步并发执行。再说什么场景千万不能用业务链路强一致性比如支付成功后必须立刻落账并返回结果这种不依赖异步依赖的是分布式事务或本地事务。上游需要结果才能继续如果调用方拿不到返回值就没法做下一步那就不叫异步叫并发组装。超低延迟要求的场景异步引入线程切换和队列调度本身有微小开销毫秒级 RPC 反而更合适同步。我个人的经验是一个异步改造项目第一步不是写代码而是把接口链路梳理清楚分清哪些环节可以迟到哪些必须同步等。这一步想不明白后面线程池配置得再漂亮也是白搭。2. Async 注解最快见效但也最容易失效的方案2.1 三步把同步方法变成异步Async 是 Spring 框架提供的最基础的异步方案。整个用法浓缩成三步第一步在配置类或者启动类上加EnableAsync告诉 Spring我要开启注解异步功能。SpringBootApplication EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }第二步在需要异步执行的方法上加Async注解。这里有个考试必考的细节这个方法的类必须被 Spring 容器管理也就是必须是一个 Bean。你可以把它理解成异步能力是通过 Spring 的 AOP 代理实现的代理对象才有资格把方法调用转到线程池。Service public class NotificationService { Async public void sendSms(String mobile, String content) { // 模拟短信发送耗时可能在几百毫秒到几秒 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(短信发送线程: Thread.currentThread().getName()); } }第三步从另一个 Bean 注入这个 Service 并调用。这一步也是经典考点后面我会专门讲为什么这一步有讲究。RestController public class OrderController { private final NotificationService notificationService; public OrderController(NotificationService notificationService) { this.notificationService notificationService; } PostMapping(/order) public String createOrder() { // 下单逻辑... notificationService.sendSms(13800000000, 您的订单已提交); return ok; } }跑起来之后如果看到控制台输出里的线程名是类似order-async-1这种业务线程池的名字而不是http-nio-8080-exec-1这种 Tomcat 线程名说明异步生效了。看日志线程名是验证异步是否生效最快的方式。2.2 为什么自己调自己会让 Async 失效这条坑我见过不止一个团队踩过。写一个内部方法Service public class OrderService { public void createOrder() { // 业务逻辑 this.sendAuditLog(order created); } Async public void sendAuditLog(String message) { // 异步写日志 } }然后发现sendAuditLog根本不起作用它依然是同步执行的。原因要从代理机制说起。Async的异步能力来自 Spring AOP 创建的一个代理对象。你注入外部调用时注入的是代理代理拿到请求后才会把方法调用转发到线程池。但this.sendAuditLog()这种写法调用的是当前对象的原始方法根本没有经过代理对象所以注解被直接无视了。正确做法是要么把异步方法拆到另一个 Bean 里注入调用要么在类内部注入自身的代理对象比如通过Lazy自注入Service public class OrderService { Autowired Lazy private OrderService self; public void createOrder() { self.sendAuditLog(order created); } Async public void sendAuditLog(String message) { // 异步写日志 } }用Lazy是为了避免循环依赖的坑。这个知识点在 Spring 面试里基本是必问项理解了代理机制就能答出真正的所以然。2.3 返回值怎么选void 和 Future 的差异Async 方法的返回值有三类选择void调用方完全不等结果异常默认会被吞掉这是最不推荐的生产用法。FutureT一个异步结果占位调用方可以通过get()拿到值但注意get()会阻塞。CompletableFutureT推荐。它既能拿结果又能做异步编排后文我会重点讲。void 方法为什么危险异步线程里如果抛出异常调用方根本感知不到默认情况下异常也不会进入你平时的全局异常处理。我用一个生产事故来举例曾经有个团队用 Async 发短信结果短信平台的一个 key 过期了每次调用都抛异常但方法返回 void异常被静默吞掉线上看起来毫无波澜直到那段时间用户收不到验证码的投诉量飙升才发现异常已经在日志里刷了整整两天。解决办法要么统一改用CompletableFuture异步方法内部 catch 住异常自己处理要么实现AsyncConfigurer接口提供自定义的AsyncUncaughtExceptionHandlerConfiguration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) - { // 这里做统一告警、日志收集 log.error(异步方法执行异常: {}.{}, method.getDeclaringClass().getSimpleName(), method.getName(), ex); }; } }这个方法只能兜底 void 类型的异常CompletableFuture 的异常走的是 exceptionally 链路两者别混为一谈。3. 线程池配置决定异步系统是救命还是添乱3.1 SpringBoot 默认的异步线程池为什么不适合生产如果只加 EnableAsync 和 Async不配置任何线程池Spring 容器里没有 TaskExecutor 类型的 Bean 时会启用一个默认的执行器。具体来说Spring Boot 的自动配置会在有些情况下装配ApplicationTaskExecutor但如果你的异步方法和这个默认执行器匹配不到位也可能退化成SimpleAsyncTaskExecutor。SimpleAsyncTaskExecutor的问题在于它每次执行任务都会 new 一个新线程不搞线程复用。高并发场景下线程数会不受控地疯涨最终内存和 CPU 都被拖垮。这跟你自己写new Thread()访案没什么本质区别。另一类常见默认行为是Spring 容器里如果有ThreadPoolTaskExecutor类型的 Bean它会自动作为 Async 的执行器如果没有Spring 搜索名为taskExecutor的 Bean再没有就用SimpleAsyncTaskExecutor。我强烈建议作为一个正经 Web 项目永远不要依赖默认选择一定要显式定义线程池。3.2 一个可以抄作业的线程池配置下面这套配置是我多个线上项目里跑过、压测验证过的通用模板可以直接复制改造Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Bean(bizAsyncExecutor) public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数日常流量大部分情况下同时在跑的任务数 executor.setCorePoolSize(8); // 最大线程数突发流量允许扩到的上限 executor.setMaxPoolSize(16); // 队列容量任务排队等待执行的缓冲长度 executor.setQueueCapacity(200); // 非核心线程空闲 60 秒后回收 executor.setKeepAliveSeconds(60); // 线程名前缀排查问题时一眼就能分辨 executor.setThreadNamePrefix(biz-async-); // 拒绝策略任务队列满了之后怎么办 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } Override public Executor getAsyncExecutor() { return bizAsyncExecutor(); } }配置类的核心思路是通过AsyncConfigurer的getAsyncExecutor()提供一个全局默认线程池这样所有没指定线程池的 Async 方法都会走它而不是走到 SimpleAsyncTaskExecutor 去。3.3 核心参数到底怎么定不只是拍脑袋先理清ThreadPoolTaskExecutor的行为逻辑提交任务时如果当前线程数小于核心线程数创建新线程执行如果核心线程已经满员任务进队列排队如果队列也满了继续创建线程直到最大线程数如果线程数到了最大值、队列还是满的触发拒绝策略。为什么不能一次性把最大线程数调很大线程多了有上下文切换成本内存占用也高。线程数的合理范围取决于任务类型CPU 密集型任务核心线程数建议CPU 核数 1因为多出来的线程大多在跟 CPU 抢时间片无益。IO 密集型任务建议CPU 核数 * 2因为任务中大量时间在等待远程响应这时候多开的线程能利用等待窗口做别的事。混合型任务以压测为准从经验公式起步然后压测看 P99 延迟和 CPU 使用率再调参。队列长度的设置也有讲究。队列长等于允许大量任务积压缓冲能力强但是任务排队时间变长可能任务还没被执行调用方已经超时了。队列短任务容易推到最大线程数很快触发拒绝策略。我见过很多初始配置用的是Integer.MAX_VALUE无界队列它的副作用非常隐蔽——任务全被堆积在队列里线程数永远到不了 maxPoolSize看起来线程池很稳其实系统的实时处理能力早就被队列拖垮了。3.4 不同任务怎么用不同的池子一个系统里的异步任务往往不止一种。发短信的任务和报表导出的任务混用一个线程池互相影响是最常见的坑。发短信高峰期把线程池占满了报表导出排到队尾半天出不来。推荐按业务域拆分线程池。比如Bean(notifyAsyncExecutor) public ThreadPoolTaskExecutor notifyAsyncExecutor() { // 发通知的线程池核心线程小一点 ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(notify-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); executor.initialize(); return executor; } Bean(reportAsyncExecutor) public ThreadPoolTaskExecutor reportAsyncExecutor() { // 导出报表的线程池核心线程多给一些 ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(16); executor.setMaxPoolSize(32); executor.setQueueCapacity(500); executor.setThreadNamePrefix(report-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }用到某个池子时在注解里显式声明Async(notifyAsyncExecutor) public void sendSms(String mobile, String content) { // ... }如果没指定名字Spring 会使用全局执行器也就是我们配置的getAsyncExecutor()返回的那个池子。我建议全局兜底池子继续保留但在核心业务上一定显式指名这样后续追踪线程问题时光看线程名前缀就能定位到是哪个业务域的任务在跑。3.5 虚拟线程SpringBoot 3.2 之后的新选项如果你的项目是 JDK 21 Spring Boot 3.2 以上可以了解一下虚拟线程方案。虚拟线程是 JDK 21 正式推出的定位是轻量级线程创建成本比平台线程低得多理论上你可以为每个任务开一个虚拟线程而不用太心疼资源。Spring Boot 3.2 有两个层面的支持。一是全局启用虚拟线程Tomcat 处理请求改用虚拟线程spring.threads.virtual.enabledtrue二是把虚拟线程接入 Async。可以注册一个适配器Bean public AsyncTaskExecutor applicationTaskExecutor() { ExecutorService virtualThreadExecutor Executors.newVirtualThreadPerTaskExecutor(); return new TaskExecutorAdapter(virtualThreadExecutor); }实测下来虚拟线程对 IO 密集型任务的提升非常明显线程调度开销大幅下降。不过它还不是万灵药如果任务里有 CPU 密集计算虚拟线程的优势会缩水另外一些依赖 ThreadLocal 的框架比如安全上下文、事务上下文在虚拟线程下要注意虚拟线程的复制和恢复机制虽然 JDK 已经内置了一部分但和 Spring 的上下文传递还是有配合成本。保守的说新项目可以尝鲜老项目还是先把传统线程池的配置吃透。4. CompletableFuture当异步遇上多任务编排4.1 场景详情页聚合多个远程调用串行太慢我接手过一个电商后台的订单详情接口一个接口里同步串行调用了五个服务订单服务、用户服务、物流服务、商品服务、优惠券服务。链路一次调用平均耗时 500 毫秒用户点开订单详情要转半天圈。这种场景不是任务丢出去就行的异步而是所有结果都要拿回来但希望它们并发去拿。这时候要用 CompletableFuture 做并发聚合。4.2 核心用法从并发执行到结果聚合第一步是并行发请求。每个远程调用放在一个 supplyAsync 任务里Service public class OrderDetailService { private final OrderClient orderClient; private final UserClient userClient; private final LogisticsClient logisticsClient; private final Executor bizAsyncExecutor; public OrderDetailVO getOrderDetail(String orderId) { CompletableFutureOrderDTO orderFuture CompletableFuture.supplyAsync(() - orderClient.getOrder(orderId), bizAsyncExecutor); CompletableFutureLogisticsDTO logisticsFuture CompletableFuture.supplyAsync(() - logisticsClient.getLogistics(orderId), bizAsyncExecutor); // 关键点allOf 等待所有任务完成 CompletableFuture.allOf(orderFuture, logisticsFuture).join(); OrderDTO order orderFuture.join(); LogisticsDTO logistics logisticsFuture.join(); return assemble(order, logistics); } }这里有两个容易踩的细节我一个个说清楚。第一个细节supplyAsync 必须显式传入线程池。不传的话JUC 会使用公共的ForkJoinPool.commonPool()。这个公共池看起来方便但它和其他调用方共享线程数默认是 CPU 核数减一。一旦任务里有阻塞操作公共池线程被占满后同 JVM 里其他地方的并行流、CompletableFuture 全部跟着卡顿。线上坑过我的就是这种全链路神秘迟滞最后定位到是公共池被打满。第二个细节不要一个结果一个结果 get()。正确的做法是先allOf聚合全部 future再各自 join。如果先 get 第一个再 get 第二个等于是先把第一个等完才开始等第二个失去了并发的意义。4.3 异常和超时异步聚合的保命手段远程调用必然涉及失败和超时。CompletableFuture 的异常默认是原地吞掉join 的时候抛出。所以合理的处理链路是给每个任务配异常兜底CompletableFutureLogisticsDTO logisticsFuture CompletableFuture.supplyAsync(() - logisticsClient.getLogistics(orderId), bizAsyncExecutor) .exceptionally(ex - { log.error(查询物流失败 orderId: {}, orderId, ex); // 返回一个默认值或者一个标记了失败的对象 return LogisticsDTO.empty(); });超时同样要处理。JDK 9 提供了orTimeout方法超时后能主动令 future 异常结束CompletableFutureLogisticsDTO logisticsFuture CompletableFuture.supplyAsync(() - logisticsClient.getLogistics(orderId), bizAsyncExecutor) .orTimeout(2, TimeUnit.SECONDS) .exceptionally(ex - LogisticsDTO.empty());如果项目还在 JDK 8可以用get(timeout, TimeUnit)实现相似效果但要注意超时在 get 处触发的话原本的异步任务还在继续跑不会真正中断所以还是建议小步快跑把 JDK 升上去。4.4 和 Async 的配合Service 返回 CompletableFutureCompletableFuture 还有一种用法方法内部不创建 future而是直接返回一个 CompletableFuture配合 Async 使用。Async(bizAsyncExecutor) public CompletableFutureReportVO generateReportAsync(String date) { ReportVO report doGenerate(date); return CompletableFuture.completedFuture(report); }调用方拿到的 CompletableFuture 在方法真正执行完成时才进入完成状态调用方可以做 thenApply、thenCombine 等后续编排也可以把它作为 Controller 的返回体触发 Spring MVC 的异步处理。我个人更喜欢这种写法因为业务方法里不用操心线程池从哪里来线程池由 Spring 管方法里只关心业务逻辑。唯一要注意的是如果异步方法内部要等待多个子任务建议在方法内使用独立的编排而不是把复杂依赖全部暴露给调用方。4.5 线程切换的隐性成本上下文传递异步编排一定会遇到一个隐藏问题ThreadLocal 在线程切换时丢失。比如日志追踪用的 traceId、安全上下文里的用户信息都是存在线程的 ThreadLocal 里的。你从请求线程切到业务线程池那边拿不到 traceId线上排查日志时链路就断了。解决思路是用TaskDecorator在执行任务前把主线程的上下文拷进去任务执行完之后清理掉executor.setTaskDecorator(runnable - { MapString, String contextMap MDC.getCopyOfContextMap(); return () - { try { if (contextMap ! null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { MDC.clear(); } }; });这个装饰器本质上就是一个包装 Runnable 的钩子线程池提交的每个任务都会经过它。对于像安全上下文、语言环境这类信息原理一样只是用的工具不同。从架构角度说只要代码里用了异步就一定要安排一次上下文传递的梳理不然线上排查问题的效率会大打折扣。5. 异步调用在生产里最常见的四个翻车现场5.1 从日志线程名开始的 Async 失效排查链路我总结了一个标准排查顺序遇到异步好像没生效的问题按这个链路走看方法所在类是否为 Spring Bean没有被Service/Component等注解声明的类异步必然无效。看启动类或配置类有没有EnableAsync漏加是最低级但最常见的问题。看方法是不是publicCGLIB 代理无法对非 public 方法生效方法要么报错要么退化为同步。看调用方是不是同类自调用this.xxx()的调用不走代理前面详细讲过。看方法上有没有Transactional等注解叠加导致拦截器排序异常有些组合会让异步拦截器失效。跑一个场景打印日志观察线程名如果是 Tomcat 线程名说明根本没走到线程池。这个排查链路基本覆盖了我见过的所有失效场景。生产上再一次遇到奇怪问题我第一反应都是先看日志线程名再往上按顺序排查效率比盲目改配置高得多。5.2 线程池耗尽、队列堆积怎么提前发现线程池耗尽不像内存溢出那么直观它是慢慢变卡的。你在日志里看到的典型症状是异步任务耗时越来越长接口本身没怎么变慢但任务结果迟迟不落库或者部分任务直接报了拒绝异常。ThreadPoolTaskExecutor 内部就是ThreadPoolExecutor所以底层指标都可以监控到。最简单的做法是暴露几个关键指标到一个 HTTP 接口或者通过 Actuator 接监控系统RestController public class ThreadPoolMonitorController { private final ThreadPoolTaskExecutor bizAsyncExecutor; public ThreadPoolMonitorController(Qualifier(bizAsyncExecutor) ThreadPoolTaskExecutor bizAsyncExecutor) { this.bizAsyncExecutor bizAsyncExecutor; } GetMapping(/monitor/bizAsyncExecutor) public MapString, Object monitor() { ThreadPoolExecutor pool bizAsyncExecutor.getThreadPoolExecutor(); MapString, Object result new HashMap(); result.put(corePoolSize, pool.getCorePoolSize()); result.put(activeCount, pool.getActiveCount()); result.put(queueSize, pool.getQueue().size()); result.put(largestPoolSize, pool.getLargestPoolSize()); result.put(taskCount, pool.getTaskCount()); result.put(completedTaskCount, pool.getCompletedTaskCount()); return result; } }监控的核心是三个信号activeCount 长期贴到 maxPoolSize、queueSize 持续增长、completedTaskCount 涨幅变缓。任何一个信号亮了都要回头看是不是任务量预估错误、线程池参数不合适或者下游响应变慢。5.3 异步方法里的事务为什么经常不生效Spring 事务默认是基于 ThreadLocal AOP 代理实现的。一个 Transactional 方法被调用时事务管理器会在当前线程上绑定一个数据库连接事务的提交和回滚都围绕这个连接展开。当一个 Async 方法里调用了 Transactional 方法会发生两种典型的翻车情况第一种事务边界所在的方法本身是异步的Async Transactional叠加写在同一个方法上。异步拦截器把这个方法丢到了业务线程池同时事务拦截器想在当前线程开启事务。两个拦截器的叠加顺序不明确事务可能跟着异步线程走也可能压根没开启最终结果不可控。我踩过之后的原则很简单这两个注解不要写在一个方法上。要么拆成两层要么在异步方法里自己用TransactionTemplate控制事务。第二种异步任务里调用了另一个 Bean 的 Transactional 方法。如果两个方法不在同一个类通过 Bean 注入调用事务是能生效的因为代理还在但如果你把事务方法和异步方法写在同一个类里用this调用事务同样失效。跟 Async 失效的机制一样也是代理问题。说到底异步和事务是完全相反的两个方向事务要求紧凑关联在一条线程上异步要求跨线程自由切换。两者叠加时一定要想清楚事务到底开在哪一层。5.4 队列满了怎么办拒绝策略不能瞎选我见过很多配置里直接setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy())。AbortPolicy 的意思是队列满了、线程也满了新任务直接抛RejectedExecutionException。在 async 的 void 方法里这个异常会进入异步异常处理器但如果你没配处理器它就消失得无影无踪业务上表现为任务丢了没人知道。下面是几种策略的适用场景AbortPolicy宁可丢失任务也不能阻塞调用方。适合非核心旁路任务但必须配异常处理。CallerRunsPolicy不丢弃任务任务回退到调用方线程执行。但这个策略会阻塞调用方线程如果回退任务很多等于是把异步变回了同步。适合数据一致性要求高、不能丢的任务。DiscardPolicy静默丢弃没有任何提示。除非你能接受丢任务否则别用。DiscardOldestPolicy把队列里最老的任务丢掉新任务入队。适合能容忍丢掉老数据、要最新数据的任务。我自己的习惯是通知类任务用 DiscardPolicy短信条数有限丢几条可以忍数据入库类任务用 CallerRunsPolicy不能丢但要做好线程阻塞的兜底核心链路不依赖异步所以不需要特别注意拒绝策略。6. 从一次故障到架构决策什么时候该上消息队列6.1 JVM 级异步和系统级异步的边界Async 和 CompletableFuture 解决的都是在一个应用进程内部的线程调度问题本质是 JVM 内的异步。它有三个天然边界第一重启即丢。应用进程一重启内存队列里的任务全部消失。如果任务没有落库或者没有补偿机制业务数据就丢了。第二无法横向伸缩。异步任务只有在这台机器自己的线程池里排队负载均衡到另一台机器上的请求不会共享这个队列。第三生产者消费者耦合在同一条部署链路里一个下游接口变慢会拖住所有异步任务最终引发线程池耗尽。如果你遇到这些问题说明异步已经从代码技巧升级为系统架构问题消息队列就该登场了。6.2 用表格看明白怎么选我整理了一个选择参考遇到类似需求可以直接对照需求特征优先用 JVM 异步Async/CompletableFuture优先用消息队列任务是否允许进程重启时丢失不允许丢了有事故风险不允许MQ 持久化能兜底是否需要多个消费者处理同一份消息不需要单机处理足够需要多个订阅方各自处理下游是否稳定基本稳定偶尔抖动不稳定需要削峰填谷、重试是否需要独立扩展某个任务的处理能力不需要整体扩应用即可需要消费者可以独立部署独立扩容任务体量每秒几十到几百个每秒几千上万个突发流量大一句话总结任务量大、要求可靠、消费者的处理能力和生产者解耦上 MQ任务量可控、单机处理够用、能接受进程重启丢任务JVM 异步就够。上 MQ 的架构复杂度是量级提升的别为了几个通知任务就把 Kafka 引进项目。6.3 我的异步改造心路历程最后分享一点个人经验。我刚工作的时候接手一个老系统第一次做异步改造只加了 Async结果线上告警三连没配自定义线程池、方法同类调用、异常全被吞。排查了一周最后同步到领导汇报时候我自己都觉得丢人。后来再改任何系统我都会先在纸上画一张链路图哪些环节必须同步等、哪些可以异步、异步任务能不能丢、丢了对业务有什么影响、任务总量大概多少、下游 P99 是多少。这张图画完再回来定线程池参数、选返回类型、配拒绝策略每一步都有依据而不是拍脑袋。异步调用这个东西看起来是一行注解的事实际上考验的是对线程模型、代理机制、事务边界和系统容错的理解。把这几个点吃透了SpringBoot 异步调用对你就不再是能跑就行而是真正可控、可监控、可运维的架构能力。希望这篇文章里的配置和思路能帮你少走我走过的那些弯路。