Spring @Async异步编程实战:线程池配置、事务与异常处理
做Web开发的朋友应该都遇到过这种场景一个接口里既要写数据库又要调用第三方接口还要发短信通知用户一圈跑下来接口耗时直线飙升。其实很多步骤根本不需要让用户在前台干等Spring自带的Async注解就是用来解决这个问题的。Async属于Spring框架提供的异步编程方案只要在方法上加上这个注解再配合启动类上的EnableAsyncSpring就会把方法丢进线程池异步执行调用方不需要阻塞等待。这篇内容就围绕Async的完整用法来聊包括它的实现原理、线程池配置、事务协作、异常处理以及那些一踩一个准的经典坑。不管是刚接触Spring Boot的同学还是写过一阵子业务的老手都有参考价值。1. 先搞清楚同步与异步的本质差别1.1 同步调用到底痛在哪很多新人刚上手Spring的时候思路都是“顺序执行”请求进来了先查数据库再调外部接口最后组装返回。这套逻辑在低并发、低延迟的场景下没啥问题但一旦接口链路变长问题就非常明显。举一个我在实际项目里遇到的例子。用户注册成功之后系统要执行以下流程写入用户基本信息耗时约50毫秒调用第三方风控接口做实名校验耗时约200毫秒发送一条注册成功的短信通知耗时约30毫秒记录一条操作日志耗时约10毫秒如果全部同步执行接口总耗时接近300毫秒。而用户真正关心的其实只有“注册成功”这个结果。短信、风控、日志都是附加动作。让用户在前台等300毫秒只是因为后台要完成一堆和用户体验无关的事情这种设计显然是亏的。我们把耗时的行动表整理一下步骤操作内容耗时是否必须同步1写入用户信息50ms是2调用风控接口200ms否可后台重试3发送短信通知30ms否可后台发送4记录日志10ms否可后台写入把后三个步骤改成异步执行接口的响应时间就能从300毫秒降到50毫秒左右这个提升非常可观。更关键的是异步化之后用户的注册主流程不会因为短信网关超时、风控接口抖动而跟着失败系统的稳定性和可用性也会上一个台阶。1.2 Async的适用边界Async不是什么银弹它解决的是“非核心操作与核心操作解耦”的问题。在用之前先自己过一遍需求判断场景适不适合异步。我一般会问自己三个问题调用方能否接受这个操作不在当前线程内完成如果异步任务执行失败有没有补偿机制异步任务是否会大量占用线程资源导致核心业务线程饥饿以这个标准去卡以下几个场景比较适合:邮件、短信、站内信等消息通知日志采集、审计记录、埋点上报非关键数据的预热、缓存更新第三方接口调用尤其是响应慢且允许重试的接口以下场景则要慎重前端页面需要立即展示的查询结果需要与主事务保持强一致性的写操作并发量极高、任务积压风险大的链路给一个直观的类比同步调用就像你去饭店吃饭必须等厨子把每一道菜做完端上来你才吃下一道异步调用就像你先点好菜厨子在后厨一道道做服务员做好一道上一道你在前台可以同时吃菜、聊天、处理别的事。Async就相当于告诉Spring“这个菜不用等让它后厨慢慢做就行。”1.3 和消息队列的关系也要理清不少面试官会问有了Async为什么还要用MQ这两种异步方案本质上处理不同层级的诉求。Async是进程内的线程池异步它解决的是“当前服务内部不用阻塞等待”的问题。它的优点是轻量、简单、没有额外依赖缺点是只在单个JVM进程内有效服务重启或宕机时任务会丢也没有重试和持久化机制。消息队列解决的是“跨服务、跨实例的消息解耦和削峰填谷”问题。任务先写到MQ里消费者服务去拉取处理即使生产者宕机消息也不会丢。项目初期、单体架构、链路简单用Async完全够到了微服务阶段、任务需要可靠投递、多个服务实例都去消费同一批任务时再考虑引入MQ。两者不是替代关系而是从“轻量异步”到“可靠异步”的演进关系。2. 三个必配项一次讲透2.1 启动类上的EnableAsync到底做了什么要知道Async为什么会失效得先理解Spring是怎么让它生效的。EnableAsync是总开关。Spring在启动阶段扫描到它之后才会注册一个AsyncAnnotationBeanPostProcessor处理器。这个处理器会在Bean创建完成后检查类中是否存在Async标注的方法存在就为目标Bean生成一个代理对象JDK动态代理或CGLIB后续所有对该Bean方法的调用都会先经过代理对象的拦截。拦截器拿到方法之后判断方法上有没有Async有的话就从容器中找线程池把方法封装成一个任务提交给线程池执行没有Async就直接走原方法。所以EnableAsync漏写Async就完全失效而且不会报错。方法还是在当前线程同步执行接口是慢了一点但功能还是正常的这种静默失效最坑人。2.2 默认线程池的坑加了EnableAsync没有显式声明线程池Spring会去找类型为TaskExecutor的Bean找不到就用默认的SimpleAsyncTaskExecutor。SimpleAsyncTaskExecutor这个名字看着像线程池实际上是个假的线程池。它每执行一个任务就new一个新线程不重用线程也没有队列容量限制。在高并发场景下线程数量会跟着任务数一起疯涨直接把系统资源耗光。压测的时候经常遇到这种情况CPU还没打满线程数已经上千紧接着就是内存溢出、Full GC频繁。官方文档也建议正式项目不要直接用SimpleAsyncTaskExecutor要么自定义线程池Bean要么在Async注解上指定线程池名称。2.3 自定义线程池参数怎么定线程池参数是异步编程的地基这个配不好后面全白搭。我贴一段生产环境里常用的配置类import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; import java.util.concurrent.ThreadPoolExecutor; Configuration EnableAsync public class AsyncConfig { Bean(businessExecutor) public Executor businessExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(16); // 队列容量 executor.setQueueCapacity(200); // 线程名前缀方便日志排查 executor.setThreadNamePrefix(biz-async-); // 拒绝策略由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }参数怎么定我的经验是这样核心线程数业务系统大多是IO密集型调第三方接口、读写数据库建议CPU核数乘以2再加1。例如8核机器核心线程数配到8~16比较稳妥。最大线程数给突发流量留一点余量一般是核心线程数的1.5到2倍。队列容量队列是线程池的缓冲池容量太小容易触发拒绝策略容量太大会导致请求排队时间过长。200到500是常见值具体看单任务耗时。拒绝策略生产环境我推荐CallerRunsPolicy它的逻辑是“线程池满了任务不丢弃由提交任务的线程直接执行”。这样做的好处是任务不会被静默丢弃坏处是调用方线程会被阻塞但反过来也成为天然背压能保护下游系统。给Async指定线程池的写法Async(businessExecutor) public void sendSms(String mobile) { // 发送短信逻辑 }这样指定之后任务就会进入businessExecutor线程池执行。如果没有指定名称Spring会按名字查找容器中的TaskExecutor或Executor类型的Bean。2.4 异步异常的正确处理同步方法抛了异常调用方用try-catch就能捕获异步方法抛异常异常发生在子线程里调用方根本感知不到。默认情况下异常只会打印到日志如果异步方法里没有try-catch异常信息甚至都不会很显眼。Spring提供了接口AsyncUncaughtExceptionHandler专门处理无返回值异步方法抛出的异常。用法是自定义一个异常处理器然后在配置类里注册。Component public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 记录异常日志发送告警或者做补偿 System.out.println(异步方法执行异常 method.getName()); System.out.println(异常信息 ex.getMessage()); // 这里可以把异常信息接入告警平台 } }注册方式Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 参数配置略 executor.initialize(); return executor; } Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncExceptionHandler(); } }带返回值的异步方法Future、CompletableFuture异常不会走到这个处理器里它会被封装到Future对象里调用get()方法时以ExecutionException的形式抛出来。3. 从无返回值到拿到异步结果的完整实操3.1 无返回值异步方法最简单的形态先看最基础的无返回值写法这也是项目里用得最多的。定义一个Service类在方法上直接加Async然后在Controller里调用。Service public class NoticeService { Async(businessExecutor) public void sendSms(String mobile) { // 模拟耗时操作真实场景这里是调用短信网关 System.out.println(Thread.currentThread().getName() 开始发送短信); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() 短信发送完成); } }Controller调用RestController public class RegisterController { Resource private NoticeService noticeService; PostMapping(/register) public String register() { // 注册主流程逻辑省略 noticeService.sendSms(13800138000); return 注册成功; } }运行之后控制台日志会看到类似下面这样的输出http-nio-8080-exec-1 开始处理注册请求 biz-async-1 开始发送短信 http-nio-8080-exec-1 注册接口返回 biz-async-1 短信发送完成核心线程池里线程名biz-async-1出现了说明异步生效接口线程没有等待短信发送完成。有三个细节需要注意Async方法所在的类必须被Spring管理也就是要加Component、Service等注解。方法必须是publicprivate方法的代理无法生效。方法不能是finalfinal方法没法治字节码增强。3.2 带返回值的异步Future与CompletableFuture有的场景需要在异步任务执行完之后把结果带回来。比如并行查询多个数据源最后合并结果返回给前端。此时返回值类型不能随便写Spring支持Future、CompletableFuture等类型。Service public class QueryService { Async(businessExecutor) public CompletableFutureString queryUserInfo(Long userId) { try { Thread.sleep(500); return CompletableFuture.completedFuture(用户信息 userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return CompletableFuture.failedFuture(e); } } Async(businessExecutor) public CompletableFutureString queryOrderInfo(Long userId) { try { Thread.sleep(800); return CompletableFuture.completedFuture(订单信息 userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return CompletableFuture.failedFuture(e); } } }调用方可以通过CompletableFuture的allOf方法并行等待所有任务完成Service public class AggregateService { Resource private QueryService queryService; public ListString getDashboardData(Long userId) throws ExecutionException, InterruptedException { CompletableFutureString userFuture queryService.queryUserInfo(userId); CompletableFutureString orderFuture queryService.queryOrderInfo(userId); CompletableFuture.allOf(userFuture, orderFuture).join(); ListString result new ArrayList(); result.add(userFuture.get()); result.add(orderFuture.get()); return result; } }两个查询分别耗时500毫秒和800毫秒如果同步执行总耗时是1300毫秒异步并行之后总耗时压到800毫秒左右。需要明确一点CompletableFuture本身不是必须搭配Async才能用它自己也可以创建异步任务。但Spring加持之后线程池统一管理、线程名前缀统一规范、和Spring容器无缝集成管理起来更舒服。3.3 自调用失效问题同一个类里调用为什么不管用Async失效的案例里自调用self-invocation出现的频率最高。先看错误代码Service public class OrderService { Async(businessExecutor) public void sendOrderNotify(Long orderId) { System.out.println(Thread.currentThread().getName() 发送订单通知); } public void createOrder(Order order) { // 创建订单逻辑 this.sendOrderNotify(order.getId()); // 注意这里调的是this的方法没有经过Spring代理 } }createOrder执行时this.sendOrderNotify()是直接调用本类方法根本没走代理对象Async自然就不生效。异步方法会退化成一个普通的同步调用。为什么会这样因为Async是基于AOP代理实现的。Spring容器里真正注入到其他Bean的是代理对象代理对象在方法前执行拦截逻辑然后才去调用原始对象的方法。而this关键字指向的是原始对象本身它身上没有被增强过。解决方案有三种第一种注入自身代理Resource注解需要额外的配置支持循环依赖自引用Service public class OrderService { Resource private OrderService self; // 注意Spring Boot 2.6默认禁止循环依赖此方法要谨慎使用 public void createOrder(Order order) { // 创建订单逻辑 self.sendOrderNotify(order.getId()); } Async(businessExecutor) public void sendOrderNotify(Long orderId) { // 异步逻辑 } }第二种把异步方法拆到另一个独立的Service里。这是我最推荐的做法职责清晰也不依赖循环引用。Service public class NotifyService { Async(businessExecutor) public void sendOrderNotify(Long orderId) { System.out.println(Thread.currentThread().getName() 发送订单通知); } } Service public class OrderService { Resource private NotifyService notifyService; public void createOrder(Order order) { // 创建订单逻辑 notifyService.sendOrderNotify(order.getId()); } }第三种通过AopContext.currentProxy()显式获取代理对象需要配置exposeProxytrue代码稍微绕一些一般用不上知道有这招就行。判断自调用问题有一个很土但很有效的办法在异步方法里打印线程名。如果线程名是http-nio-xxxx或者tomcat-exec-xxx说明根本没切到异步线程池如果线程名是biz-async-xxx说明走的是异步线程。4. 经典故障与排查方法4.1 异步方法里的事务为什么经常失效Async和Transactional一起用时很多人会想当然地认为“方法异步执行事务也自动开了”。实际上这里面的坑很深。先看一个典型例子Service public class OrderService { Async(businessExecutor) Transactional(rollbackFor Exception.class) public void placeOrder(OrderRequest request) { // 订单表插入 // 明细表插入 // 抛一个异常测试回滚 throw new RuntimeException(模拟异常); } }很多同学以为这个方法会开启事务异常后自动回滚。但实测时往往发现订单表和明细表的数据竟然都写进去了。原因有两个层面。第一Spring事务也是通过AOP代理实现的。代理的调用顺序上如果先被Async拦截器拦截方法被丢到子线程之后真正的调用发生在新的线程里父线程的事务上下文ThreadLocal里存的事务资源根本传递不过去。第二事务管理器默认绑定在ThreadLocal上。子线程是新的执行栈拿不到父线程里的事务信息所以即使Transactional生效它开启的也是新的事务。生产环境里遇到这个组合我的建议是“先拆再合”把异步和事务拆开。异步只负责“提交一个事务任务”事务逻辑放在另一个方法里。Service public class OrderTaskService { Transactional(rollbackFor Exception.class) public void createOrderWithTx(OrderRequest request) { // 订单表插入 // 明细表插入 // 异常时这里会正常回滚 } } Service public class OrderAsyncService { Resource private OrderTaskService orderTaskService; Async(businessExecutor) public void placeOrder(OrderRequest request) { orderTaskService.createOrderWithTx(request); } }这样异步线程池执行任务方法内部有独立事务异常可以正常回滚链路也清晰。4.2 线程名错误定位与日志排查线程池参数配好了还要考虑怎么把问题暴露出来。一个非常容易被忽视的点是线程第一名默认的ThreadPoolTaskExecutor生成的名字是pool-1-thread-1这种名字在排查问题的时候几乎无效。你看到日志里一行pool-1-thread-1根本不知道它属于哪个业务模块。所以务必在配置线程池时显式设置threadNamePrefix比如biz-async-、sms-async-、report-async-。设置之后日志里就能快速区分是哪个业务线在执行。另外有条件的话把线程池的活跃线程数、队列大小接入监控。Spring Boot Actuator对Tomcat线程池有很好的监控支持。异步线程池如果要自行上报可以在ThreadPoolTaskExecutor外面包一层定时抓取getActiveCount()、getQueue().size()等指标。我在实际项目里踩过一个很深的坑有一个线程池队列容量配了10000核心线程数只有4。任务大量积压在队列里接口层面感知不到异常但实际上很多异步任务的延迟已经到了分钟级。最后通过监控队列积压数才定位到问题。后续优化时把队列容量调小、核心线程数调大积压立刻降了下来。4.3 常见问题速查表把这一节的内容整理成一张速查表方便后续遇到问题时快速对照问题现象可能原因解决方案异步方法没有异步执行没加EnableAsync在启动类或配置类上加EnableAsync异步方法没有异步执行方法不是public或final改写法保持方法可被代理异步方法没有异步执行同类内部自调用拆分到独立Bean或用AopContext.currentProxy()线程直接打满系统资源耗尽用了默认SimpleAsyncTaskExecutor自定义线程池并指定名称异步方法抛异常但调用方无感知没有配置异常处理器实现AsyncUncaughtExceptionHandler出现大量任务堆积队列容量过大调小队列配置拒绝策略并监控积压异步方法里事务不回滚事务上下文无法传递到子线程事务单独拆到另一个方法异步调用事务方法日志中线程名无法区分业务没有设置线程名前缀配置threadNamePrefix4.4 如何快速验证异步真正生效写完代码之后不要急着合代码用最简单的方法验证一下第一种方法在异步方法里加一行日志打印当前线程名。看启动日志里是否出现线程池对应的前缀。第二种方法在application.properties或application.yml中配置Spring Boot Actuator端点通过JMX或者HTTP端点观察线程池指标。比加打印日志更专业。management: endpoints: web: exposure: include: beans,metrics,health查询线程池的内存地址需要先知道Bean名称可以访问/actuator/beans找到businessExecutor的资源路径然后再看它的线程数、队列大小。5. 进阶结合自定义注解与多实例的思考5.1 用自定义注解封装异步日志实际项目中如果运营团队要求记录每次异步任务的执行耗时、成功失败、入参出参在每个异步方法里手工写日志代码会很痛苦。这时候可以把Async和一个自定义注解结合起来通过AOP做统一增强。思路是这样的定义一个注解AsyncMetric标注了它的方法会被AOP环绕通知拦截。通知里记录方法名、入参、耗时、异常信息同时决定是否真正提交给线程池。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AsyncMetric { String module() default ; }AOP切面伪代码Aspect Component public class AsyncMetricAspect { Around(annotation(asyncMetric)) public Object record(ProceedingJoinPoint joinPoint, AsyncMetric asyncMetric) throws Throwable { long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; // 记录成功日志 return result; } catch (Throwable throwable) { long cost System.currentTimeMillis() - start; // 记录异常日志 告警 throw throwable; } } }这样处理之后业务代码干净得多异步任务的监控和日志在切面里统一维护。这个思路和热搜里那个“AOP基于注解的接口限流”模式类似核心是“用注解定义切入点用AOP做逻辑增强”。5.2 微服务多实例下的异步改造最后聊一个进阶话题微服务多实例部署时Async还能不能撑住。假设一个订单服务部署了3个实例每个实例都配置了businessExecutor线程池。此时某个业务方法加了Async调用了发送短信的服务。结果可能是主调方实例的线程池执行任务其他两个实例闲着。请求量一旦上涨单个实例的线程池反而先被打满其他实例帮不上忙。Async本质上还是“单机线程池异步”它没有任务分发的概念。真正要解决多实例异步任务的负载均衡和可靠投递最终还是得上MQ。给一个决策建议任务不需要严格分布式锁且单实例吞吐能抗住用Async完全没问题。任务有严格的成功率要求需要重试和补偿尽量用MQ。任务需要所有实例共同分担考虑MQ或者分布式调度框架。这个边界想清楚了Async用起来会更踏实。最后提一句我在实际项目里的习惯接手一个老项目先看它的线程池配置没有自定义线程池还敢大量用Async的迟早会在压测或者大促时炸。每加一个Async方法前先问自己三个问题调用方能接受异步延迟吗失败后的补偿机制是什么线程池容量够吗想清楚这三点再动手能省掉后面一大半的排查时间。另外异步代码虽然写起来痛快但它天生比同步代码难调试线上排查问题时要先锁定线程名、线程池、任务队列和异常日志一层层往下摸很快就能定位到根因。