ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot异步调用实战:从@Async到线程池、事务与监控的完整指南

Spring Boot异步调用实战:从@Async到线程池、事务与监控的完整指南 异步调用这事好多项目一开始都不当回事。等哪天线上接口突然变慢、调用链路上某个环节卡了五秒才想起来这活儿不该同步干。我这两年经手过好几个项目最典型的一个场景是下单成功之后的通知组合拳——发短信、推消息、记流水、扣库存。全部同步跑完主流程接口直接奔着三秒去了用户端转圈圈后台一堆超时报警。说白了Spring Boot 里做异步调用不是为了炫技是为了把“非关键路径”的活儿从“关键路径”上挪走让请求线程早点释放让用户早点儿拿到响应。这篇我就把实操里验证过、踩过坑的方案完完整整梳理一遍从最简单的 Async 注解到自定义线程池的参数测算再到异常处理和事务组合的那些坑。不管你是刚开始接触 Spring Boot还是已经在项目里用过异步但被莫名问题坑过这篇应该都能给你一些直接能抄作业的参考。1. 先搞清楚异步到底解决的是什么问题很多入门的教程一上来就贴 Async 注解告诉你这样就是异步了。但如果你不清楚这玩意解决的是什么问题很容易在错误的地方用、在正确的地方不敢用。1.1 同步链路里的“隐形浪费”想象一下你公司食堂打饭的窗口。打饭阿姨既要收钱、又要打菜、还要抽空回答“这个菜辣不辣”。每个排队的人都觉得阿姨服务自己花了二十秒其实真正盛菜只要八秒。系统里的请求线程就是阿姨业务逻辑里那些耗时的非核心步骤就是“回答辣不辣”。传统同步调用里一个 HTTP 请求进来容器从线程池里分配一个线程给你这个线程要一路执行完 controller - service - dao 的全部逻辑最后把响应返回。如果 service 里有一个短信接口正常耗时 800 毫秒但对方服务偶发延迟到 3 秒你这 3 秒就白白夯在线程上。用户感受是接口慢后台的感受是线程被占着不放后续请求只能排队等线程。线程池耗尽雪崩就来了。异步要解决的问题本质就两个一个是响应时间让调用方不需要等你不重要的步骤跑完一个是吞吐量别让有限的线程被 IO 等待拖死。1.2 Spring Boot 里异步的三个层级Spring Boot 里做异步从轻到重大致可以分三个层级这篇会逐个展开方法级异步Async 注解 线程池适用于服务内部把某个耗时的 Bean 方法甩给后台线程去执行。Web 层异步DeferredResult、WebAsyncTask、Callable适用于把 HTTP 请求本身变成异步让 Tomcat 线程先归还等业务结果出来了再返回。应用间异步消息队列。这不是 Spring 注解层面的事而是把任务彻底抛给另一个系统去消费适合重试、削峰、解耦。选哪个取决于业务需要。别一上来就上 MQ很多时候 Async 就够用了。2. 最基础的姿势Async 三分钟接入如果你的需求只是把方法内部某个非核心步骤拎出去异步执行Async 是最快的方式。但我得提醒一句三分钟接入是骗人的真正接得好还要解决线程池、异常、事务等问题。先看基础。2.1 开启异步能力启动类或者配置类上加一个注解EnableAsync SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }EnableAsync 是总开关不加这个下面的 Async 一概不生效。另外 Spring Boot 2.1 之后如果你没有自定义线程池默认会去查找容器中的 TaskExecutor 或者名字为 “taskExecutor” 的 Bean都找不到的话它会用 SimpleAsyncTaskExecutor。2.2 一个简单的异步方法Service public class OrderNotifyService { Async public void sendSmsAsync(String orderId) { // 模拟短信发送 Thread.sleep(1000); System.out.println(异步执行发送短信 orderId , 线程: Thread.currentThread().getName()); } }调用的时候注意必须从另一个 Bean 里注入调用或者通过 ApplicationContext 拿代理对象来调用。Service public class OrderService { Resource private OrderNotifyService notifyService; public void createOrder(String orderId) { // 核心业务逻辑 System.out.println(创建订单 orderId); // 异步发送短信不阻塞主流程 notifyService.sendSmsAsync(orderId); System.out.println(主流程结束); } }跑起来你会发现主流程不会等 Thread.sleep(1000)直接打印“主流程结束”短信线程在后台慢慢执行。看起来很简单对吧2.3 为什么用 Spring 而不是自己 new 线程有人会说我不就是想让代码别阻塞吗我自己 new Thread 扔后台不行吗行但问题一堆线程生命周期没人管项目里如果每个接口都 new 一个线程几百个请求下去就是几百个游离线程无上限、无复用、无监控。用 Spring 的线程池至少能统一管理、复用线程、控制最大并发接入监控也方便。提示从 Spring 6.1 / Boot 3.2 开始Async 在相同类内部方法调用会直接走 this 引用注解失效。这个坑我后面单独讲。3. 核心难点自建线程池与参数测算框架给你默认的线程池就像公司给你配了台默认配置的办公电脑。日常 word 打字肯定够了但你要搞视频剪辑它就发烫给你看。异步任务只要稍微带一点 IO 或者并发量上来默认线程池就是第一个要换掉的。3.1 默认的 SimpleAsyncTaskExecutor 为什么不能用Spring Boot 文档里写得很清楚只要容器里没有自定义线城池默认会退到 SimpleAsyncTaskExecutor。这个名字翻译过来是“简单异步”但说得难听点叫“裸奔异步”。它每次调用都会新建一个线程来执行不复用线程池。高并发场景下线程数直接暴涨很快资源耗尽、OOM 或者服务僵死。所以接异步的第一件事不是写 Async是先把线程池定下来。下面给一个我在项目里常用的配置类关键参数我都加了注释说明。3.2 线程池参数怎么定Configuration public class AsyncThreadPoolConfig { Bean(customAsyncExecutor) public ThreadPoolTaskExecutor customAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(16); // 队列容量 executor.setQueueCapacity(1000); // 最大存活时间 executor.setKeepAliveSeconds(60); // 线程名前缀方便排查日志 executor.setThreadNamePrefix(async-order-); // 拒绝策略由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 交给容器管理生命周期 executor.initialize(); return executor; } }参数怎么定这是个值得说道的问题。网上流传的公式是 CPU 核心数 1CPU 密集型或者 CPU 核心数 × 2 1IO 密集型。但我跟你讲这个公式只是一个起点真实生产里最好通过压测来回调。我的做法一般是这么推的先估算核心业务指标比如“每秒需要处理多少个异步任务”和“单个任务的平均耗时”。举个例子假设系统目标是每秒处理 300 个异步通知单个任务平均耗时 200 毫秒那么需要的并发执行线程数至少是 300 × 0.2 60 个。再考虑线程本身有一些创建开销、任务产生的不均匀我会先定核心线程 32、最大线程 64、队列容量 500然后用压测去验证。线程池的线程数和队列是跷跷板。核心线程数定得大响应快但资源占用高队列定得大突发流量都能缓冲但积压任务过多时实时性变差。我的习惯是一个任务的耗时越短越不该依赖队列缓冲直接把线程数调大让任务尽快跑完一个任务耗时越长越要控制并发量防止线程全被长任务占住短任务排队干瞪眼。3.3 拒绝策略选择与队列容量推导拒绝策略要在“任务满到装不下”的时候才触发。ThreadPoolExecutor 自带四种策略行为适用场景AbortPolicy直接抛 RejectedExecutionException不推荐用于业务异步会导致调用方感知异常CallerRunsPolicy任务退回调用者线程执行常用牺牲调用线程但保证不丢任务DiscardPolicy静默丢弃可用于日志等可丢任务DiscardOldestPolicy丢弃最老的任务某些场景下可用但要注意业务丢失我个人在异步通知类任务里倾向于 CallerRunsPolicy。理由很简单任务不能丢丢了用户就收不到通知。退回调用者去执行虽然会拖慢调用方但至少是“慢”而不是“丢”而且这种状态本身就是信号说明线程池压力已经到顶该扩容或降级了。队列容量这块我踩过一次坑。当时图省事把队列设置成 99999想着能缓冲掉所有峰值。结果某天上游系统疯狂打了几个小时的流量队列把积压的任务全装下了看似没拒绝但等真正消费到的时候任务已经过期、用户已经下单了三天通知才姗姗来迟。所以队列不是越大越好。核心线程数 队列的容量 常规情况下系统能吃的并发量超出这个量才动用最大线程数。我一般按预估峰值 QPS × 任务耗时 × 2余量系数来设队列。4. 异步方法内部的“魔鬼细节”基础功能跑通不难真正让开发从“自以为懂了”到“被生产环境教做人”的是下面这几个细节。每一项我都见过真实案例。4.1 this 调用为什么会失效这是 Async 失效的头号原因。你在同一个类内部方法 A 调用方法 BB 上标了 Async但根本不生效。Service public class OrderService { public void handleOrder(String orderId) { this.sendNotifyAsync(orderId); // 不生效其实是 this 调用的自己 } Async public void sendNotifyAsync(String orderId) { // 异步逻辑 } }原因很简单Async 是基于 Spring AOP 做代理实现的。Spring 会为你的 Bean 生成一个代理对象调用异步方法时实际上会经过代理由代理把任务丢给线程池。但 this 调用是直接调目标类自身的方法根本没经过代理对象注解自然就成了摆设。解决方式有三个把异步方法单独放到一个 Service 类里从外部注入并调用。在类内部注入自己的代理比如Autowired或者通过ApplicationContext.getBean()拿到代理对象再调用。在方法内部使用self()那套方案但实现复杂不如拆分类干净。我推荐的从来都是第一方案。把异步逻辑拆成独立的 Bean职责更清晰也能规避所有失效问题。4.2 异常处理三选一别裸奔Async 方法有个区别返回值是 void 的时候异常不会抛给调用方而是会被吞掉直接打到默认的异常处理器上。Spring 默认的异常处理器会输出一行日志就完事问题是你根本不知道它有没有打打到哪里了经常线上出了事连日志都找不到。第一种方案方法返回 FutureAsync public FutureString sendSmsAsync(String orderId) { try { Thread.sleep(1000); return new AsyncResult(success); } catch (Exception e) { return new AsyncResult(failed: e.getMessage()); } }调用方接收 Future调用 get() 的时候可以拿到结果或者捕获 ExecutionException。注意一旦调用 Future.get()主线程就会等待失去异步的意义所以一般不建议同步等结果。第二种方案实现 AsyncConfigurer 接口自定义全局异常处理器Configuration public class AsyncExceptionConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { return new ThreadPoolTaskExecutor(); } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, objects) - { // 自定义记录日志、告警 System.out.println(异步任务异常方法 method.getName() 异常 throwable.getMessage()); }; } }第三种方案在异步方法内部自己 try/catch兜底处理好。这个看着最土但最好用尤其是对业务有要求的场景。异步任务里的异常你本来也不指望调用方处理自己 catch 掉记录日志该补偿的补偿该告警的告警。注意如果你没实现任何异常处理器Async void 方法的异常会走 SimpleAsyncUncaughtExceptionHandler它的默认日志级别是 WARN 并且只在类路径存在 log4j 或 SLF4J 的时候才输出。现实情况是很多团队根本没看这行日志。4.3 事务与异步的组合坑这个坑得单独拎出来讲因为它很隐蔽。假设你在一个事务方法里调用了异步方法异步方法内又读取了刚写的数据你会发现异步方法里查不到主流程刚提交的数据。原因很好理解事务是绑定在调用线程上的。主方法事务没提交之前异步线程去读数据库要么读不到READ_COMMITTED 下要么读到旧快照REPEATABLE_READ 下。你以为事务已提交其实异步线程启动的时候主事务可能还在执行中。更隐蔽的坑在事务注解本身Transactional Async public void doSomething() { // 这里的事务可能没有生效 }当一个 Async 方法在代理层面被调用的时候事务拦截器和异步拦截器的执行顺序极其容易出错。如果异步拦截器先执行事务代码就跑在异步线程里那倒是能生效但如果事务上下文在异步线程里传播很多场景会报“No thread-bound request found”或者事务静默失效。我的建议是异步和事务不要直接叠在同一个方法上。正确的姿势是外层方法写 Async 调内部事务方法或者把事务逻辑拆到一个独立方法让事务切面失效的几率和上面讲的 this 调用一样降低。真实项目里我见过一次事故订单完成后主流程先调了异步任务去发积分但积分发放逻辑需要读取订单状态异步任务读太快订单事务还没提交结果发了空积分。排查了半小时最后把异步调用移到事务提交之后用TransactionSynchronizationManager.registerSynchronization注册提交后回调才算解决。这个做法值得记下来。5. Web 层异步与消息队列的适用边界方法级异步讲完了再说说另外两层。很多人分不清“方法异步”和“请求异步”经常混淆。5.1 DeferredResult / WebAsyncTask把 Tomcat 线程让出来如果你的需求是接口本身要等到一个耗时的计算结果才能返回响应但这个等待过程很长比如两秒甚至十秒那 Async 不适合直接把整个接口甩给后台线程因为用户还在等着你需要的是“让出 Tomcat 线程等结果好了再异步写回响应”。DeferredResult 的思路是请求线程进来之后立即把 DeferredResult 对象返回给容器容器释放线程。另一个线程执行长任务完成后调用 deferredResult.setResult()容器再把结果写回客户端。下面是一个示例代码框架RestController public class LongTaskController { private final DeferredResultQueue queue new DeferredResultQueue(); PostMapping(/submit) public DeferredResultString submit(RequestBody String taskParam) { DeferredResultString deferredResult new DeferredResult(5000L); // 放进队列由后台线程处理 queue.add(deferredResult); // 模拟把任务提交给其他地方执行 taskExecutor.execute(() - { try { Thread.sleep(2000); deferredResult.setResult(处理完成: taskParam); } catch (Exception e) { deferredResult.setErrorResult(e); } }); return deferredResult; } }这种做法常见的应用场景包括第三方回调通知、长轮询、异步文件处理进度查询、服务器推送等。本质上是为了在高并发长耗时场景下保住 Tomcat 的线程不被占死。但我要泼一盆冷水如果你只是想让接口响应变快而不是真的要把可控线程归还给容器那不要用这个。DeferredResult 会让代码复杂度上一个台阶超时、异常、连接断开都要做处理得不偿失。5.2 什么时候该升级到消息队列Async 本质上是进程内线程池调度。如果你有以下几个信号就该考虑升级到消息队列了任务需要可靠投递一旦线程池重启、服务宕机任务就丢了业务不可接受。任务执行时间特别长而且上游根本不关心结果更适合削峰填谷。任务需要多个服务各自处理也就是发布订阅模型。需要重试、延迟消费、死信队列等复杂特性。我自己判断的口径很简单先问一句这任务如果真的丢了老板会不会骂你如果会就上 MQ如果只是发个欢迎语、记录个日志丢了也无伤大雅Async 就够了。不要过度设计微服务和中间件都是有运维成本的。6. 实测与监控线上出问题怎么排查写完了配置跑起来了测试通了不代表上线就稳了。异步任务最难查的点在于你不知道任务到底在哪个环节卡住了。所以从项目一开始就要把观测能力埋进去。6.1 自建一套异步任务的全链路观测第一步线程池必须设置线程名前缀这一步看起来无所谓排查问题的时候是救命稻草。上面配置里的async-order-一看到日志里这个名字你就知道这个日志属于异步任务。第二步给任务加 traceId 或者业务唯一标识。异步方法最好带上业务参数日志也一定要把参数打出来。比如“发送短信 task start orderIdxxx”和“发送短信 task end orderIdxxx”这样处理到一半的时候就能定位到具体是哪个任务卡了。第三步用 Actuator 或者监控中间件盯线程池指标。下面这个配置可以暴露线程池指标management: endpoints: web: exposure: include: health,info,metrics再把自定义线程池注入到 Metrics可以监控 active 线程数、queue 大小、completed 任务数。我给过自己的一个粗标准持续观测下来如果队列积压超过容量的 70%就要告警。如果活跃线程长期贴着最大线程数代表线程池设置不合理或下游处理太慢。6.2 常见问题速查表现象可能原因解决方案Async 不生效还是同步执行Spring 容器代理没走this 调用拆到独立 Bean外部注入调用异步方法里没日志默认异常处理器吞了日志实现 AsyncUncaughtExceptionHandler主流程事务没提交异步就读数据两个线程事务隔离使用注册事务提交后的回调来触发线程池任务堆积接口正常但通知很慢队列过大执行线程被长任务占满调小队列调大核心线程数服务重启后异步任务全部消失Async 是内存线程池不持久化换 MQ或落库做补偿一个异步任务抛异常主流程感知不到void 方法的异常天生不外抛自行 catch或方法签名加返回值上面这些问题我在各个项目里基本都见过一轮。排查的思路万变不离其宗先确定是“没进线程池”还是“进了线程池但执行慢”再往下查。前者看代理是否生效、配置是否加载后者看队列、活跃线程数、下游服务耗时。最后再分享两个我自己的偏好。第一我习惯在异步方法上显式指定线程池名字不依赖全局默认这样多个业务可以各自隔离互不影响Async(customAsyncExecutor) public void sendSmsAsync(String orderId) { // ... }第二异步任务的代码里我会尽量不让它去触碰主线程的 ThreadLocal 上下文尤其是用户信息、token 这类数据。跨线程传递这些值要额外用 TransmittableThreadLocal 之类的方案不然容易出现后台任务拿着空身份去调下游接口被告警搞到崩溃。这个坑等你要做的时候自然能体会到。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进