☰
Spring MVC高并发异步优化:WebAsyncTask与线程池限流实践
2026/10/15 4:55:28 网站建设 项目流程

写在高并发环境里做 Spring MVC 的异步请求优化,这几个月我一直在调一套“异步+限流”的组合方案,涉及WebAsyncTask、@Async、线程池隔离、全局限流过滤器,折腾完测下来 QPS 和平均耗时都明显改善。这套东西要说简单也简单,但真要在生产环境里跑稳,坑全在细节上——Tomcat 线程池怎么让位、Service 层异步线程哪里来、限流过滤器在异步链路里到底拦在哪一环,稍不注意就会陷入“线程全部打满但 CPU 空转”的尴尬。

这篇文章把整套方案的结构、代码和调优思路完整放出来。适合已经在用 Spring Boot / Spring MVC 做接口开发、对高并发场景有实际的性能压力、想要从“同步阻塞”切到“异步化”的开发者。不管你是刚接触异步编程想弄懂原理,还是已经在写@Async但经常遇到线程池耗尽、请求超时的问题,这篇都尽量把原理讲到能落地的程度。

1. 异步化整体思路:到底该让谁先“让路”

先说结论:同步模型下,一个请求占住一个 Tomcat 线程直到业务逻辑全部跑完。很多慢接口——比如查数据库、调下游 RPC、做文件导出——大部分时间都是在等待,线程就这么被占着干等。并发一高,Tomcat 默认线程池(Spring Boot 内嵌 Tomcat 默认 max-threads 是 200 左右)很快就会被占满,后面的请求直接排队,表现就是接口超时、连接拒绝。

异步化的本质不是让请求“变快”,而是让请求“不占线程地等待”。核心思路是:Tomcat 线程收到请求后,立刻把业务处理交给另外一个线程池(业务线程池),然后 Tomcat 线程立即返回到容器里继续接新请求。真正干活的线程池我们可以单独调优、单独监控、单独限流。这样一来,Tomcat 线程被释放,容器层面的吞吐量上限就大幅提升。

Spring 里实现这套有三个主流工具:

  • WebAsyncTask:Web 层配合Callable/WebAsyncTask用的,专属于 Spring MVC 的异步请求处理器;
  • @Async:Service 层方法异步执行的注解,配合@EnableAsync和线程池使用;
  • CompletableFuture:Java 8 自带的异步编排工具,可以做任务串并联、回调组合。

这里最容易被混淆的一点是:WebAsyncTask和@Async是两层东西,千万别混着用。WebAsyncTask控制的是“HTTP 请求在容器层面如何被异步释放”,@Async控制的是“业务方法是否在独立线程池里执行”。一个负责解放 Tomcat 线程,一个负责隔离业务线程。想全局调优,这两者要做组合而不是二选一。

有人问:Service 层上已经用了@Async,Controller 里直接return不就得了吗?实际上不行。如果 Controller 仍然以同步方式返回响应,Tomcat 线程还是会一直阻塞等待@Async方法的结果返回(如果你用 Future.get()),或者直接返回空内容(如果你不等待)。也就是说,只对 Service 做异步化,HTTP 层该堵还是堵。反过来,Controller 用了WebAsyncTask,Service 依然是同步阻塞方法,那阻塞只是从 Tomcat 线程搬到了业务线程池里。为了让整个请求链路的每个环节都达到最佳资源利用率,我最终的方案是四层组合:

  1. 入口统一限流:请求最早进入时拦截,超阈值直接 fail fast;
  2. Controller 层异步化:WebAsyncTask迅速释放 Tomcat 线程;
  3. Service 层异步化:@Async把耗时业务放到隔离线程池;
  4. 全局异常兜底 + 超时治理:异步请求的异常处理路径要单独设计。

这套组合能扛住瞬时高并发,是因为它对“资源占用”做了分层:Tomcat 线程只负责接活,业务线程负责干活,限流器负责控制同时干活的规模,每个环节都可以独立调节。

2. 方案选型:为什么是 WebAsyncTask 而不是只靠 @Async

2.1 WebAsyncTask 与原生 Callable 的区别

Spring MVC 里做异步请求有两种原生手段:返回Callable,或者返回WebAsyncTask。其实WebAsyncTask就是对Callable的封装加强版。它额外提供了超时回调setTimeoutCallback、完成回调onCompletion、以及设置超时时间的setTimeout。这些能力在真实生产环境里刚需,因为异步线程一旦“跑飞”了(比如下游接口挂住),你必须有一个兜底机制给客户端返回降级响应,不然用户那边会活活等到连接断开。

WebAsyncTask还可以在创建时指定使用的业务线程池。这点非常关键,它允许你做线程池隔离。我有几个不同的业务场景(报表导出、批量查询、普通接口),分别用了不同的线程池,防止某个慢任务把公共线程池全部吃掉。

另外,WebAsyncTask触发异步之后,请求和响应直接被容器挂起,Spring MVC 的拦截器链会发生一次中断再恢复的流程。preHandle先执行,postHandle和afterCompletion会在异步线程处理完后再被调用。这意味着你在拦截器里做的参数校验、鉴权操作依然有效,但要注意:到了异步阶段,RequestContextHolder里的上下文默认不会自动传给新线程,需要手动传递。

2.2 WebAsyncTask 与 DeferredResult 的区别

和DeferredResult相比,WebAsyncTask更贴近“直接提交任务”的编程模型。DeferredResult适合你自己管理线程——比如配合消息队列,你从一个地方收到请求,然后用 MQ 把任务发出去,等 worker 处理完再手动setResult()。WebAsyncTask则适合“我这里有个 Callable 任务,你帮我丢到线程池里执行”的场景。

一般业务项目的绝大多数异步需求都属于后者:Controller 收到请求,Service 层做耗时操作,有超时和异常兜底。所以我选择WebAsyncTask作为 Web 层异步的基座。

2.3 为什么不直接用 CompletableFuture + 手动返回

把 Controller 返回类型直接设置成CompletableFuture<String>也是 Spring MVC 官方支持的异步方式。但有个问题:超时控制不好做。WebAsyncTask的设置超时时间、注册超时回调是框架级的,由容器统一在真正的 IO 线程上触发;而CompletableFuture你需要在业务代码里自己做get(timeout)或者orTimeout(),一不小心就在异步线程里抛超时异常,用户端收不到任何有意义的错误提示,监控也难做。

所以我的建议是:Controller 层统一走WebAsyncTask,Service 层内部如果需要编排多个任务再做CompletableFuture组合。这样分工最清晰:Controller 只负责接收、响应和超时兜底;Service 负责真正的异步分析和聚合。

3. 全局调优的线程池设计:从参数计算到完整配置

3.1 谁来干活:定义业务线程池

WebAsyncTask和@Async默认都会尝试使用 Spring 容器里的TaskExecutorBean。如果你什么都不配置,Spring Boot 会默认创建一个简单的SimpleAsyncTaskExecutor——这个名字有迷惑性,它不是复用线程,而是每次请求都新建一个线程。高并发下会直接导致线程数量失控,内存爆掉。生产环境必须自定义线程池。

我的线程池配置如下,这里以“核心业务异步线程池”为例:

@Bean("bizAsyncPool") public ThreadPoolTaskExecutor bizAsyncPool() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数:主要应对常态流量,不是越高越好 executor.setCorePoolSize(16); // 最大线程数:瞬时流量峰值时允许扩到多少 executor.setMaxPoolSize(48); // 队列容量:当核心线程忙不过来时,任务先排队 executor.setQueueCapacity(512); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; }

这里要解释几个我实际测试后得出的调参心得,直接说参数是“多少”没有意义,关键是理解每个参数背后代表的流量模型:

  • 核心线程数:我按“容器 CPU 核数 × 2”定的基准。大多数业务逻辑是 IO 密集型(访问 DB、调用 Redis、远程接口),线程在等待 IO 时 CPU 是空闲的,所以线程数可以高于核数。16 核的机器我最初定了 32,压测后发现 16 就够,因为还有限流保护着并发总量,核心线程数太大反而增加上下文切换开销。
  • 最大线程数:代表你愿意承受的瞬时高峰并发量。我定为核心线程数 × 3,配 512 容量的队列。为什么要有队列?因为创建线程是有代价的,瞬间涌入大量请求时优先让他们排队等待,而不是疯狂创建线程把 CPU 打满。只有当队列满了,才会尝试创建新线程直到 maxPoolSize。
  • 拒绝策略:用的是CallerRunsPolicy。很多人觉得拒绝策略应该用AbortPolicy抛异常,但在异步请求场景里,一旦任务被拒绝,调用者线程(也就是 Tomcat 线程)会直接回去执行这个任务,相当于降级成同步执行。如果连同步执行都不行,那说明真的已经超出系统极限了,此时再抛异常让客户端快速失败反而是合理的。用了CallerRunsPolicy之后,异步请求最多是变成慢请求,而不会因为线程池拒绝直接丢请求。

3.2 异步线程池的容量计算逻辑

对于一个接口,如果平均耗时是 300ms,你希望它能支撑 2000 TPS,那么同一时间正在处理中的任务数量大约是:

并发中任务数 = TPS × 平均耗时 = 2000 × 0.3 = 600

这 600 个任务不能全指望线程池硬扛,而应该分解成:核心线程 16 个 + 队列 512 个 + 扩展线程 32 个 ≈ 560,再配合入口限流把同时放行的请求控制住。也就是说,线程池容量并不是独立调优的,它要和限流阈值一起算。我的做法是:先定目标 TPS 和最大容忍 RT,然后用上面的公式算出需要的“并发中任务数”,再反推线程池各参数。入口限流的 QPS 阈值则设置为略低于“线程池每秒能处理完成的任务数”,这样队列不会被无限塞满,RT 才能稳定。

3.3 全局异步配置类的完整写法

光有线程池还不够,还要让@Async和全局异步配置都指向同一个线程池。最稳妥的做法是实现AsyncConfigurer接口:

@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Bean("globalAsyncPool") @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(16); executor.setMaxPoolSize(48); executor.setQueueCapacity(512); executor.setThreadNamePrefix("global-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { // @Async 方法无返回值时,异常不会抛给调用方,必须在这里捕获 return (throwable, method, obj) -> { // 记录日志、发送告警 }; } }

这里有个非常容易被忽略的点:@Async方法如果返回值是void,异常会被吞掉,不会抛到调用方。所以你必须在AsyncUncaughtExceptionHandler里做兜底,否则线程池里的任务失败了连日志都看不着,线上排查极其痛苦。

还有一种常见写法是在@Async上直接指定线程池名称:

@Async("bizAsyncPool") public void sendMessage(String content) { ... }

这个方法适合给不同的 Service 方法指定不同线程池,做到业务间隔离。如果要做到全局统一管理,就用AsyncConfigurer的默认线程池;要精细隔离,就用@Async("线程池Bean名")。我实际项目里两种都有:大量普通异步任务走全局线程池,重要导出任务走独立隔离池,防止互相影响。

4. Controller 层 WebAsyncTask 的实践:从代码到时序

4.1 一个完整可用的异步 Controller 示例

用WebAsyncTask改造一个之前同步阻塞的查询接口,完整代码如下:

@RestController @RequestMapping("/api/order") public class OrderController { /** * 异步查询订单详情 * 请求进入后,Tomcat 线程立即释放,交给 bizAsyncPool 处理 */ @GetMapping("/detail/{orderId}") public WebAsyncTask<Result<OrderVO>> getOrderDetail(@PathVariable Long orderId) { // 1. 先在主线程里做校验等轻量操作 if (orderId == null || orderId <= 0) { return new WebAsyncTask<>(() -> Result.fail("参数异常")); } // 2. 从主线程中取出上下文,传到异步线程里 RequestAttributes context = RequestContextHolder.getRequestAttributes(); Long userId = UserContext.getUserId(); // 3. 创建 Callable,注意 lambda 里的代码是在业务线程池执行的 Callable<Result<OrderVO>> callable = () -> { // 异步线程里手动传递主线程的上下文 RequestContextHolder.setRequestAttributes(context); UserContext.setUserId(userId); try { // 这里才是真正的业务逻辑 return orderService.queryDetail(orderId); } finally { // 避免线程池中的ThreadLocal污染下个任务 RequestContextHolder.resetRequestAttributes(); UserContext.clear(); } }; // 4. 创建 WebAsyncTask,并设置超时时间(毫秒) WebAsyncTask<Result<OrderVO>> asyncTask = new WebAsyncTask<>(3000L, bizAsyncPool, callable); // 5. 超时降级回调 asyncTask.setTimeoutCallback(() -> Result.fail(500, "处理超时,请稍后重试")); // 6. 完成回调(无论成功、失败还是超时,最终都会走到这里) asyncTask.onCompletion(() -> { // 可以在这里记录耗时日志 }); return asyncTask; } }

代码里做了几件很重要的事,单独展开说明:

  • 参数校验和 UserContext 提取必须在主线程做:因为进入callable之后,代码已经在业务线程池里执行了。此时RequestContextHolder里面的内容默认是拿不到的,如果不先在主线程里保存一份,到业务线程中去取会得到 null。
  • WebAsyncTask构造器三参版:第一个参数是超时时间毫秒(3000ms),第二个参数是线程池(这里直接指定 bizAsyncPool),第三个参数是业务逻辑Callable。它比两参版好用,因为它允许我指定独立的线程池。
  • setTimeoutCallback里直接返回一个降级结果:它是接口超时后的用户端最终响应。真实生产环境里我建议返回一个明确的业务错误码和提示,而不是简单返回 500,这样前端可以发起重试或者提示用户。
  • onCompletion不是finally:它在异步任务执行、出错、超时后都会触发,但不是所有场景都会执行——比如容器在异常情况下直接断开连接时可能不触发。依赖它做资源清理不保险,真正的资源清理还得靠finally。

4.2 异步请求与拦截器、过滤器的执行时序

这里有一个我踩过坑的点,必须单独拎出来讲清楚。

Spring MVC 同步请求的链路是:Filter → DispatcherServlet → Interceptor.preHandle → Controller → Service → Interceptor.postHandle → Interceptor.afterCompletion → Filter。

使用WebAsyncTask之后,异步处理开始的同时,postHandle和afterCompletion不会立刻执行。它们的执行被推迟到异步任务结束后。在异步任务真正执行的这段时间,Filter的doFilter其实已经走到了末尾——但容器会把响应对象保留住,不返回给客户端。整个链路变成了:

Filter → Interceptor.preHandle → Controller 返回 WebAsyncTask → 容器释放 Tomcat 线程 → [业务线程池异步执行] → 异步任务结束 → Interceptor.postHandle / afterCompletion → Filter 末尾 → 向客户端输出响应

这个特性对限流过滤器有一个重大影响:如果限流逻辑写在某个 Filter 里,它拦到的是请求第一次进来的那一下,整个异步任务期间的线程池占用它根本看不到。也就是说,限流放在过滤器里只能控制“每秒进入系统的请求次数”,控制不了“同时正在执行中的任务数量”。关于这一点,第 5 节里有更细的配套方案。

4.3 常见的误区:以为返回 WebAsyncTask 就万事大吉

改造异步后,很多人习惯把所有业务都塞到callable里,结果主线程几乎不干活。最后发现线程池还是被打爆了,Tomcat 线程倒是很空闲,但这只是把“堵车”从高速公路挪到了市区。异步化要搭配限流使用,而且要对异步线程池的核心指标做监控(活跃线程数、队列长度、拒绝次数)。我后期在监控面板上专门加了这几个指标,遇到异常流量能第一时间定位是容器层顶不住还是业务线程池顶不住。

另外一个容易忽略的点是:WebAsyncTask 的异步线程在执行时,如果抛出了没有捕获的异常,@ControllerAdvice里的@ExceptionHandler是不会直接生效的。异步线程里抛出的异常会被容器当作任务执行失败,然后回调到超时或错误处理逻辑。如果你希望在异步接口里走统一的异常处理,必须自己把异常捕获后转为Result返回,或者实现AsyncControllerAdvice来处理WebAsyncTask的异常。我推荐简单粗暴的方式:业务代码里try-catch兜底,将异常转成统一响应格式,减少对全局异常处理器的依赖。因为异步场景下,全局异常处理的适配逻辑复杂,而且不同版本 Spring 行为还不完全一致。

5. 限流层的设计:异步场景下的流量控制

5.1 为什么异步后必须重新做限流

同步接口的限流相对直观:你限制了每秒进入的请求量,系统当前在处理的任务数和进入的请求量基本成正比。但异步化之后,Tomcat 线程瞬间被释放,请求被放到线程池队列里,限流如果不配合线程池队列一起看,会出现“入口 QPS 不高,但线程池队列已经积压了几千个任务”的情况。更麻烦的是,这些任务还是会逐渐被消费,线程池长期处于高负载状态,RT 越拉越长,最终拖垮下游。

所以异步系统的限流要回答两个问题:一是每秒允许进来多少请求(QPS 维度),二是同一时刻最多允许多少个任务在排队或执行(并发维度)。前者用经典限流器解决,后者靠线程池自身容量 + 信号量配合解决。

5.2 限流器的选择与实现

我选的是令牌桶限流器,因为令牌桶允许一定程度的突发流量,相比固定窗口限流(固定窗口会在窗口边界一次性放行大量请求,导致流量不平滑),更适合业务接口的实际情况。

用 Google Guava 的RateLimiter是目前最偷懒的方案,但它有个缺点:它是进程内限流,集群环境每个节点各自持有一个令牌桶,实际放行总量是节点数 × 单机阈值,需要配合中间件做分布式统一限流。这里给出一个简单的单机令牌桶实现思路:

@Component public class ApiRateLimiter { private final RateLimiter orderApiLimiter = RateLimiter.create(200); // 每秒 200 个令牌 public boolean tryAcquire() { return orderApiLimiter.tryAcquire(); } }

在 Controller 里,请求一进来就做一次tryAcquire,拿不到令牌直接返回“系统繁忙”:

if (!rateLimiter.tryAcquire()) { return Result.fail(429, "请求过于频繁,请稍后再试"); } resp

这个位置很重要:限流必须放在创建WebAsyncTask之前,尽量放在 Controller 方法体的最前面。因为一旦返回WebAsyncTask,请求已经被容器挂起,这个时候再想“拒绝”请求,实际上下一次响应已经预定要发出来了,处理起来更加绕。

分布式场景下可以用 Redis + Lua 脚本做滑动窗口,或者用现成的中间件。核心思想都一样:在入口处限制速率,防止无限制地往线程池里塞任务。

5.3 并发维度限制:信号量与线程池容量

除了每秒速率,我还给耗资源的接口加了并发信号量。Semaphore比用线程池activeCount做判断更直观、更精准:

private final Semaphore orderSemaphore = new Semaphore(80); Callable<Result<OrderVO>> callable = () -> { if (!orderSemaphore.tryAcquire()) { return Result.fail(429, "系统繁忙"); } try { return orderService.queryDetail(orderId); } finally { orderSemaphore.release(); } };

这里Semaphore(80)表示同时最多允许 80 个该接口的任务在处理中。为什么是 80?这是根据线程池核心线程数(16)和单个任务平均耗时(300ms)推出来的:如果每个任务 300ms 完成,那么 16 个线程每秒最多完成 16 × (1000/300) ≈ 53 个任务。如果你希望接口不要长期处于满负载,同时并发数就定在 50~80 之间比较合适。定太高,队列会积压;定太低,吞吐就上不去。

5.4 限流器放行的请求计算

结合前面提到的公式,我来给一个完整计算示例。假设你有一个导出接口,目标压测值是 500 TPS,单任务平均耗时 800ms,最大容忍 RT 是 3s:

并发处理中的任务数 = 500 × 0.8 = 400

这意味着线程池的容量(核心线程 + 队列 + 扩展线程)必须能容纳 400 个同时在途任务。如果你给这个接口分配的核心线程是 20,每个任务 800ms,那么每个线程每秒能完成 1.25 个任务(1000ms / 800ms),20 个线程每秒最多完成 25 个任务。要达到 500 TPS 就需要 20 个线程在 1 秒内完成 500 个任务,显然不可能。所以要么提高线程数,要么降低单任务耗时。这时你最好使用异步编排把 800ms 拆成多个并行子任务,或者增大线程池池容量,或者接受入口限流失配的现状,直接把目标降到 200 TPS。

这个计算的意义在于:它让你知道异步化不是万能药,瓶颈终究落在“每个任务消耗多久”和“系统资源能开多少线程”。盲目的把线程池设置很大,最终只会耗尽内存和 CPU。

6. Service 层 @Async 的正确使用与事务血缘问题

6.1 @Async 标注方法的设计要求

Controller 层用WebAsyncTask把请求从 Tomcat 线程解放后,Service 层的耗时任务本身还是同步执行的。如果业务逻辑里有多个互相独立的耗时操作(比如查用户信息、查订单列表、查优惠券),完全可以用@Async让它们并行,只等最终结果汇总。

我常用的写法是配合CompletableFuture返回。例如:

@Service public class OrderQueryService { @Async("bizAsyncPool") public CompletableFuture<List<OrderItemVO>> queryOrderItems(Long orderId) { // 模拟耗时查询 return CompletableFuture.completedFuture(orderMapper.listItems(orderId)); } @Async("bizAsyncPool") public CompletableFuture<UserVO> queryUserInfo(Long userId) { return CompletableFuture.completedFuture(userMapper.getById(userId)); } }

Controller 或上层 Service 在聚合时,利用CompletableFuture.allOf().join()等待全部结果:

CompletableFuture<List<OrderItemVO>> itemFuture = orderQueryService.queryOrderItems(orderId); CompletableFuture<UserVO> userFuture = orderQueryService.queryUserInfo(userId); CompletableFuture.allOf(itemFuture, userFuture).join();

注意:@Async不能和@Transactional标注在同一个方法上直接用,而且要更加小心“自调用陷阱”。如果A方法内部直接调用同一个类里带有@Async的B方法,由于没有经过 Spring 代理,@Async不会生效,B 方法还是在当前线程里同步执行。解决办法是拆分到两个 Service 类,或者使用ApplicationContext.getBean()拿到代理对象再调用那个方法。

还有一个和@Transactional相关的重要问题:如果@Async方法里操作数据库,事务要开在异步方法内部,不能指望调用方的事务传播过来。因为异步方法跑在线程池里,而事务默认绑定在调用线程的数据库连接上,这俩根本不是同一回事。所以我在项目中会把“写操作”和“异步执行”彻底分开设计——需要事务保护的代码单独写在事务 Service 层里,异步线程只负责触发这个事务方法。

6.2 异步任务里的上下文传递

在线程池里执行任务时,主线程的ThreadLocal不会自动继承。除了前面提到的RequestContextHolder,像用户登录态、链路追踪 ID、国际化 locale 之类的上下文信息,如果不做传递,异步线程里全部拿不到。

我的通用方案是做一个“上下文快照”工具类:

public class AsyncContextHolder { public static Map<String, Object> snapshot() { // 把主线程的 userId、traceId、locale 等放入Map } public static void restore(Map<String, Object> ctx) { // 从Map中恢复ThreadLocal } public static void clear() { // 清理所有ThreadLocal } }

在使用时:

Map<String, Object> ctx = AsyncContextHolder.snapshot(); CompletableFuture.supplyAsync(() -> { AsyncContextHolder.restore(ctx); try { return doSomething(); } finally { AsyncContextHolder.clear(); } }, bizAsyncPool);

这样做不仅解决功能问题,还解决一个隐蔽的“线程池污染”问题:线程池中的线程是复用的大锅饭,如果上一个任务往ThreadLocal里塞了大量数据却不清理,下一个任务可能读到上一个任务的残留数据,产生诡异 bug。清理这步绝对不能省。

6.3 @Async 的线程池怎么保住“Main”线程不阻塞

用CompletableFuture时有一点和@Async+Future.get()不同:你在主线程(可能是业务线程池里的线程)调.join()或.get()等待所有子任务时,这个线程会阻塞。所以如果一个WebAsyncTask任务内部又做了多个@Async子任务,父任务在线程池里也会等待子任务完成。如果你的线程池核心线程设置太小,父任务占满核心线程,子任务就没有线程可以跑了,形成了“线程池饥饿”。经典场景:核心线程是 16,16 个父任务齐刷刷在等子任务,但子任务要去同一个线程池里抢线程,抢不到就全部死等。

解决方法有几种:

  • 父任务和子任务用不同的线程池,核心线程按层次分设;
  • 用CompletableFuture自带的 ForkJoinPool.commonPool 作为默认线程池,但注意它的线程数默认是 CPU 核数 - 1,小心同样的问题;
  • 如果不是特别复杂的依赖编排,Service 层直接用同步调用反而更简单可靠。

我习惯的做法是:Controller 层WebAsyncTask用一个池子,Service 层的并行子任务用另一个池子,两套池子都要有各自的队列和监控,互不干扰。

7. 完整项目落地方案:配置、代码与部署调优

7.1 启动类与全局配置

@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

@EnableAsync是@Async注解生效的开关,别忘了。如果项目里存在多个异步线程池配置类,指定默认池时建议显式声明,避免 Spring 分不清到底用哪个导致启动报错。

7.2 完整目录结构

├── config │ ├── AsyncConfig.java // @Async 线程池配置 │ └── WebMvcConfig.java // MVC拦截器、异步配置 ├── controller │ └── OrderController.java // 演示 WebAsyncTask 用法 ├── filter │ └── RateLimitFilter.java // 入口限流过滤器 ├── service │ ├── OrderQueryService.java // 内含 @Async 异步方法 │ └── OrderExportService.java // 独立线程池的导出服务 ├── common │ ├── AsyncContextHolder.java // 线程上下文快照工具 │ └── Result.java // 统一响应体

7.3 入口限流过滤器的实现

Filter的执行时机比Interceptor更早,只要注册范围覆盖目标接口就可以做入口限流。放一个简单示例:

@Component public class RateLimitFilter implements Filter { private final RateLimiter limiter = RateLimiter.create(500); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (!limiter.tryAcquire()) { HttpServletResponse resp = (HttpServletResponse) response; resp.setStatus(429); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":429,\"msg\":\"请求过于频繁\"}"); return; } chain.doFilter(request, response); } }

注意:RateLimitFilter是全局过滤器,会拦所有接口。如果需要按接口维度区分限流阈值,需要从HttpServletRequest里取 URI 再分别处理。另外,异步请求到这里拦截时只是拦住“请求进入”,异步任务真正执行时的负载要交给线程池和Semaphore控制,这块要心里有数。

7.4 Tomcat 层面的线程池调优

异步化之后,Tomcat 线程池的 max-threads 不需要设置太大,因为 Tomcat 线程只负责接收请求和返回响应。我调优时把server.tomcat.threads.max从默认 200 调低到 100,把server.tomcat.threads.min-spare设为 20,配合server.tomcat.accept-count1000。这样即使入口瞬时并发很高,Tomcat 也能快速把请求转交给业务线程池,而不是让大量 Tomcat 线程干等着。

server: tomcat: threads: max: 100 min-spare: 20 accept-count: 1000 max-connections: 10000

accept-count决定了当 Tomcat 线程池满了之后,还有多少连接可以在操作系统层面排队等待。设太大,请求排队时间变长,用户 RT 恶化;设太小,直接连接拒绝。1000 算是一个安全值,结合前面的入口限流,实际放行量不会超过限流阈值。

7.5 完整的短信验证码异步发送示例

综合以上所有知识点,放一个极简但完整的异步链路例子:用户请求发送验证码 → 入口限流 → Controller 异步释放 Tomcat 线程 → Service 异步发送 → 返回结果。

@RestController @RequestMapping("/api/sms") public class SmsController { private final SmsService smsService; @GetMapping("/send") public WebAsyncTask<Result<Boolean>> sendCode(@RequestParam String phone) { // 参数校验必须同步做 if (!Pattern.matches("^1\\d{10}$", phone)) { return new WebAsyncTask<>(() -> Result.fail("手机号格式错误")); } Callable<Result<Boolean>> callable = () -> { // 真正执行异步发送 boolean ok = smsService.sendVerifyCode(phone); return ok ? Result.success(true) : Result.fail("发送失败"); }; WebAsyncTask<Result<Boolean>> task = new WebAsyncTask<>(2000L, callable); task.setTimeoutCallback(() -> Result.fail("发送超时")); return task; } }
@Service public class SmsService { @Async("bizAsyncPool") public boolean sendVerifyCode(String phone) { // 调用短信供应商,假设耗时200ms return smsProvider.send(phone, generateCode()); } }

这里其实有一个内在矛盾:SmsService.sendVerifyCode是@Async,Controller 的Callable会立即得到一个返回值(因为方法返回类型是boolean,@Async会把返回值包装成 null 或者导致报错)。正确的做法是:如果不需要等待结果,@Async方法返回void,Controller 直接返回“发送成功”;如果需要等待发送结果再响应客户,那 Service 层不要用@Async,直接在Callable里同步调用即可。

这是一个很多人困惑的点,我特别说明:@Async适合“发出去就不管了”的任务,比如日志上报、消息通知、异步刷新缓存。如果客户端关心处理结果,就不要在异步链路末尾再加一层异步——异步不是叠得越多越好,叠多了反而让结果获取和异常传递变得复杂。

8. 压测验证和常见问题排查实录

8.1 压测结果与指标

我用一个模拟接口做对照压测:接口内部模拟 50ms 数据库查询 + 50ms 远程调用,总共 100ms 耗时。环境是 4 核 8G,Tomcat 默认线程数 200,压测工具并发 500、总请求 10000。

同步模式下:平均 RT 约 350ms,QPS 约 1400,大量请求因 Tomcat 线程池排队导致 RT 急剧拉长,出现少量连接超时。

改造成WebAsyncTask+@Async+ 限流后:平均 RT 约 120ms,QPS 约 3800,线程池活跃线程稳定在 20~30 之间,队列偶尔积压但不持续增长。入口限流大概拦掉了 15% 的峰值流量,客户端的错误率明显下降。

这个结果也在预料之中:异步化的收益主要来源于“线程复用”,让同样的系统资源服务更多请求。压测时要注意的是,压测工具本身也有性能瓶颈,并发数开得太高反而不容易压出真实数据,建议用多台压测机或者按接口目标 TPS 逐步加压。

8.2 常见问题:请求在异步执行时全局异常处理不生效

现象:Controller 返回WebAsyncTask,业务线程里抛RuntimeException,客户端收到 500 但日志里没有异常堆栈,提前定义好的@ControllerAdvice也没有捕获到。

原因:异步任务在独立线程里执行,异常抛出后由容器的异步处理机制接管,不会走普通的HandlerExceptionResolver链路。

解决:在Callable内部捕获所有异常并转换为业务错误返回,或者实现AsyncHandlerInterceptor配合WebAsyncTask的超时回调处理。我建议前者,代码直观、可控。

8.3 常见问题:异步方法自调用导致 @Async 不生效

现象:Service 里给methodA调用this.methodB(),methodB标了@Async,但日志显示两个方法在同一线程执行。

原因:Spring AOP 代理只对外部调用生效,this调用不会经过代理对象。

解决:把methodB注入到另一个 Service;或者用@Autowired注入自身代理;最简单粗暴的是在类里用ApplicationContext.getBean(Class)获取代理对象再调用。

8.4 常见问题:WebAsyncTask 超时不触发

现象:设置了 3 秒超时,但任务在线程池里执行了 10 秒,客户端已经响应超时,setTimeoutCallback看起来没有触发。

原因:WebAsyncTask的超时判断依赖容器的异步超时机制。如果你的线程池任务本身没有响应中断信号,线程池继续执行任务,但客户端那边已经断开。setTimeoutCallback只在容器“检测到”超时并且还来得及发降级响应时才会生效。如果容器认为连接已经断开,或者任务一直卡在线程池队列里根本没开始执行,超时回调可能不会按预期执行。

解决:除了设置WebAsyncTask超时,还要在业务代码里对下游访问设置单独的调用超时(比如RestTemplate/Feign连接超时和读取超时),确保单个任务不会无限执行。同时让业务侧感知线程池的Future.get(timeout)也是一种手段。

8.5 调试经验汇总速查表

症状可能原因排查手段
@Async没生效类内自调用 / 未加@EnableAsync日志打印线程名,检查被调用方是否经过代理
线程池活跃数飙高队列设置太小或限流失效查看队列积压、线程池拒绝计数
异步接口返回 500 但无日志异步线程异常未捕获在Callable内 try-catch,配合AsyncUncaughtExceptionHandler
请求上下文为空ThreadLocal未传递主线程快照,异步线程中恢复
接口 RT 越来越长线程池队列积压调大队列或降低入口限流阈值
限流拦截了正常流量限流阈值太低或限流粒度太粗分接口设置不同限流阈值,压测校准
子任务死等不执行父子任务共用线程池造成饥饿父子任务拆分不同线程池

8.6 最后的两个部署经验

第一,生产环境千万不能直接裸奔上线,至少给线程池加一个监控。最轻量级的做法是定时记录ThreadPoolTaskExecutor.getThreadPoolExecutor().getActiveCount()、getQueue().size()和getTaskCount()到日志或监控系统里。我看到很多事故都是从线程池队列悄悄涨到几千开始的,等发现的时候已经拖垮下游。

第二,JVM 启动参数按需调优。并发异步场景下,线程池的任务大多是短任务,频繁创建和销毁线程会成为负担,核心线程数尽量不要比最大线程数小太多,先预热起来。同时把-Xss适当增大(比如 1M 以上),因为异步任务如果涉及递归调用或较深的调用链,容易触发栈溢出。

9. 写在最后的体会

这套“Controller 异步 + Service 异步 + 限流 + 线程池调优”组合,我前后迭代了快一个月才真正稳定下来。最大的体会是:异步化不是把同步代码换成异步注解那么简单,而是一门关于“资源如何分布、如何隔离、如何兜底”的系统工程。Controller 层的WebAsyncTask让 Tomcat 线程快速释放,Service 层的@Async让业务并行执行,限流让系统在高压下不至于失控,线程池参数则决定了这些能力能发挥到什么程度。

如果让我给第一次做异步改造的人一个建议:先从单接口开始,用压测工具对比同步和异步的 RT、QPS、线程占用,把线程池参数和限流阈值放到配置中心或者配置文件里,方便随时调整。等你把链路跑顺了,再逐步推广到全部耗时接口。整个过程里,日志和监控一定要先做好,异步线程里的错误比同步错误更难发现,稍不留神就会变成线上幽灵问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询