
1. 从一次接口超时说起为什么同步调用会拖垮整个链路很多人第一次接触异步调用都是被线上问题逼出来的。我印象很深的一次一个订单查询接口主流程只是查订单基本信息但里面串行调了三个下游查用户积分、查优惠券可用列表、查物流最新状态。平时响应 200ms 左右某天积分服务抖动单次耗时从 30ms 涨到 2s结果整个订单接口直接飙到 6s 以上线程池被打满连带其他接口一起雪崩。这就是同步调用的本质问题调用方线程会被阻塞直到被调用方返回结果。在 SpringBoot 里一个 HTTP 请求默认占用一个 Tomcat 工作线程如果这个线程在等下游 IO它就什么也干不了纯粹在空转等待。并发一上来线程池很快耗尽新请求只能排队用户体验断崖式下跌。异步调用要解决的核心就是把等待这件事从主线程里剥离出去。主线程发起调用后立刻返回去做别的事等结果回来了再通过回调、Future 或者消息通知的方式处理。听起来简单但真正落地时会遇到一堆问题线程池怎么配、异常怎么传、事务会不会失效、上下文比如登录用户信息怎么带过去、返回值怎么拿。这些才是异步调用真正的坑。这篇内容适合两类人一类是刚接触 SpringBoot 异步、想搞清楚Async到底怎么用的开发者另一类是用过异步但踩过坑、想系统梳理线程池配置和上下文传递的老手。我会从原理讲到实操把每个关键选择背后的理由讲透再分享几个我实际踩过的坑。先明确一个概念边界本文说的异步调用指的是方法级别的异步执行也就是调用一个方法时不让当前线程等待而不是指消息队列那种跨服务的异步通信。两者经常被混为一谈但解决的问题层次不同。方法级异步主要解决单机内的线程阻塞消息队列解决的是跨服务解耦和削峰。搞清楚这个边界后面的选型才不会跑偏。2. Async 注解背后的代理机制它到底是怎么偷偷换线程的2.1 一个方法加上注解为什么就异步了很多人用Async用得很顺手但被问到它底层怎么实现的就答不上来。其实核心就一句话Spring 通过 AOP 代理把对目标方法的调用拦截下来转交给线程池执行。具体流程是这样的当你在某个 Bean 的方法上标注AsyncSpring 在启动时会为这个 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 填 10maxPoolSize 填 20队列填 1000问为什么答网上都这么写。这样配在低并发下没问题一旦流量上来就会出问题。线程池大小的估算核心看任务是CPU 密集型还是IO 密集型。CPU 密集型任务线程数接近 CPU 核数就行多了反而因为上下文切换降低效率一般设为核数 1。IO 密集型任务线程大部分时间在等待可以多设一些经验公式是核数 * (1 平均等待时间 / 平均计算时间)。假设你的异步任务是调用下游接口平均耗时 100ms其中真正计算只有 5ms那么等待/计算比是 198 核机器上线程数可以设到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 CompletableFutureUserInfo getUserInfo(Long userId) { UserInfo info userClient.query(userId); return CompletableFuture.completedFuture(info); }调用方可以这样组合CompletableFutureUserInfo userFuture userService.getUserInfo(userId); CompletableFutureListCoupon couponFuture couponService.getCoupons(userId); CompletableFutureVoid all CompletableFuture.allOf(userFuture, couponFuture); all.thenRun(() - { UserInfo user userFuture.join(); ListCoupon 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 或者日志里一眼就能看出是哪个业务的线程排查问题时能省不少时间。这个习惯我从第一次排查线程问题后就一直保持着确实好用。