CompletableFuture 用了公共池:一次批量接口把 Tomcat 线程全占住
五个下游改并行,本地从 380ms 降到 120ms,压测 QPS 却腰斩。两百个 Tomcat 线程停在 join 上,干活的只有三个。公共池的容量、隔离与超时该怎么配。
商品详情接口原来是一个个串行调下游:基础信息、库存、价格、评价、推荐,五个 RPC 加起来 380 毫秒。产品要求压到 200 毫秒以内,我看这五个调用互不依赖,就用 CompletableFuture 改成并行,本地跑起来确实降到了 120 毫秒。
上线第二天压测,QPS 从 800 掉到 390,而且 RT 的 P99 从 400 毫秒涨到 2.3 秒。并发改造把性能做反了,这件事本身就值得写下来,因为它不是「改错了」,是「改对了但用错了地方」。
一、批量接口加了并发,QPS 反而降了一半
先看现象。压测机 50 并发,jstack 抓了三次 Tomcat 线程栈,http-nio-8080-exec-* 这两百个线程,绝大多数停在同一行:
"http-nio-8080-exec-47" #47 daemon prio=5 os_prio=0 tid=0x... nid=0x...
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000f1a2b3c4> (a java.util.concurrent.CompletableFuture$Signaller)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.CompletableFuture.waitingGet(CompletableFuture.java:1819)
at java.util.concurrent.CompletableFuture.join(CompletableFuture.java:1934)
at com.example.goods.GoodsDetailService.detail(GoodsDetailService.java:88)
两百个 Tomcat 线程全在 join() 上等。而在另一侧,真正干活的线程只有几个:
"ForkJoinPool.commonPool-worker-3" #63 daemon prio=5
java.lang.Thread.State: TIMED_WAITING (parking)
at java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1590)
ForkJoinPool.commonPool-worker-3,编号只到 3。这台压测机是 4 核,公共池的并行度是 CPU - 1 = 3。
到这里答案已经出来了:两百个请求线程,每个提交五个任务,总共一千个任务挤进一个只有 3 个线程的池子,排队等。请求线程不但没省下时间,还额外付出了「提交任务 + 排队 + 唤醒」的开销。
我在本地测不出来,是因为本地是串行跑单个请求,五个任务在 3 个线程的池子里刚好能并发跑完,看起来很快。压测的并发一上来,池子就成了唯一瓶颈。
二、commonPool 的大小:可用处理器减一
ForkJoinPool.commonPool() 的并行度默认是 Runtime.getRuntime().availableProcessors() - 1。这个「减一」是给调用线程留的位置——ForkJoin 的设计里,提交任务的线程可以「帮忙」执行任务(work-stealing 的一种延伸),所以实际能用的线程是并行度加调用方。
但在 CompletableFuture.supplyAsync(task) 这种用法里,调用方是 Tomcat 线程,它提交完就去 join() 阻塞了,根本不会帮忙。所以实际并发度就是 3。
availableProcessors() 在容器里还有个坑。JDK 8u191 之前,它读的是宿主机核数,不是容器的 CPU limit;之后加了 UseContainerSupport(默认开启),读 cgroup 的配额。如果容器 limit 是 2 核而宿主机 64 核,老版本 JDK 会算出 63 个线程的池子,然后被 CPU 限流打得很惨。
我们的服务跑在 4 核的容器里,JDK 17,算出来是 3。可以用一行命令确认:
# 容器内确认可用处理器数与公共池并行度
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i ActiveProcessorCount
# 或者运行期
jcmd 1 VM.system_properties | grep -i processor
# 直接看公共池状态(需要 JMX 或 Arthas)
# Arthas:
# vmtool --action getInstances --className java.util.concurrent.ForkJoinPool \
# --express 'instances[0].getParallelism()'
数值本身没错,错的是「用它来跑 IO 密集任务」。公共池的设计目标是 CPU 密集型计算,线程数按核数配;而我们的五个下游调用是纯 IO 等待,一个线程能同时等几十个请求。用 CPU 密集的池子跑 IO 密集的任务,本来就配错了。
三、谁来等:join 与 allOf 的差别
改造时我用的是这样的写法,看着很常规:
public GoodsDetail detail(Long skuId) {
CompletableFuture<BaseInfo> base = CompletableFuture.supplyAsync(() -> baseClient.get(skuId));
CompletableFuture<Stock> stock = CompletableFuture.supplyAsync(() -> stockClient.get(skuId));
CompletableFuture<Price> price = CompletableFuture.supplyAsync(() -> priceClient.get(skuId));
CompletableFuture<Comment> comment = CompletableFuture.supplyAsync(() -> commentClient.get(skuId));
CompletableFuture.allOf(base, stock, price, comment).join();
return GoodsDetail.builder()
.base(base.join())
.stock(stock.join())
.price(price.join())
.comment(comment.join())
.build();
}
allOf(...).join() 和四次 join() 的差别不在功能,在于谁在等。allOf 会返回一个新的 CompletableFuture,它的完成依赖四个子任务;调用 join() 的时候,如果发现这个 future 还没完成,调用线程(Tomcat 线程)就 park 住了。
也就是说,无论怎么写,最终都是一个 Tomcat 线程在等。这不是 bug,是这套模型的必然:同步接口里做异步,等待一定会落到调用线程上。真正省下的是「五个 380 毫秒串行变成 380 毫秒并行」,省不掉「等」这个动作占着线程。
如果要连「等」都省掉,就得把整个链路改成异步(WebFlux 或 Servlet 3 的 AsyncContext),返回 CompletableFuture 让容器去挂起请求。这是一个更大的改造,我们当时没做,只在池子上做文章。
顺带一个细节:allOf 里任何一个任务抛异常,allOf 的 future 会以 CompletionException 结束,join() 抛出的是 CompletionException 而不是原始异常。生产代码里必须拆包,否则你看到的堆栈全是指向 CompletableFuture.encodeThrowable,一点信息量都没有。
try {
CompletableFuture.allOf(base, stock, price, comment).join();
} catch (CompletionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
if (cause instanceof TimeoutException) {
// 降级:用缓存里的旧数据,标记 partial
return fallbackFromCache(skuId);
}
throw new BizException("detail_aggregate_failed", cause);
}
四、一次下游抖动,整个应用的异步都停了
上面那件事只是「慢」。真正让我下决心改的,是后面那次事故。
评价服务做了次发布,有三十秒的接口超时。我们的评价调用没有设超时,commentClient 用的是默认 60 秒。于是四个并发请求就把公共池的 3 个线程全占住了,每个卡在 socket 读上。
公共池是 JVM 全局的。它一堵,同一个 JVM 里所有用 CompletableFuture.supplyAsync 不带 executor 的地方全跟着堵:另一个模块的导出任务、一个日志异步落盘的包装、还有个定时任务的分片计算。评价服务三十秒的抖动,把整个应用的非主链路全部拖停了四分钟。
这就是公共池最危险的地方:它是一个隐式的全局共享资源,任何人写一行 supplyAsync(task) 都会往里投任务,但没有人对它负责。一个模块的慢,会变成所有人的慢。
| 池 | 线程数 | 队列 | 是否共享 | 适合 |
|---|---|---|---|---|
ForkJoinPool.commonPool() |
CPU - 1 | 任务队列无界 | 全 JVM 共享 | CPU 密集的短计算 |
newFixedThreadPool(n) |
固定 n | 无界 LinkedBlockingQueue |
独占 | 不推荐,队列会堆 |
ThreadPoolExecutor 自定义 |
按需 | 有界 + 拒绝策略 | 独占 | IO 密集、需要隔离 |
| 虚拟线程(JDK 21) | 每任务一个 | 无需队列 | 廉价 | IO 密集、JDK 21+ |
五、给每类任务配自己的池
改法是给「下游聚合」这类 IO 密集任务一个独立的、有界的池,并且带拒绝策略和监控。
@Configuration
public class AsyncPoolConfig {
@Bean("aggregateExecutor")
public ThreadPoolExecutor aggregateExecutor() {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, // core:按「并发请求数 × 每请求任务数 / 单任务耗时比」估
32, // max:留一倍余量应对突发
60L, TimeUnit.SECONDS, // 空闲回收
new ArrayBlockingQueue<>(256), // 有界队列,容量 = core × 16
new ThreadFactoryBuilder().setNameFormat("aggregate-%d").setDaemon(true).build(),
new ThreadPoolExecutor.AbortPolicy() // 满了直接抛,由调用方降级
);
pool.allowCoreThreadTimeOut(true);
return pool;
}
}
几个参数是怎么定的,说清楚比给个数字有用:
core=16。每个请求提交 4 个任务,目标支撑 4 个并发请求同时跑满一轮,再考虑 4 倍余量。严格算法是「(QPS × 每请求任务数 × 单任务平均耗时) / 目标线程利用率」,代入峰值 400 QPS 会算出两百多,明显不现实——因为峰值不持续。所以我按 P95 的并发量估,得出 16。
queue=256,是 core × 16。队列太小会让突发全部走拒绝,太大则排队时间超过下游超时,等于白等。经验值是「队列容量 × 单任务耗时 < 可接受的最大延迟」,256 × 100ms 显然超了,所以真正的保护是超时,不是队列。
AbortPolicy 而不是 CallerRunsPolicy。CallerRuns 会让 Tomcat 线程自己跑任务,看起来「不丢」,实际上把 Tomcat 线程占住了,和没改一样。我们要的是「快速失败 + 降级」,所以选择直接抛。
调用的地方把 executor 显式传进去,这是关键——不传就等于还用公共池:
CompletableFuture<Comment> comment = CompletableFuture
.supplyAsync(() -> commentClient.get(skuId), aggregateExecutor)
.orTimeout(300, TimeUnit.MILLISECONDS) // JDK 9+
.exceptionally(e -> Comment.empty(skuId)); // 单个下游失败不影响整体
orTimeout 是 JDK 9 引入的,JDK 8 要用 Future.get(timeout) 或者自己包一层 ScheduledExecutorService。这个超时必须加,而且必须小于接口的整体超时预算——我们的接口 SLA 是 500 毫秒,四个并行任务给 300 毫秒,留 200 毫秒给序列化和网络。
六、线程池的关闭与上下文传递
两个容易被忽略的点。
一是优雅关闭。ThreadPoolExecutor 是 @Bean,Spring 关闭时不会自动 shutdown,正在跑的任务会被硬切。加 @PreDestroy 或者用 destroyMethod:
@Bean(name = "aggregateExecutor", destroyMethod = "shutdown")
public ThreadPoolExecutor aggregateExecutor() { ... }
shutdown() 只是不再接新任务,队列里的还会跑完。如果要求更快,用 shutdownNow() 并处理 InterruptedException。
二是上下文传递。ThreadLocal 里的东西(traceId、用户身份、MDC 的日志字段)不会自动带到池线程里。我们最初的日志里,异步任务的 traceId 全是空的,链路追踪直接断在 supplyAsync 这一跳。
解决办法有两个:用阿里的 TransmittableThreadLocal 包一层,或者显式把上下文当参数传。后者更笨但更可靠,我们选了后者——只传 traceId 和 tenantId 两个字段,一个包装类搞定。
String traceId = MDC.get("traceId");
String tenantId = TenantContext.get();
CompletableFuture<Stock> stock = CompletableFuture.supplyAsync(() -> {
MDC.put("traceId", traceId);
try {
return stockClient.get(skuId, tenantId);
} finally {
MDC.remove("traceId"); // 池线程是复用的,不清会污染下一个任务
}
}, aggregateExecutor);
finally 里那句 MDC.remove 不是可选的。池线程被复用,上一个任务的 traceId 留着,下一个任务的日志就会带着别人的 ID,排查时会把你带到完全错误的方向。
七、防复发
三件事。
一是加一条静态检查规则:代码里出现 supplyAsync(、runAsync(、thenApplyAsync( 且没有第二个参数(executor)时,流水线直接失败。这条规则上了之后,仓库里翻出十七处裸调用,分布在六个模块。
二是把线程池的关键指标打出来:activeCount、queue.size()、completedTaskCount、拒绝次数。前两个是瞬时值,后面两个是累计值,拒绝次数大于 0 就应该告警——它意味着已经有请求在走降级路径了。
三是给「异步任务没有超时」加检查。这次事故的根因其实不是公共池,是评价调用没有设超时。公共池只是把「一个下游慢」放大成「全应用慢」。就算用了独立池,没有超时一样会把池子占满,只是影响范围小一点。
小结
ForkJoinPool.commonPool()的并行度是CPU - 1,它是为 CPU 密集计算设计的,跑 IO 等待是配错了对象。- 公共池全 JVM 共享,一个模块的下游抖动会拖停所有不带 executor 的
supplyAsync,这是最危险的地方。 - 同步接口里用异步,等待最终仍落在 Tomcat 线程上;省下的是串行时间,省不掉线程占用。
- 每个
supplyAsync都要显式传自己的池,池要有界、有拒绝策略、有监控。 orTimeout必须加,且要小于接口整体超时预算;没有超时的异步任务迟早会把池占满。ThreadLocal不会自动跨线程,traceId 要显式传,且必须finally清理,池线程复用会污染下个任务。
正在加载评论…