SpringBoot4系列12 - 使用异步任务与线程池
前阵子我盯着一个下单接口,落库本身很快,麻烦都在落库之后:给客户发短信通知要 600 毫秒,积分入账要 700 毫秒,报表聚合要 800 毫秒。原来的写法是三件事按顺序排在一个方法里,我拿 curl 压了一把,接口响应 2155 毫秒,高峰期每个请求都把 HTTP 线程占住两秒多,吞吐量被这几件慢活按在地上。

这三件慢事没有一件影响下单结果,它们只是要在下单之后发生。把「做」和「等结果」拆开,落库完成就把请求放行,三件慢事丢给线程池并行去跑,同一套流程的接口响应降到 23 毫秒。@EnableAsync 加 @Async 两个注解负责拆,线程池的形态负责兜住流量。
这篇全按 Spring Boot 4.1.1 加 Framework 7.0.9 的写法来。说实话,异步里的坑比注解本身多:自调用静默失效、void 和返回值的异常去处完全不同、ThreadLocal 一过线程就断、默认配置的线程池队列无界堆积。每个坑我都在工程里做成了可以当场复现的探针,插画、响应面板和旁边的文字取自同一轮运行,线程名与毫秒值每轮会有抖动,机制取值以所在章节的图为准。
编程模型与自动配置的事实
动笔写代码之前,我先把 import 和依赖摸了一遍。第一件事实:没有专属 starter。我在 Maven Central 上探了 4.1.1,spring-boot-starter-task 返回 404,独立的 spring-boot-task 模块也不存在。异步能力由两部分组成,常规 web 工程零额外依赖:
| |
四个包各有分工。annotation 包里是注解与代理装配逻辑;autoconfigure.task 包里是 Boot 的装配与属性类;boot.task 包里是构建器与定制器;aop.interceptor 包里是异常处理的两个接口与默认实现。我写 import 就按这张清单核对,对不上的一律以它为准。
第二件事实与默认执行器的归属有关。TaskExecutionAutoConfiguration 会创建一个名字叫 applicationTaskExecutor 的 ThreadPoolTaskExecutor,但它身上挂着 @ConditionalOnMissingBean(Executor.class):容器里只要出现任何一个 Executor 类型的 bean,整套默认装配立刻退让。我自定义了两个池,然后拿一条测试把这个判断钉死:
| |
两个断言都是 false。挺有意思,自己建池的工程里 Boot 的默认执行器压根不存在,与「感觉上还有个默认池兜底」的直觉相反,排错时省得在两个池之间来回猜。
第三件事实是 Boot 替 @Async 补的接线。上下文里没有用户自己的 AsyncConfigurer 时,Boot 注册一个 applicationTaskExecutorAsyncConfigurer(实现 AsyncConfigurer,条件同样是 @ConditionalOnMissingBean),把 @Async 不写限定符的方法接到 applicationTaskExecutor。我声明了自己的 AsyncConfigurer 之后它就退位,默认执行器与异常处理器都由这份声明决定。
装配出来的执行器按平台线程与虚拟线程分两条分支:
| |
代理机制也在这里说清。@EnableAsync 的 proxyTargetClass 属性默认值是 false,但 Boot 的 spring.aop.proxy-target-class 默认是 true,Boot 应用里的 @Async 代理实际是 CGLIB 子类代理。@Async 的 value 属性是执行器 bean 的限定符,填了就走指定名字的池,不填走 AsyncConfigurer 给的默认池。
属性前缀没有改名,spring.task.execution 的注解定义在字节码里原样可查。属性类构造器里的默认值,我逐个翻出来验过:
| |
默认组合里最要命的是队列:core 8、queue 无界。无界队列永远填不满,线程数永远停在 8,max-size 配了也到不了。高峰期的任务全部堆在队列里不报错,内存涨上去你才发现。我在工程里做的关键演示之一,就是把队列改小,让拒绝路径现出原形。
依赖和配置
工程依赖清单里没有任何异步坐标,web 与 JDBC 就够了:
| |
订单落 MySQL,表结构与前几篇一致。配置文件里与异步相关的只有一处 spring.task.execution,再加一组演示耗时:
| |
线程名前缀 biz- 是我给排查留的记号,后面所有验证输出里的线程名都以它开头,日志里一眼能认出哪些活是异步线程干的。三件慢事的耗时收进配置,接口响应时间的对比来自这组值:600 加 700 加 800,同步串行 2100 毫秒起步。
两条路配线程池
异步的执行质量由线程池决定。属性已经配好,怎么让它流进自己声明的池里,看装配代码:
| |
bizExecutor 没有一行 setter,core 4、max 8、队列 100、前缀 biz- 全部来自 yml。构建器把这些属性吃进默认值,属性文件与代码各管各的,改池参数不用动 Java。我在测试里对池做了逐项断言,四个属性值与 yml 完全一致,属性到池的通路被钉死。
第二条路是完全接管,这是我给演示拒绝行为准备的有界池:
| |
core 2、queue 2、max 4,一组刻意收窄的参数。拒绝策略换成了自定义实现:拒绝前先计数再抛出,这个计数器就是后面实测里「拒绝 4 次」的来源。这条链路的分配规则要先记一遍:任务来了先占 core,core 满了进队列,队列满了扩线程到 max,max 也满了才触发拒绝。JDK 默认的 AbortPolicy 什么也不做直接抛,ThreadPoolTaskExecutor 把它包成 TaskRejectedException 丢回提交方。
两个池就位之后,@Async 的默认走向需要一份声明:
| |
AsyncConfigurer 只有两个方法,且都是 default:一个给默认执行器,一个给未捕获异常处理器,按需覆盖。这份声明同时解决两件事:不写限定符的 @Async 走 bizExecutor,void 方法的异常流进自己的处理器。我声明它的那一刻,Boot 自动装配的那份接线整体退位。

顺带记一个定制器的事实:ThreadPoolTaskExecutorCustomizer 存在于 org.springframework.boot.task 包,注册成 bean 后被构建器自动收集,改线程名、换装饰器这类浅定制够用。与它并排的还有 SimpleAsyncTaskExecutorCustomizer、ThreadPoolTaskSchedulerCustomizer、SimpleAsyncTaskSchedulerCustomizer 三个,全是具体实现类级别的定制器,没有泛型的 TaskExecutorCustomizer。
@Async 的两种返回与异常的去处
三件慢事的代码长这样,一个 void、一个带返回值:
| |
方法体里没有一行线程管理的代码,注解接管全部调度。入参里带着 taskId,任务一开始就在台账上登记自己的线程名与开始时间,这就是后面所有线程证据的来源。返回值分两种:void 的短信任务发完就结束;积分任务返回 CompletableFuture,调用方拿着它等结果。
两种返回对应两条完全不同的异常去处。void 方法的异常没有地方搁,框架把它交给 AsyncUncaughtExceptionHandler,默认实现 SimpleAsyncUncaughtExceptionHandler 记一条 ERROR 日志了事。我换成自己的处理器:
| |
处理器做三件事:计数加一、把台账里的对应记录标成 FAILED、按 WARN 记日志。日志级别打 WARN 是我的选择,异常已经有了计数与台账两个去处,应用的 ERROR 指标保持干净。验证里这个计数与日志 WARN 行数都是 1,两个口径对得上。
CompletableFuture 方法的异常走另一条路,处理器的签名里没有它的位置。异常被装进 future,调用方执行 get() 的那一刻才以 ExecutionException 的形式浮出来:
| |
实测输出里,future 异常任务的台账状态是 FAILED,查询接口的 futureException 字段是 java.util.concurrent.ExecutionException: java.lang.IllegalStateException: 演示异常:积分服务超时,同一轮里 uncaughtHandled 保持不变。异常去处的分流规则由此钉死:void 进处理器,future 等调用方来取,写代码时按返回类型决定防线设在哪一侧。

自调用失效与 ThreadLocal 断层
自调用是 @Async 最常见的静默失效,我测试就栽在这里。同一个 bean 里方法 A 调方法 B,B 上标了 @Async,调用走的却是 this 引用,代理完全没有机会切进去,B 就在 A 的线程里同步跑完。工程里把两条调用路径并排放进一个探针:
| |
self 是注入到自身类型的字段,拿到的是容器里被 CGLIB 包装过的代理。这里有一个 Boot 侧的细节:Bean 默认不允许循环引用,直接 @Autowired 自己的类型会启动失败,报 Requested bean is currently in creation,字段上要加 @Lazy 才能拿到延迟解析的占位。
探针的输出一次说清两条路径:
| |
this 调用的执行线程与 HTTP 线程逐字相同,注解没有生效;代理调用落在 biz-2。判断项目里有没有踩这个坑,最快的办法就是把两类线程名都打出来比对,排除下来九成是这个原因。
ThreadLocal 是另一条断层。HTTP 线程写入的值,异步线程读出来是 null:
| |
实测输出:HTTP 线程里是 trace-0b79adcd,异步线程 biz-3 里是 null。链路追踪、安全上下文(SecurityContext)、事务同步器都挂在这类 ThreadLocal 上,跨线程之后全部丢失。要传递就显式传参,或者给池挂 TaskDecorator 把上下文复制过去,Boot 还提供了开关:spring.task.execution.propagate-context=true 时自动装配 ContextPropagatingTaskDecorator,处理 ThreadContext 这类标准上下文。

拒绝策略的现场
拒绝行为要有一组够小的参数才能看到。我向 boundedExecutor 灌 10 个各睡 1 秒的任务:
| |
提交动作在几毫秒内完成,池的分配是确定性的:2 个占 core,2 个进队列,2 个触发扩容到 max,剩下 4 个被拒绝。实测输出:
| |
提交侧的 TaskRejectedException 与计数器的 4 一致。把这条链路换成默认配置再看一遍就明白风险在哪:core 8 加无界队列,任务只进队列不扩容也不拒绝,堆积的唯一表现是内存与延迟。有界队列加明确的拒绝策略,溢出从「慢性病」变成「当场报警」。

完整案例,跑起来看
同一个下单动作,工程里给出异步与同步两个入口。同步入口先把三件慢事串行做完:
| |
响应 201,elapsedMillis 是 2165。这就是被拖死的版本,数字与配置里三个耗时的和严丝合缝。
异步入口只提交不等待:
| |
响应 201,返回 taskId,elapsedMillis 是 18。三件慢事在后台并行跑,POST 的响应体里看不出它们的下落,去向在任务台账接口里:
| |

台账面板里一次看清三类证据。聚合状态是 DONE;三条执行记录分别是 sms、points、report,耗时 609、782、807 毫秒,线程名是 biz-1、biz-2、biz-3,三条不同线程并行取活;积分任务的返回值 points 等于 10,future 正常完成时查询接口直接给出结果。三个任务同时投递、核心池 4 根线程充足,互不排队,毫秒级响应就是这么来的。

探针拼板是另一组运行期证据:自调用探针里 selfCallThread 与 httpThread 同为 http-nio-18120-exec-3,代理调用落在 biz-4;ThreadLocal 探针里 HTTP 线程的 trace-626533f9 在异步线程 biz-1 上读出 null;void 异常之后计数器 uncaughtHandled 等于 1;future 异常任务的台账状态 FAILED,futureException 字段是完整的 ExecutionException 链。截图轮与开篇引子的 2155 对 23 来自不同的运行轮次,毫秒级抖动不改变量级结论。拒绝策略在验证脚本一轮里接受 6 拒绝 4,两轮的应用日志都是 ERROR 0 行,ASYNC_UNCAUGHT 的 WARN 与 uncaughtHandled 计数逐轮对得上。执行记录是这套演示的中枢,每条记录带着线程名、耗时、异常类型,行为变化当场能对出来。
这一篇的测试怎么写
17 个用例全连真实 MySQL,异步相关的断言集中在三类:配置事实、异常分流、并行行为。配置事实的用例在前面已经见过,并行行为的断言我用 Awaitility 轮询台账,不打时间的主意:
| |
轮询直到三个任务全部 DONE,再对台账里的线程名做逐条断言:每条都以 biz- 开头,不同线程的数量大于等于 2。异步测试最容易写成 Thread.sleep(3000) 然后祈祷,我早年就这么干过,闹过测试时好时坏的笑话;Awaitility 把「等到位」变成可重试的条件断言,快机器上几百毫秒就过,慢机器上也不脆。
异常分流的两个用例分别钉住两条去处:
| |
其余用例的分布:配置事实 5 个(属性进池、有界池参数、默认执行器退让、AsyncConfigurer 接线、@EnableAsync 在位),接口 4 个(异步毫秒级、同步不低于 2 秒、校验 400、查询 404),主流程 4 个(三任务并行、台账聚合、自调用失效、ThreadLocal 断层),异常分流 3 个,拒绝策略 1 个。拒绝策略的断言是确定性的:灌 10 个任务,接受 6、拒绝 4,两个数都与池参数的推导一致。
避坑指南
坑一,默认队列无界,堆积不报警。 queue-capacity 默认 Integer.MAX_VALUE,core 8 之外永远不扩容,max-size 形同虚设。高峰期任务全部压在队列里,内存涨、延迟升,日志里一个报错都没有。我建议上生产前把队列改小、把拒绝策略显式定下来,让溢出当场暴露。
坑二,this 自调用让 @Async 静默失效。 同类内部调用走不到代理,方法退化为同步执行,没有任何报错。判断方法很简单:把执行线程名打出来,与调用方线程一致就是实锤。解法是拆到另一个 bean,或者注入自己的代理(字段加 @Lazy,否则循环引用直接启动失败)。
坑三,void 与返回值的异常去处不同。 void 方法的异常进 AsyncUncaughtExceptionHandler,默认实现只记一条 ERROR 日志;CompletableFuture 的异常待在 future 里,调用方不调 get() 就永远没人知道。防线要按返回类型设:void 靠处理器计数与告警,返回值靠调用方 try 住 ExecutionException。
坑四,ThreadLocal 与安全上下文不跨线程。 链路追踪 ID、SecurityContext、事务同步器挂在 ThreadLocal 上,线程一换全部为 null。显式传参最稳;要自动传递就挂 TaskDecorator,或开 spring.task.execution.propagate-context=true。事务边界的坑在 06 篇写过一遍,跨线程同样生效。
坑五,自己声明 Executor bean 之后,默认执行器整套退让。 @ConditionalOnMissingBean(Executor.class) 的判断范围是所有 Executor 类型,自定义池一出现,applicationTaskExecutor 与 Boot 的 AsyncConfigurer 接线一起消失。此时 @Async 的默认执行器由自己的 AsyncConfigurer 决定,不声明它,默认池的解析就会落到不确定的分支上。
坑六,@Async 方法要 public、要从外部调。 代理拦截的是外部进入的调用,private 方法标了注解也不生效,同类内部调用同样不生效。方法返回 Future 之外的自定义类型时,框架按同步方法处理,返回值原样带回。
坑七,虚拟线程开关会换掉执行器实现。 spring.threads.virtual.enabled=true 时默认执行器从 ThreadPoolTaskExecutor 换成 SimpleAsyncTaskExecutor,bean 名字变为 applicationTaskExecutorVirtualThreads,pool 参数的语义随实现变化。依赖具体实现类的代码先 instanceof 确认,测试里钉一遍最稳。
坑八,优雅停机要在属性里声明。 shutdown.await-termination 加 await-termination-period 让在跑的任务执行完再退。异步任务写到一半被 kill,积分入账落在半截,重试逻辑要在业务侧自己兜住。积分这类天然可重入的任务靠幂等键防重复,通知类任务重复发一次的代价要事先想清楚。
小结
选型建议就三句。常规 web 工程把池参数收进 spring.task.execution,经 ThreadPoolTaskExecutorBuilder 建池,别拿默认的无界队列裸奔;要自己掌控执行策略,就手工建池配好拒绝策略,再把 AsyncConfigurer 接过来,让默认执行器与异常处理器都听你的;异步测试用 Awaitility 轮询任务台账,不赌固定时延。异常防线按返回类型设,上下文要跨线程就显式传参或挂 TaskDecorator。
没解决的事也直说:本篇只给了单接口串行与异步两组响应数,没有做并发压测基准;拒绝策略是确定性推导加一轮实测,持续高压下队列深度与扩容的动态行为我没观察。这两块等我补上压测场景再谈,本文不给没测过的结论。
参考链接
- Spring Framework Task Execution and Scheduling:
@Async注解、AsyncConfigurer与异常处理链的官方说明 - Spring Boot Reference:
spring.task.execution.*属性清单与 task 自动配置的行为 - ThreadPoolTaskExecutorBuilder:构建器链式方法的 API 文档
- spring-boot-autoconfigure 4.1.1:本文包位置与属性前缀的制品实证来源