本节目标:把「借阅成功后异步发通知」这类后台动作做对——理解
@Async的代理前提、默认执行器为什么危险、如何自定义线程池并传递上下文,以及虚拟线程开启后该怎么选型。
适用版本:Spring Boot 4.1.x(Java 21)
10.1 @Async 与线程池
9.3 把租户上下文和授权落到了方法级。可一旦「借阅成功后发一封通知邮件」这类动作被挪到后台线程,第一个撞上的问题就是线程池:谁来跑、能跑几个、跑满了怎么办、上下文怎么带过去。本节把 @Async 从「加个注解就能用」讲到「能上生产」。
10.1.1 代理前提:@Async 为什么常常没生效
@Async 的生效方式和 9.3 的方法级安全完全一样:靠 Spring AOP 代理。这意味着它有三个前置条件,缺一个就静默失效。
@SpringBootApplication
@EnableAsync // 1. 必须显式开启,没有它注解形同虚设
public class LoanApplication {
}
@Service
public class LoanService {
public void borrow(Long bookId, Long memberId) {
// 2. 同类内部调用 this.notifyMember(...) 不走代理,注解失效
notifyMember(memberId);
}
@Async
public void notifyMember(Long memberId) {
// 发通知
}
}
第三条是方法的可见性:被 @Async 标注的方法必须是 public,且不能被 final 修饰(final 方法无法被 CGLIB 覆写)。把上面三条件排成一张表:
| 条件 | 违反后的现象 | 排查手段 |
|---|---|---|
有 @EnableAsync | 注解完全无效,同步执行 | 检查主配置类 |
| 调用经过代理(非自调用) | 同步执行,日志里没有异步线程名 | 看线程名是否为 task-* |
方法是 public 非 final | 注解无效 | 用 javap 或代码审查 |
自调用是最常见的一种。把 notifyMember 拆到独立的 NotificationService 里,由 LoanService 注入后调用,代理就能介入:
@Service
public class NotificationService {
@Async("loanExecutor")
public void notifyMember(Long memberId) {
// 真正的后台动作
}
}
排查「到底有没有异步」最直接的办法是看线程名。Spring Boot 自动配置的执行器线程名前缀默认是 task-,自定义后就是我们自己设的前缀。同步执行时线程名会是 http-nio-8080-exec-*,两者一眼可辨。
10.1.2 默认执行器:为什么 SimpleAsyncTaskExecutor 不能上生产
@Async 如果没有指定执行器,Spring 会去找一个默认的。这里有个容易被忽略的历史包袱:在没有 Spring Boot 任务自动配置的纯 Spring 环境里,默认回退到 SimpleAsyncTaskExecutor。
SimpleAsyncTaskExecutor 的名字很有迷惑性——它叫「Executor」却不是线程池。它的实现是每个任务新建一个线程,没有复用、没有队列、没有上限。并发一上来,线程数就线性增长,直到把栈内存耗光或触发系统级线程上限,表现是「服务平时没事,压测一开始就 OOM 或大面积超时」。
Spring Boot 修正了这一点:TaskExecutionAutoConfiguration 会注册一个名为 applicationTaskExecutor 的 bean(底层是 ThreadPoolTaskExecutor),并通过一个 AsyncConfigurer 把它设成 @Async 的默认执行器。所以只要任务自动配置在生效,默认就是有池的。但下面两种情况会退回去:
- 你在别处定义了自己的
TaskExecutorbean,导致自动配置退让; - 依赖里排除了
spring-boot-autoconfigure的任务配置。
判断标准很简单:启动日志里 @Async 任务跑的线程名前缀是不是你预期的。 默认自动配置给的池也偏保守,核心线程数默认只有 8,队列无界——无界队列意味着「永远不扩容、永远不拒绝」,压力全堆在内存里。生产上应当显式定义自己的池。
自动配置可调的参数(4.1.1 实测默认值):
| 属性 | 默认值 | 说明 |
|---|---|---|
spring.task.execution.pool.core-size | 8 | 核心线程数,虚拟线程开启后无效 |
spring.task.execution.pool.max-size | 无 | 最大线程数,队列无界时无效 |
spring.task.execution.pool.queue-capacity | 无(无界) | 队列容量,不设则 max-size 不生效 |
spring.task.execution.pool.keep-alive | 60s | 空闲线程存活时间 |
spring.task.execution.thread-name-prefix | task- | 线程名前缀 |
spring.task.execution.mode | auto | auto 按需创建,force 总是创建 |
spring.task.execution.propagate-context | false | 是否把当前上下文传播到异步执行(4.1 新增) |
10.1.3 自定义线程池:把参数写进代码
生产上更推荐显式定义执行器,因为你要控制线程名前缀(排查用)、队列容量和拒绝策略。给「借阅异步任务」单独配一个池:
@Configuration
public class AsyncConfig {
@Bean("loanExecutor")
public ThreadPoolTaskExecutor loanExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200); // 有界队列,超过才扩容到 max
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("loan-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(new ContextCopyingDecorator());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30); // 优雅停机:最多等 30 秒把在途任务跑完
executor.initialize();
return executor;
}
}
@Async("loanExecutor") 里写的字符串就是这里的 bean 名。不写 bean 名就会用默认执行器,所以每个业务池都要显式引用,否则你以为自己配了池,实际跑在默认池上。
setWaitForTasksToCompleteOnShutdown(true) 配合 setAwaitTerminationSeconds(...) 是优雅停机的关键:应用收到停机信号时,先停止接收新任务,再给在途任务一段时间跑完。否则重启瞬间会丢掉一批已经投出去的通知——对「发邮件」这种可容忍丢失的动作问题不大,但对「写审计日志」就是数据缺失。
10.1.4 上下文传递:TaskDecorator
线程池里的线程是复用的,ThreadLocal 不会自动跟着任务走。9.3 讲的租户上下文就是个典型:如果异步任务里读 TenantContext.get(),拿到的可能是上一个任务残留的值,也就是串租户。
解法是给线程池装一个 TaskDecorator,在任务执行前后搬运上下文:
public class ContextCopyingDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
String tenant = TenantContext.get();
SecurityContext security = SecurityContextHolder.getContext();
return () -> {
try {
TenantContext.set(tenant);
SecurityContextHolder.setContext(security);
task.run();
} finally {
TenantContext.clear();
SecurityContextHolder.clearContext();
}
};
}
}
4.0 起,容器里若有多个 TaskDecorator bean,自动配置会用 org.springframework.core.task.support.CompositeTaskDecorator 把它们按顺序串起来,所以你可以把「租户搬运」和「MDC 日志搬运」拆成两个独立的 decorator,各管一摊。
需要区分两类上下文:
| 上下文类型 | 例子 | 谁来搬运 |
|---|---|---|
| 业务上下文 | 租户 id、当前用户 | 自己写 TaskDecorator |
| 可观测上下文 | trace id、span | 4.1 的上下文传播或 ContextPropagatingTaskDecorator |
4.1 为 @Async 增加了上下文传播能力,开启方式是把 spring.task.execution.propagate-context 设为 true;它底层用的是 ContextPropagatingTaskDecorator,服务于 Micrometer 的上下文(trace、observation)。它不负责租户这种业务上下文——业务上下文仍然要靠 TaskDecorator 或显式传参,不要指望开关一开全都自动带过去。
10.1.5 队列满时的拒绝策略
ThreadPoolTaskExecutor 底层是 ThreadPoolExecutor,它的调度顺序容易记错,这里明确一次:
- 线程数小于
corePoolSize,直接开新线程跑; - 达到核心数后,任务进队列;
- 队列满了,才把线程数扩到
maxPoolSize; - 队列满且线程数也到上限,触发拒绝策略。
这解释了「为什么我把 maxPoolSize 调到 64 也没用」:只要队列无界(queueCapacity 默认 0 表示无界),第 3 步永远不触发,线程数停在核心数,任务无限堆积直到 OOM。 所以有界队列是让扩容和拒绝生效的前提。
拒绝策略有四种,选哪个取决于你能否承受背压:
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 不能丢、上游能重试 |
CallerRunsPolicy | 由提交任务的线程自己执行 | 想自动降速、形成背压 |
DiscardPolicy | 静默丢弃 | 可丢的指标类任务 |
DiscardOldestPolicy | 丢队列里最老的一个 | 只要最新数据的场景 |
对「借阅通知」这种可容忍延迟但不可静默丢失的动作,CallerRunsPolicy 是稳妥的默认值:队列满时让 Web 线程亲自把通知发出去,请求变慢,但不会丢消息,也不会无界堆积。代价是它会占用请求线程,所以只适合单次耗时短的异步动作。
10.1.6 返回值与异常:void 的坑
@Async 方法支持两类返回值:void 和 CompletableFuture<T>。
@Async("loanExecutor")
public CompletableFuture<LoanSummary> summarize(Long memberId) {
LoanSummary summary = compute(memberId);
return CompletableFuture.completedFuture(summary);
}
返回 CompletableFuture 时,异常会随着 future 传播,调用方 .join() 或 .whenComplete(...) 能拿到:
loanService.summarize(memberId)
.whenComplete((summary, ex) -> {
if (ex != null) {
log.warn("异步汇总失败 memberId={}", memberId, ex);
}
});
返回 void 时异常无处可抛。 任务在另一个线程里抛出,调用方早就返回了,异常会跑到默认的 SimpleAsyncUncaughtExceptionHandler,只打一行日志,很容易被淹没。这类「静默失败」是异步最常见的生产事故来源。两种补救:
- 需要感知结果就用
CompletableFuture返回; - 确实要
void,就注册自己的AsyncUncaughtExceptionHandler:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("异步任务异常 method={} params={}", method.getName(), params, ex);
}
}
注意:一旦你实现了 AsyncConfigurer,就同时接管了默认执行器的选择。如果你还想要 Spring Boot 那个 applicationTaskExecutor,就要在 getAsyncExecutor() 里显式返回它,否则容易把默认池覆盖掉。
10.1.7 虚拟线程:还要不要线程池
Java 21 的虚拟线程改变了「异步 = 平台线程池」的默认假设。开启只需一行:
spring.threads.virtual.enabled=true
开启后,Spring Boot 会把默认执行器换成基于虚拟线程的 SimpleAsyncTaskExecutor(内部 setVirtualThreads(true)),@Async 任务每次在一条虚拟线程上跑。
关键在于池参数随之失效:spring.task.execution.pool.core-size、max-size、queue-capacity 的官方描述都明确写着「虚拟线程开启后不生效」。因为虚拟线程不是稀缺资源,不需要靠池来复用——需要的是别把并发无节制地放大。此时真正的限流参数换成了简单执行器的并发上限:
| 场景 | 参数 |
|---|---|
| 平台线程池 | spring.task.execution.pool.* + 拒绝策略 |
| 虚拟线程 | spring.task.execution.simple.concurrency-limit |
| 虚拟线程 | spring.task.execution.simple.reject-tasks-when-limit-reached |
选型建议:
- I/O 密集型、任务数多、每个任务以阻塞等待为主(调用外部接口、发邮件、读数据库):虚拟线程收益明显,可以开启。
- CPU 密集型(图像处理、大量计算):虚拟线程没有优势,平台线程池 + 固定大小更合适,避免把 CPU 抢成上下文切换。
- 依赖
ThreadLocal且没配 decorator 的老代码:先补齐上下文传递再谈虚拟线程,否则问题从「串租户」变成「虚拟线程里也串」。
虚拟线程不是「免费提速」。它把线程变便宜了,反而让「无限制地开并发」更容易发生,所以 concurrency-limit 这类显式上限在虚拟线程下更重要,而不是更不重要。
10.1.8 常见坑
坑一:把 @Async 加在 private 或自调用方法上。 编译能过、运行同步执行、没有任何报错。用线程名验证是唯一可靠手段。
坑二:无界队列 + 调大 maxPoolSize。 两者一起用等于没配 maxPoolSize,任务无限堆积。要么设 queueCapacity,要么接受「不扩容」的事实。
坑三:用 void 又不管异常。 异步任务里的异常只留一行日志,业务上表现为「通知偶尔没发出去」却查不到原因。要么返回 CompletableFuture,要么注册 AsyncUncaughtExceptionHandler。
坑四:多个池共用一个 ThreadLocal 却没装 decorator。 串租户、串用户、串 MDC 都由此而来。每新建一个执行器,就检查一次它有没有 TaskDecorator。
坑五:在 @Async 方法里直接读请求作用域的 bean。 请求线程已经结束,RequestContextHolder 里没有绑定,会拿到空值或抛异常。需要的信息要在提交任务时当参数传进去,而不是在异步方法里现取。
小结
@Async靠 AOP 代理生效,自调用、private、final、缺@EnableAsync都会让它静默失效,用线程名排查。- 纯 Spring 环境下默认回退到
SimpleAsyncTaskExecutor(每任务一线程,不能上生产);Spring Boot 用applicationTaskExecutor兜底,但默认池队列无界,生产应显式定义。 - 有界队列是让
maxPoolSize与拒绝策略生效的前提;可容忍延迟不可丢的动作,CallerRunsPolicy是稳妥默认。 - 线程池线程复用,业务上下文必须靠
TaskDecorator搬运;4.1 的propagate-context只管可观测上下文,不管租户。 void异步方法的异常无处可抛,需要CompletableFuture或AsyncUncaughtExceptionHandler兜底。- 虚拟线程开启后池参数失效,限流改由
concurrency-limit承担,I/O 密集场景收益明显,CPU 密集场景不必强上。
异步把「发通知」挪到了后台,但它只解决了「当前进程内不阻塞」。当通知要跨越服务边界、要保证不丢,就需要把动作投递到消息中间件——这正是下一节的主题。
阅读导航:上一节:9.3 方法级授权与多租户 · 下一节:10.2 消息队列集成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。