1. 从一次接口超时说起:为什么同步调用会拖垮整个链路
很多人第一次接触异步调用,都是被线上问题逼出来的。我印象很深的一次,一个订单查询接口,主流程只是查订单基本信息,但里面串行调了三个下游:查用户积分、查优惠券可用列表、查物流最新状态。平时响应 200ms 左右,某天积分服务抖动,单次耗时从 30ms 涨到 2s,结果整个订单接口直接飙到 6s 以上,线程池被打满,连带其他接口一起雪崩。
这就是同步调用的本质问题:调用方线程会被阻塞,直到被调用方返回结果。在 SpringBoot 里,一个 HTTP 请求默认占用一个 Tomcat 工作线程,如果这个线程在等下游 IO,它就什么也干不了,纯粹在"空转等待"。并发一上来,线程池很快耗尽,新请求只能排队,用户体验断崖式下跌。
异步调用要解决的核心,就是把"等待"这件事从主线程里剥离出去。主线程发起调用后立刻返回,去做别的事,等结果回来了再通过回调、Future 或者消息通知的方式处理。听起来简单,但真正落地时会遇到一堆问题:线程池怎么配、异常怎么传、事务会不会失效、上下文(比如登录用户信息)怎么带过去、返回值怎么拿。这些才是异步调用真正的坑。
这篇内容适合两类人:一类是刚接触 SpringBoot 异步、想搞清楚@Async到底怎么用的开发者;另一类是用过异步但踩过坑、想系统梳理线程池配置和上下文传递的老手。我会从原理讲到实操,把每个关键选择背后的理由讲透,再分享几个我实际踩过的坑。
先明确一个概念边界:本文说的异步调用,指的是方法级别的异步执行,也就是调用一个方法时不让当前线程等待,而不是指消息队列那种跨服务的异步通信。两者经常被混为一谈,但解决的问题层次不同。方法级异步主要解决单机内的线程阻塞,消息队列解决的是跨服务解耦和削峰。搞清楚这个边界,后面的选型才不会跑偏。
2. @Async 注解背后的代理机制:它到底是怎么"偷偷"换线程的
2.1 一个方法加上注解,为什么就异步了
很多人用@Async用得很顺手,但被问到"它底层怎么实现的"就答不上来。其实核心就一句话:Spring 通过 AOP 代理,把对目标方法的调用拦截下来,转交给线程池执行。
具体流程是这样的:当你在某个 Bean 的方法上标注@Async,Spring 在启动时会为这个 Bean 生成一个代理对象。外部调用这个方法时,实际调用的是代理对象。代理对象发现方法上有@Async注解,就不会直接执行原方法,而是把原方法的调用包装成一个任务,提交给TaskExecutor线程池,然后立即返回。真正的业务逻辑在另一个线程里跑。
这里有个关键点必须强调:代理生效的前提是"外部调用"。也就是说,如果你在同一个类的 A 方法里直接调用本类的 B 方法(B 标了@Async),这个调用走的是this.b(),压根不经过代理对象,异步不会生效,B 方法还是同步执行。这是新手最容易踩的坑,没有之一。
@Service public class OrderService { public void createOrder() { // 这样调用,@Async 不会生效,还是同步执行 this.sendNotify(); } @Async public void sendNotify() { // 发送通知逻辑 } }要解决这个问题,常见做法有三种:把异步方法抽到另一个 Bean 里注入调用;通过AopContext.currentProxy()拿到当前代理再调用(需要开启exposeProxy);或者干脆注入自己。我个人最推荐第一种,职责清晰,也不依赖 Spring 的内部机制。
2.2 默认线程池的隐患:SimpleAsyncTaskExecutor 不是给你生产用的
如果你只加@Async不做任何配置,Spring 会用默认的执行器。在较新的版本里,默认是SimpleAsyncTaskExecutor,这个执行器的特点是每次调用都新建一个线程,不复用。它压根没有线程池的概念,只是名字里带了个 Executor 而已。
这意味着什么?高并发下会疯狂创建线程,线程数没有上限,很容易把系统资源耗尽,直接 OOM 或者把 CPU 打满。所以生产环境必须自定义线程池,这是硬性要求,不是可选项。
自定义线程池有两种方式。一种是实现AsyncConfigurer接口,重写getAsyncExecutor()方法,这样全局的@Async都会用这个线程池:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("async-task-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new SimpleAsyncUncaughtExceptionHandler(); } }另一种是定义多个命名的线程池,在@Async("poolName")里指定用哪个。实际项目里我更倾向第二种,因为不同业务对线程池的要求不一样:发短信的可以容忍排队,但支付回调必须快速响应,混用一个池子会互相影响。
2.3 线程池参数怎么算:别拍脑袋填数字
线程池参数是异步调用里最容易被"随便填"的地方。corePoolSize 填 10,maxPoolSize 填 20,队列填 1000,问为什么,答"网上都这么写"。这样配在低并发下没问题,一旦流量上来就会出问题。
线程池大小的估算,核心看任务是CPU 密集型还是IO 密集型。CPU 密集型任务,线程数接近 CPU 核数就行,多了反而因为上下文切换降低效率,一般设为核数 + 1。IO 密集型任务,线程大部分时间在等待,可以多设一些,经验公式是核数 * (1 + 平均等待时间 / 平均计算时间)。
假设你的异步任务是调用下游接口,平均耗时 100ms,其中真正计算只有 5ms,那么等待/计算比是 19,8 核机器上线程数可以设到8 * 20 = 160。当然这只是理论值,实际还要看下游能承受多少并发,别把下游打挂了。
队列容量也要想清楚。队列太大,任务堆积严重,用户等半天才拿到结果,还不如快速失败;队列太小,稍微有点波动就触发拒绝策略。我一般会结合接口的 SLA 来定:如果这个异步任务允许 5 秒内完成,单任务耗时 100ms,那队列容量大概能容纳"5 秒内能处理完的任务量"。
拒绝策略更是关键。默认的AbortPolicy会直接抛异常,如果调用方没处理,用户就看到报错了。CallerRunsPolicy会让提交任务的线程自己执行,相当于降级成同步,虽然慢但不会丢任务,适合不能丢的场景。选哪个取决于业务能不能接受任务丢失。
| 参数 | 作用 | 常见误区 | 建议 |
|---|---|---|---|
| corePoolSize | 核心线程数 | 填太小导致频繁创建线程 | 按任务类型估算,IO 密集型可偏大 |
| maxPoolSize | 最大线程数 | 和 core 设成一样,队列满了也不扩容 | 留出弹性空间 |
| queueCapacity | 队列容量 | 设得过大导致任务堆积 | 结合 SLA 和单任务耗时计算 |
| rejectedExecutionHandler | 拒绝策略 | 用默认的导致任务丢失 | 按业务能否丢任务选择 |
3. 返回值、异常与上下文:异步调用最容易翻车的三个地方
3.1 有返回值的异步:Future 和 CompletableFuture 怎么选
@Async方法如果要有返回值,返回类型必须是Future或其子类。早期大家用Future,但Future有个硬伤:获取结果只能阻塞式地 get(),而且没法组合多个异步任务。比如你要并发调三个下游,然后等三个都回来再汇总,用Future写起来很别扭。
CompletableFuture就是来解决这个问题的。它支持链式回调、任务组合、异常处理,写起来清爽很多:
@Async public CompletableFuture<UserInfo> getUserInfo(Long userId) { UserInfo info = userClient.query(userId); return CompletableFuture.completedFuture(info); }调用方可以这样组合:
CompletableFuture<UserInfo> userFuture = userService.getUserInfo(userId); CompletableFuture<List<Coupon>> couponFuture = couponService.getCoupons(userId); CompletableFuture<Void> all = CompletableFuture.allOf(userFuture, couponFuture); all.thenRun(() -> { UserInfo user = userFuture.join(); List<Coupon> coupons = couponFuture.join(); // 汇总处理 });这里有个细节要注意:@Async方法返回CompletableFuture时,方法内部如果抛异常,异常会被包装进 Future,调用方join()或get()时才会抛出。如果你不调用获取结果的方法,异常就被"吞"了,日志里什么都看不到。这是排查异步问题时特别容易迷惑人的地方。
3.2 异步方法里的异常,为什么日志里找不到
前面提到,无返回值的@Async方法抛异常,默认不会传播到调用方,因为调用方早就返回了。Spring 会把这个异常交给AsyncUncaughtExceptionHandler处理,默认实现只是简单打印一行日志,信息量很少,很容易被忽略。
我踩过一次坑:一个异步的发券方法内部报错,但日志里只有一行Unexpected exception occurred invoking async method,没有堆栈,排查了半天。后来自定义了异常处理器,把完整堆栈打出来才定位到问题:
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { log.error("异步方法执行异常, method={}, params={}", method.getName(), Arrays.toString(params), throwable); }; }所以我的建议是:只要用了@Async,就一定要自定义异常处理器,把方法名、参数、完整堆栈都记下来。否则线上出问题,你连哪个异步任务挂了都不知道。
另外,异步方法内部如果要做事务,也要特别小心。@Async和@Transactional加在同一个方法上时,事务是在异步线程里开启的,和调用方的事务完全隔离。如果调用方事务还没提交,异步线程去查数据可能查不到,这就是典型的"异步读不到未提交数据"问题。解决办法是把异步调用放到事务提交之后,用TransactionSynchronizationManager注册回调,或者用事件机制。
3.3 上下文丢失:ThreadLocal 里的用户信息怎么带过去
这是异步调用里最隐蔽的坑。很多系统用ThreadLocal存当前登录用户、租户 ID、链路追踪 ID。同步调用时,这些信息在同一个线程里,随时能取。一旦切到异步线程,ThreadLocal里的东西全没了,异步方法里取用户信息就是 null。
原因很简单:ThreadLocal是线程隔离的,主线程存的值,异步线程读不到。解决办法是在提交任务时把上下文快照传过去,在异步线程里重新设置。
Spring 提供了TaskDecorator接口,可以在任务提交前做装饰:
public class ContextCopyingDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { // 捕获当前线程的上下文 RequestContext context = RequestContextHolder.getRequestAttributes(); String userId = UserContext.getUserId(); return () -> { try { // 在异步线程里恢复上下文 RequestContextHolder.setRequestAttributes(context); UserContext.setUserId(userId); runnable.run(); } finally { // 记得清理,避免线程复用导致串数据 UserContext.clear(); } }; } }然后在配置线程池时设置executor.setTaskDecorator(new ContextCopyingDecorator())。
这里有个必须注意的点:finally 里一定要清理 ThreadLocal。因为线程池的线程是复用的,如果这次任务设置了用户 ID 没清理,下个任务用同一个线程时就会读到上一个用户的信息,造成数据串号,这种 bug 极其难查。我见过一个案例,A 用户看到了 B 用户的订单,最后定位就是 ThreadLocal 没清理导致的。
4. 事件驱动与消息队列:异步不止 @Async 一条路
4.1 Spring 事件机制:解耦比 @Async 更彻底
@Async是"我知道要调谁,只是不想等"。但有些场景是"我做完这件事,谁关心谁自己去处理,我不关心"。这时候用 Spring 的事件机制更合适。
定义一个事件:
public class OrderCreatedEvent { private final Long orderId; public OrderCreatedEvent(Long orderId) { this.orderId = orderId; } // getter }发布事件:
applicationEventPublisher.publishEvent(new OrderCreatedEvent(orderId));监听处理:
@Async @EventListener public void handleOrderCreated(OrderCreatedEvent event) { // 发通知、加积分、更新统计等 }事件机制的好处是发布方和监听方完全解耦,发布方不需要知道有哪些监听者。配合@Async,监听逻辑还能异步执行。但要注意,默认情况下事件是同步发布的,如果监听方法没加@Async,发布方还是会被阻塞。所以事件 +@Async才是完整的异步方案。
4.2 什么时候该上消息队列
@Async和事件机制都是单机内的异步,应用重启时未处理的任务会丢失,也没法跨服务。如果任务不能丢、需要跨服务、或者要削峰填谷,就得上消息队列。
判断标准很简单:任务丢了会不会有业务损失。发个营销短信丢了,顶多少发一条;但订单创建、支付结果通知这种丢了就是事故,必须用消息队列做持久化。
我一般的选型思路是这样的:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单机内、可容忍丢失、追求简单 | @Async | 零依赖,改造成本低 |
| 单机内、需要解耦、多监听者 | Spring 事件 + @Async | 解耦彻底,实现简单 |
| 跨服务、不能丢、需削峰 | 消息队列 | 持久化、可靠投递、跨服务 |
| 定时延迟任务 | 延迟队列 / 定时任务框架 | 支持延迟触发 |
不要为了"显得高级"就上消息队列,运维成本、消息重复消费、顺序性这些问题都会跟着来。能用@Async解决的,就别引入 MQ。
4.3 异步链路的可观测性:出问题怎么定位
异步调用最大的运维痛点是链路断了。同步调用时,一个请求的日志都在一个线程里,顺着 traceId 就能串起来。异步之后,任务跑到别的线程,甚至别的服务,日志散落各处,排查问题像大海捞针。
我的做法是:把 traceId 作为上下文的一部分,通过 TaskDecorator 传递到异步线程,并且在异步任务的日志里都带上这个 traceId。这样即使跨线程,也能通过 traceId 把所有相关日志捞出来。
另外,线程池的监控也不能少。至少要暴露这几个指标:活跃线程数、队列大小、已完成任务数、拒绝任务数。队列持续增长或者拒绝数上升,都是危险信号,要提前告警。SpringBoot 的 Actuator 配合 Micrometer 可以比较方便地采集这些指标。
5. 几个真实踩坑记录与排查思路
5.1 异步方法不生效,先查这三处
异步不生效是最常见的问题,我总结了一个排查顺序,基本能覆盖 90% 的情况。
第一,查调用方式。是不是同类内部调用?如果是,改成注入其他 Bean 调用。这是最高频的原因。
第二,查注解是否开启。启动类或配置类上有没有@EnableAsync?没有这个注解,@Async就是个普通注解,完全不起作用。
第三,查方法修饰符。@Async方法必须是public的,private、protected或者final方法代理拦截不到,异步不会生效。这个坑比较隐蔽,因为代码不报错,只是默默变成同步执行。
5.2 线程池打满后的连锁反应
有一次线上告警,某个服务的异步任务全部延迟,用户反馈"操作后半天没反应"。排查发现线程池队列满了,任务在排队。再往下查,是某个下游接口变慢,导致异步任务耗时从 50ms 涨到 3s,队列迅速堆积。
这个问题的根因是没有做超时控制。异步任务调用下游时,如果下游不返回,线程就一直占着。解决办法是给下游调用设置合理的超时时间,超时后快速失败,释放线程。另外,不同优先级的任务最好用不同的线程池隔离,避免一个慢任务拖垮所有异步逻辑。
我还加了一个保护措施:队列满时记录详细日志并告警,而不是静默地走拒绝策略。这样能在问题扩大前就发现苗头。
5.3 应用关闭时任务丢失
应用发布重启时,如果线程池里还有任务没执行完,默认会被直接丢弃。对于发通知、写日志这类任务可能无所谓,但如果是重要的业务处理,丢了就是问题。
解决办法是在线程池配置里加上:
executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60);这样关闭时会等待任务执行完,最多等 60 秒。但要注意,这个等待时间不能设太长,否则发布流程会被卡住。而且它只保证"已提交到线程池的任务"不丢,如果任务还在业务代码里没提交,那还是保不住。所以真正重要的任务,还是得靠消息队列的持久化来兜底。
6. 我个人的一些实践建议
用了几年异步调用,我最大的体会是:异步不是免费的午餐,它用复杂度换来了性能。每引入一处异步,就多了一处可能出问题的地方——线程池配置、异常处理、上下文传递、链路追踪,每一项都要考虑周全。
我的建议是,能用同步就用同步,只有当同步确实成为瓶颈时才上异步。上异步之前,先问自己三个问题:这个任务丢了能不能接受?调用方需不需要返回值?出问题了怎么排查?想清楚这三个问题,方案基本就定了。
线程池配置上,我倾向于按业务隔离,不要全局一个池子。发短信的、算积分的、写日志的,各用各的,互不影响。参数上宁可保守一点,配合监控慢慢调,也别一上来就设个巨大的队列,把问题掩盖住。
最后分享一个小技巧:给每个异步线程池起一个有意义的名字前缀,比如sms-async-、point-async-。这样在 jstack 或者日志里,一眼就能看出是哪个业务的线程,排查问题时能省不少时间。这个习惯我从第一次排查线程问题后就一直保持着,确实好用。