Spring 异步编程的隐藏风险:@Async 线程池耗尽与异常处理详解
1. 从一次线上事故说起
假设你负责一个电商系统,用户下单后需要发送通知短信、生成积分流水、更新推荐位。这些操作如果都同步执行,下单接口的响应时间会拖长到好几秒,用户体验很差。于是你决定用 Spring 的异步能力把这些非核心操作挪到后台执行,让接口先返回"下单成功"。
上线初期一切正常,但到了大促高峰期,下单接口时不时报出类似这样的错误:
text
Task rejected from java.util.concurrent.ThreadPoolExecutor@xxxx[Running, pool size = 5, active threads = 5, queued tasks = 100, completed tasks = 0]
更奇怪的是,有些异步操作的日志消失了,像是根本没执行。打开监控面板,你发现线程池的活跃线程数一直顶在最大值,队列也满了,新任务不断被拒绝。而当你查看业务表时,有些订单的积分没有发放,短信也没发出去,但用户却收到了"下单成功"的反馈。
这就是本文要探讨的核心:Spring 的 @Async 看着简单,但背后隐藏着线程池配置、任务拒绝、异常处理等一系列风险。如果不在设计阶段就理解清楚,它们会在流量高峰时集中爆发。
本文将带你从问题场景出发,先建立一个可复述的模型,再深入线程池、异常处理、任务拒绝和上下文传递的细节,最后给你一套工程上的决策清单。
2. 一句话模型与整体框架
先记住一个最小模型:调用方把任务交给一个门卫(线程池),门卫决定是让某个工人(线程)立即处理、还是先排到队伍(队列)里,或者直接拒收(拒绝策略)。工人干活时如果出了差错(异常),门卫和工人的沟通方式决定了你能不能知道这个差错。
把整套机制拆成四部分,它们依次关联:
- 任务产生者 :被
@Async注解的方法调用处,它把任务提交给线程池。 - 任务执行容器 :线程池(
ThreadPoolTaskExecutor),管理线程、队列和拒绝策略。 - 任务执行结果:正常返回或抛出异常,异常需要被捕获才能感知。
- 任务环境:事务、安全上下文、请求参数等,默认不会自动传递给异步线程。
一次完整的调用流程大致如下:
text
调用方(main-1线程) → 提交任务 → ThreadPoolTaskExecutor
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
核心线程有空闲 排队等待 队列满且线程都忙
立即执行 worker线程唤醒 触发拒绝策略
│ │ │
▼ ▼ ▼
执行业务逻辑,捕获异常 执行业务逻辑,捕获异常 抛出TaskRejectedException或吞掉
下面我们将沿着这个流程逐步展开:先看线程池如何工作,再深入了解异常处理、任务拒绝和上下文传递的细节。
3. 线程池:@Async 的执行基石
3.1 线程池的核心参数与工作原理
Spring 的 @Async 背后依赖的是 Java 的线程池体系。默认情况下,Spring 会为 @Async 创建一个简单的线程池,但它的参数往往不适合生产环境。我们先明确线程池的几个核心参数,它们决定了线程池的行为:
| 参数 | 含义 | 示例值 |
|---|---|---|
| corePoolSize | 核心线程数,即使空闲也不会回收 | 5 |
| maxPoolSize | 最大线程数,当队列满且核心线程都忙时才会创建新线程 | 20 |
| queueCapacity | 等待队列容量,超出后才会尝试创建新线程 | 100 |
| keepAliveSeconds | 非核心线程空闲存活时间 | 60 |
| threadNamePrefix | 线程名前缀,便于日志排查 | "async-" |
| rejectionPolicy | 任务拒绝策略 | CallerRunsPolicy |
关键规则是:先核心线程 → 再队列 → 再非核心线程 → 最后拒绝。假设核心线程 5,队列容量 100:当提交第 6 个任务时,如果核心线程都忙,任务会进入队列而不是直接创建新线程;只有当队列也满了,才会创建非核心线程来救急,直到达到 maxPoolSize;当线程数达到最大值且队列又满时,新任务才会触发拒绝策略。
很多团队把 maxPoolSize 设得很大,认为越多越好,但实际上每个线程都要占用系统资源,包括栈内存和 CPU 调度成本。线程太多会导致频繁上下文切换,反而降低吞吐量。
3.2 Spring 默认线程池如何配置?
如果你只写了一个 @Async 注解,没有定义任何线程池,Spring Boot 会使用默认的 SimpleAsyncTaskExecutor,它不重用线程,为每个任务新建一个线程,这在高并发下几乎必然导致资源耗尽。因此,生产环境中必须自定义线程池。
这里需要纠正一个重要误解:Spring 默认并不会自动使用 ThreadPoolTaskExecutor 。只有当你显式定义一个 Executor 类型的 Bean,Spring 才会使用它。下面是推荐的做法:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("async-");
// 拒绝策略:由调用者线程执行任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
3.3 线程池调优:从业务角度出发
线程池参数的设置不能拍脑袋。需要根据业务特性估算:
- 任务执行时长:如果任务平均耗时 100ms,那么一个线程每秒能完成 10 个任务。若预期高峰时每秒新增 100 个异步任务,至少需要 10 个线程才能消化。
- 任务对顺序性的要求:如果任务需要保证顺序执行(比如对某个订单的操作),可能需要为每个订单分配一个队列,技术上是额外问题。
- 对丢弃任务的容忍度 :如果任务允许丢失(如日志),可以配置
DiscardPolicy;如果不行,必须用缓冲或CallerRunsPolicy降级。
提供一个参考经验值:核心线程数 = CPU 核数 * 目标 CPU 利用率 * (1 + 平均等待/平均计算时间)。对于 IO 密集任务,通常是 CPU 核数的 2-4 倍。但最佳实践是压测确定。
4. 核心机制一:@Async 的工作过程
4.1 @Async 是怎样被激活的?
你需要在配置类或启动类上添加 @EnableAsync 来激活 Spring 的异步处理。这个注解会像开关一样,让 Spring 解析 @Async 注解并创建代理对象。如果你忘记加这个注解,@Async 注解不会生效,方法会同步执行,可能让你误以为异步生效,但实际并未减轻接口压力。
最小示例:
java
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableAsync
public class AppConfig {
// 其他配置...
}
然后,你只需要在希望异步执行的方法上标明 @Async,并确保该方法是被另一个 Bean 调用的。
4.2 一个最小可运行的示例:@Async 的基础用法
我们先构建一个完整的 Spring Boot 项目的最小实验,来验证 @Async 的行为。
目标 :演示 @Async 默认使用自定义线程池,并观察调用线程和异步线程的区别。
前置环境:JDK 8+,Maven 或 Gradle,Spring Boot 2.7+。
首先创建 Spring Boot 项目,并添加以下文件:
java
// pom.xml (依赖的关键部分)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
配置类:
java
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
// 若想自定义线程池,在这里定义Bean即可
}
异步服务类:
java
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
@Async
public void sendSms(String phone, String content) {
System.out.println("发送短信线程: " + Thread.currentThread().getName());
// 模拟耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("短信已发送至: " + phone + ", 内容: " + content);
}
}
控制器:
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
@Autowired
private NotificationService notificationService;
@GetMapping("/order")
public String createOrder() {
System.out.println("下单线程: " + Thread.currentThread().getName());
notificationService.sendSms("13800138000", "您已成功下单");
return "订单创建成功";
}
}
运行后访问 http://localhost:8080/order,观察控制台:
- 下单线程通常为
http-nio-8080-exec-1(Web线程)。 - 发送短信线程如果没有自定义线程池,默认是
SimpleAsyncTaskExecutor,线程名可能是SimpleAsyncTaskExecutor-1等;如果自定义了线程池,则显示你配置的名称前缀,如async-1。
预期输出:
text
下单线程: http-nio-8080-exec-1
发送短信线程: async-1
短信已发送至: 13800138000, 内容: 您已成功下单
关键理解 :方法被 @Async 修饰后,调用方线程不会等待方法执行完毕,而是立刻返回。异步方法的执行被交给独立的线程。若没有配置线程池,每次调用都会新建线程,非常浪费。
4.3 注意事项:@Async 失效的常见陷阱
- 自调用(this 调用) :如果异步方法在同一个类内部通过
this.xxx()调用,由于没有经过代理,@Async不生效。例如下面的代码:
java
@Service
public class MyService {
public void doSomething() {
// 自调用,@Async失效
asyncMethod();
}
@Async
public void asyncMethod() {
// 异步逻辑
}
}
-
方法必须为 public:Spring 通过代理实现,private 或 protected 方法不会被代理拦截。
-
返回值类型限制 :异步方法返回类型只能是
void或Future(包括CompletableFuture)。如果返回其他类型,Spring 会忽略异步,直接同步执行。
5. 核心机制二:异步异常处理------你抓不到的异常
5.1 为什么异常会"消失"?
先看一个现象:
java
@Async
public void asyncMethodWithException() {
throw new RuntimeException("异步任务出错了");
}
调用后,控制台可能打印出错误堆栈,也可能没有(取决于默认配置)。但调用方(如 Controller)并不感知这个异常,它也无法在调用点捕获,因为调用已经立即返回了。传统的 try-catch 无法捕获异步任务内部的异常。
原因在于:异常发生在另一个线程中,调用线程根本不知道,也无法直接处理。同时,Spring 的 @Async 默认将异常记录到日志,但具体是否打印取决于异步执行器是否处理了异常。实际上,Spring 在内部会捕获异步方法抛出的异常,如果方法返回类型是 void,它会被 AsyncExecutionInterceptor 捕捉并记录到日志,但不会向上传播给调用方;如果方法返回 Future,则异常会被封装到 Future 中,当你调用 Future.get() 时才会抛出。
这里最容易误解的是 :以为 catch(Exception e)能捕获异步任务异常。在异步场景下,try-catch 包裹的是提交动作,而不是任务执行动作,所以它捕获不到。
5.2 异常处理的第一种:返回 Future
通过返回 Future,我们可以主动获取任务执行结果和异常。修改一下异步方法:
java
import org.springframework.scheduling.annotation.AsyncResult;
import java.util.concurrent.Future;
@Async
public Future<String> asyncMethodWithReturn() {
// 若出现异常,会被封装到Future中
if (true) {
throw new RuntimeException("异步任务出错");
}
return new AsyncResult<>("成功");
}
调用方可以这样处理:
java
NotificationService service = ...;
Future<String> future = service.asyncMethodWithReturn();
try {
String result = future.get(); // 会阻塞直到任务完成
// 处理成功结果
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // 获取真正的异常
log.error("异步任务执行失败", cause);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
优点 :可以精确获取异常,并选择重试或补偿。缺点:调用方必须等待任务完成,失去了异步的即时性。适合需要关注结果的场景。
5.3 异常处理的第二种:AsyncUncaughtExceptionHandler
如果不关心返回结果,只是想统一记录异常,可以注册全局异步异常处理器。只需要实现 AsyncUncaughtExceptionHandler 接口,并在配置类中指定。
配置示例:
java
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import java.lang.reflect.Method;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncExceptionConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// 返回你自定义的线程池,可以为null使用默认
return null;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncUncaughtExceptionHandler() {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
System.out.println("异步方法 " + method.getName() + " 抛出未捕获异常:" + ex.getMessage());
// 这里可以进行告警、记录日志、发送通知等
}
};
}
}
当 @Async 修饰的方法返回 void,且抛出异常时,Spring 会调用这个处理器,所有未被捕获的异常都汇聚到这里。这样异常不会丢失,你可以统一格式记录到日志或上报监控。
5.4 异常处理的最佳实践:使用 CompletableFuture
CompletableFuture 结合了 Future 的异常捕捉和回调机制,更灵活。异步方法返回 CompletableFuture,可以让异常传播出来,并在调用方里链式处理。
示例:
java
import org.springframework.scheduling.annotation.Async;
import java.util.concurrent.CompletableFuture;
@Service
public class PaymentService {
@Async
public CompletableFuture<String> processPayment() {
return CompletableFuture.completedFuture("支付成功");
}
}
调用方可以这样:
java
service.processPayment()
.thenApply(result -> result + ", 发积分")
.exceptionally(ex -> {
System.err.println("支付处理异常: " + ex.getMessage());
return "支付失败";
});
理解:thenApply 链式处理前一步的结果,exceptionally 是异常时的补偿路径。
6. 核心机制三:任务拒绝------池子满了怎么办
6.1 四种拒绝策略对比
当线程池的线程数已达最大值且队列已满,新任务会被拒绝。ThreadPoolExecutor 提供了四种拒绝策略:
| 策略 | 行为 | 使用场景 |
|---|---|---|
| AbortPolicy (默认) | 直接抛出 RejectedExecutionException |
任务不能丢弃,想快速感知系统过载 |
| CallerRunsPolicy | 调用者线程执行该任务 | 不想丢任务,但可能阻塞调用线程,造成接口变慢 |
| DiscardPolicy | 默默丢弃任务 | 允许丢任务,如临时性、可重试任务 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,重试被拒绝的任务 | 需要淘汰旧任务来为新任务让路,对新任务友好 |
默认的 AbortPolicy 会让你的异步操作在高峰期直接抛异常,导致业务中断。很多团队会选择 CallerRunsPolicy,但要注意它会让调用线程执行异步任务,如果异步任务很重,可能导致原本应该快速返回的接口被拖慢,甚至把 web 容器线程池也拖满,变为雪崩。
所以选择时需要考虑 :如果任务允许丢弃(比如缓存刷新、非关键数据统计),可以用 DiscardPolicy;如果任务重要但可以降级(比如发送通知),可以考虑用 DiscardOldestPolicy(丢弃早任务)或者用 CallerRunsPolicy 并做好监控。
6.2 拒绝策略的完整示例
让我们用代码演示各种策略的效果。这里我们构造一个会触发拒绝的场景,并对比不同策略的行为。为了演示,我们配置一个极小的线程池,核心线程 1,最大线程 2,队列容量 1,然后提交 5 个任务,每个任务 sleep 几百毫秒。
先定义线程池配置:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class RejectConfig {
@Bean("smallTaskExecutor")
public ThreadPoolTaskExecutor smallTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("reject-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 通过构造器传入不同的策略
executor.initialize();
return executor;
}
}
任务类:
java
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class TaskService {
@Async("smallTaskExecutor")
public void execute(int i) {
String t = Thread.currentThread().getName();
System.out.println("开始任务 " + i + ",线程: " + t);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("完成任务 " + i + ",线程: " + t);
}
}
调用方:
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RejectController {
@Autowired
private TaskService taskService;
@GetMapping("/submit")
public String submit() {
for (int i = 0; i < 5; i++) {
try {
taskService.execute(i);
} catch (Exception e) {
System.out.println("任务 " + i + " 被拒绝: " + e.getClass().getSimpleName());
}
}
return "done";
}
}
预期观察(使用 AbortPolicy 时):
- 任务 0 被核心线程执行。
- 任务 1 进入队列。
- 因为队列满,创建最大线程(第二个线程),任务 2 被新线程执行。此时线程数达到 max=2。
- 队列里已有任务1,新任务3,线程都已忙,队列也满,任务3被拒绝,抛出
TaskRejectedException(它是RejectedExecutionException的子类)。 - 任务4也被拒绝。
若换成 CallerRunsPolicy,则任务3、4会由调用方线程(即 web 线程)执行,不会抛出异常。
这个例子告诉我们 :默认的 AbortPolicy 在高峰期容易导致误伤;CallerRunsPolicy 将压力转移到上游。选择哪种策略,需要业务上明确对丢任务的态度。
7. 核心机制四:上下文传递------异步让信息丢失
7.1 为什么需要传递上下文?
在线程切换时,每个线程拥有自己的局部变量、事务、安全上下文(如 SecurityContext)、请求参数(如 RequestContextHolder)等。这些默认不会共享给异步线程。比如你在 @Async 方法中尝试获取当前登录用户,可能会得到 null 或空。事务更是如此,异步方法的事务和调用方不在同一个事务中,甚至没有事务(除非异步方法自身加事务注解,它会在新线程中开启新事务)。
我们为什么要传递上下文? 因为很多业务逻辑依赖请求中的信息,比如订单号、用户 ID、traceId 用于日志追踪。如果异步线程拿不到这些,就无法正确关联数据,排查问题也困难。
7.2 Spring 提供的便捷方案:TaskDecorator
Spring 的 ThreadPoolTaskExecutor 支持设置 TaskDecorator,它在每次提交任务时对任务进行包装,可以把调用线程的上下文设置到执行线程中去。这是一个常用的解决方案。
下面我们通过复制 RequestContextHolder 的请求属性和 MDC 中的 traceId 来做演示。
首先,让我们创建一个自定义的 TaskDecorator:
java
import org.springframework.core.task.TaskDecorator;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import java.util.Map;
import org.slf4j.MDC;
public class ContextCopyingDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 捕获调用线程的上下文
RequestAttributes context = RequestContextHolder.getRequestAttributes();
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
// 在异步线程中设置上下文
RequestContextHolder.setRequestAttributes(context);
MDC.setContextMap(contextMap);
runnable.run();
} finally {
// 清理,避免线程复用导致上下文污染
RequestContextHolder.resetRequestAttributes();
MDC.clear();
}
};
}
}
然后在配置类中启用这个装饰器:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfigWithDecorator {
@Bean(name = "contextAwareExecutor")
public Executor contextAwareExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setTaskDecorator(new ContextCopyingDecorator());
executor.setThreadNamePrefix("ctx-async-");
executor.initialize();
return executor;
}
}
然后在 @Async 中指定使用这个线程池:
java
@Async("contextAwareExecutor")
public void sendNotification(Order order) {
// 现在可以获取当前请求的用户信息或MDC中的traceId了
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
// ...
}
7.3 事务传递:一个不可跨越的边界
关于事务,需要特别注意:调用方的事务边界不会传播到异步线程 。如果异步方法中需要数据库操作,每个异步方法都有自己的事务(如果它有 @Transactional),或者没有事务。不要指望多个异步操作共享同一个事务。在设计时应尽量让异步任务只做不依赖强一致性的操作,或者用消息队列等异步系统来保证最终一致性。
为什么不能跨线程传播事务? 事务的本质是绑定到线程的数据库连接。Spring 的事务管理器把连接放在 ThreadLocal 中,而异步线程有自己独立的 ThreadLocal,看不到主线程的连接。强行传播会引起连接管理和事务隔离的复杂性,Spring 刻意设计为不跨线程传播。
8. 整体过程:一次业务请求的全景推演
把前面的知识串起来,我们来看一个贴近业务的完整案例。
场景:创建订单成功后,异步执行两个任务:1) 发送短信通知;2) 更新用户的积分。
要求:
- 发送短信不可失败(至少不抛出异常),如果失败要记录日志。
- 积分更新成功要提交,失败要能感知。
- 线程池不能爆,不能因为异步任务把主接口拖垮。
- 日志中要有关联的 trace-id 和用户 id。
实现方案:
- 自定义线程池,使用
CallerRunsPolicy并配置 queue 大小和核心线程。 - 自定义
AsyncUncaughtExceptionHandler统一捕获 void 方法异常。 - 返回
Future的方法让调用方感知异常(积分更新)。 - 使用
TaskDecorator传递上下文。
关键的代码结构如下:
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(15);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("order-async-");
executor.setTaskDecorator(new ContextCopyingDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("异步任务执行异常, method: {}", method.getName(), ex);
}
}
业务 Service:
java
@Service
public class OrderService {
@Async
public void sendNotification(Order order) {
// 发送短信,如果异常则被全局处理器捕获,不会影响主流程
try {
smsService.send(order.getPhone());
} catch (Exception e) {
log.warn("短信发送失败,订到ID: {}", order.getId(), e);
}
}
@Async
public CompletableFuture<Void> updateScore(Order order) {
return CompletableFuture.runAsync(() -> {
// 更新积分,可能抛异常,异常会封装到CompletableFuture中
scoreService.addScore(order.getUserId(), order.getAmount());
});
}
}
主业务代码中:
java
public void createOrder(Order order) {
// 同步保存订单...
orderService.sendNotification(order);
CompletableFuture<Void> future = orderService.updateScore(order);
// 如果需要,可通过future.whenComplete进行结果处理或补偿
future.whenComplete((result, ex) -> {
if (ex != null) {
log.error("积分更新失败,将尝试补偿", ex.getCause());
// 发送MQ进行补偿
}
});
// 返回响应
}
推演一次请求流程:
text
1. 用户下单请求进入 Controller,在 tomcat 线程中执行。
2. 同步保存订单,成功后,调用 orderService.sendNotification()(提交异步任务)。
任务先被线程池接收:如果核心线程有空闲,立即执行;否则入队。
3. 几乎同时调用 orderService.updateScore(),返回 CompletableFuture。
4. Controller 返回响应,主线程释放。
5. 异步线程从队列取任务,执行 sendNotification,成功后继续下一个任务。
6. 若队列满且线程忙,按照 CallerRunsPolicy,主线程可能亲自执行任务,此时主线程会被阻塞,响应时间变长。
7. 若积分更新抛出异常,该异常被封装在 CompletableFuture 中,通过 whenComplete 捕获并触发补偿。
8. 若 sendNotification 内部抛出异常,被 AsyncUncaughtExceptionHandler 统一记录。
9. 由于 TaskDecorator,异步线程可以获得 MDC 中的 traceId,日志能串联起来。
关键成功标准:主接口响应时间不显著增加,异步任务不丢失(除非极端过载),异常可见,日志可追踪。
9. 常见误区与设计取舍
9.1 常见误区速查表
| 误区 | 真相 | 后果 |
|---|---|---|
| 认为 @Async 默认使用自定义线程池 | Spring Boot 默认使用 SimpleAsync(每次新建线程)或单线程池 | 高并发下线程无限增长或只有单线程,吞吐量低 |
| 在调用 try-catch 中捕获异步异常 | 异步异常发生在其他线程,调用方无法捕获 | 异常丢失,系统无感知 |
| 认为 @Async 与 @Transactional 可以在同一线程中传播事务 | 事务不跨线程 | 异步方法内如果没有事务,操作处于自动提交模式,容易产生不一致 |
| 把 maxPoolSize 设得很大解决并发 | 线程过多导致上下文切换开销 | 性能下降,甚至资源耗尽 |
| 忽略 TaskDecorator 传递上下文 | 异步线程无法获得请求信息/RDC | 日志无法关联,业务取不到用户ID |
9.2 设计取舍:异步还是消息队列?
@Async 适用于进程内简单异步,当你需要可靠投递、重试、削峰填谷时,应考虑消息队列(如 RabbitMQ、Kafka)。两者对比:
| 维度 | @Async | 消息队列 |
|---|---|---|
| 投递可靠性 | 内存队列,进程重启可能丢失 | 支持持久化,可靠投递 |
| 削峰 | 依赖队列,容量有限 | 可缓冲大量消息 |
| 重试机制 | 需自己实现 | 通常自带重试和死信 |
| 复杂度 | 简单,侵入式 | 引入外部组件 |
取舍建议 :如果异步任务对一致性要求不高、允许丢失、且并发量可控,用 @Async 成本低;如果任务关键,必须不丢,或需要解耦,请选择 MQ。
10. 生产实践建议
- 永远显式自定义线程池:不要依赖 Spring Boot 默认行为,自己定义 core、queue、max,并设置有意义的线程名前缀。
- 拒绝策略选 CallerRunsPolicy 还是 DiscardOldestPolicy :如果任务可以容忍延迟,用
DiscardOldestPolicy(丢弃最老,保证较新任务),但要注意丢弃逻辑;如果不愿丢任务但可接受主线程阻塞,用CallerRunsPolicy,并监控主线程的阻塞增长。建议默认使用CallerRunsPolicy,并配合监控告警,当主线程阻塞增多时能快速扩容线程池。 - 异步方法返回 void 时,必须配置全局 AsyncUncaughtExceptionHandler:将异常日志统一记录并上报。
- 需要结果或重试时,返回 CompletableFuture:用 whenComplete 或 exceptionally 处理异常。
- 设置线程池监控:通过 Actuator 或自定义 metrics 监控 poolSize、activeCount、queueSize、rejectedCount,并设定告警阈值。
- 传递上下文:实现 TaskDecorator 复制 MDC 和请求上下文,但要注意清理,避免线程池复用造成信息污染。
- 不要在异步任务中做大量数据同步:如果有大量数据迁移,评估是否改用批处理或 MQ。
11. 排障清单:当异步任务不工作或丢失时
当遇到异步任务没有执行或丢失时,可以按以下顺序排查:
- 是否添加了 @EnableAsync? 在配置类中检查。
- 调用方是否通过代理调用? 检查是否同一类内部调用或 final 方法。
- 线程池是否撑爆? 查看监控中 rejectedCount 是否增长,或日志是否出现
TaskRejectedException。 - 异步方法是否返回 void 并抛异常? 查看全局
AsyncUncaughtExceptionHandler有无日志,是否已配置。 - 上下文传递是否失败? 在异步方法中打印用户ID,检查是否为 null。
- 数据库连接池是否足够? 如果异步任务访问数据库,可能因连接池耗尽而阻塞或失败。
- 日志中是否有 traceId? 如果没有关联,检查 MDC 是否被传递。
12. 面试/复盘问题
以下问题可用于自查或团队复习:
@Async默认使用什么执行器?为什么不推荐?- 如何捕获
@Async方法返回 void 时的异常? - 如何设计异步任务的上下文传递?
- 线程池的饱和策略有哪些?如何选择?
- 为什么
@Transactional不会传播到异步线程?你可以如何设计补偿? - 在生产中,你会监控异步线程池的哪些指标?
13. 总结:一张决策清单
核心要点收回:
| 场景 | 选择 |
|---|---|
| 任务允许丢失(日志、监控) | 使用 DiscardPolicy 或直接忽略 |
| 任务不允许丢失但可延迟 | 使用 CallerRunsPolicy,并做好主线程监控 |
| 需要知道执行结果 | 返回 CompletableFuture |
| 不需要知道结果,但要统一记录异常 | 实现 AsyncUncaughtExceptionHandler |
| 需要传递上下文 | 装饰线程池 TaskDecorator |
| 任务重要且量大 | 使用消息队列 |
| 并发高且线程池长期满 | 扩容线程池,并评估是否达到瓶颈 |
14. 参考资料
- Spring Framework Documentation: Task Execution and Scheduling - https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- Spring Boot Reference Guide: Asynchronous Methods - https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
- Java Concurrency in Practice, Brian Goetz, et al., Addison-Wesley, 2006
- ThreadPoolExecutor JavaDoc - https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
- CompletableFuture JavaDoc - https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletableFuture.html