一个 CompletableFuture,把 CPU 打满了:默认线程池的线上坑
接口明明只是想并行查几个服务。
加上
CompletableFuture后,响应一开始确实快了。结果高峰期 CPU 打满、接口超时、异步任务堆积,最后发现:所有任务都挤进了默认的
ForkJoinPool.commonPool。
一、事故现场
线上有个订单详情接口。
页面需要展示这些信息:
text
订单基本信息
用户信息
物流信息
优惠券信息
售后状态
一开始是串行查:
java
public OrderDetail getOrderDetail(Long orderId) {
Order order = orderService.getOrder(orderId);
User user = userService.getUser(order.getUserId());
Logistics logistics = logisticsService.getLogistics(orderId);
Coupon coupon = couponService.getCoupon(orderId);
AfterSale afterSale = afterSaleService.getAfterSale(orderId);
return buildDetail(order, user, logistics, coupon, afterSale);
}
接口 RT 偏高。
于是开发同学改成了并行:
java
public OrderDetail getOrderDetail(Long orderId) {
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId));
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userService.getUserByOrderId(orderId));
CompletableFuture<Logistics> logisticsFuture =
CompletableFuture.supplyAsync(() -> logisticsService.getLogistics(orderId));
CompletableFuture<Coupon> couponFuture =
CompletableFuture.supplyAsync(() -> couponService.getCoupon(orderId));
CompletableFuture<AfterSale> afterSaleFuture =
CompletableFuture.supplyAsync(() -> afterSaleService.getAfterSale(orderId));
CompletableFuture.allOf(
orderFuture,
userFuture,
logisticsFuture,
couponFuture,
afterSaleFuture
).join();
return buildDetail(
orderFuture.join(),
userFuture.join(),
logisticsFuture.join(),
couponFuture.join(),
afterSaleFuture.join()
);
}
刚上线时效果很好。
接口从 800ms 降到 300ms。
但高峰期来了以后,监控开始报警:
text
CPU 持续 90%+
接口 RT 飙升
Tomcat 线程开始堆积
下游服务偶发超时
ForkJoinPool.commonPool-worker 线程大量出现
最后定位到一个点:
CompletableFuture.supplyAsync()没传线程池,默认用了ForkJoinPool.commonPool()。
二、问题藏在没传 Executor 里
很多人用 CompletableFuture 时,会写:
java
CompletableFuture.supplyAsync(() -> querySomething());
但这个方法有两个版本。
不传线程池:
java
CompletableFuture.supplyAsync(Supplier<U> supplier)
传线程池:
java
CompletableFuture.supplyAsync(Supplier<U> supplier, Executor executor)
如果你不传 Executor,默认会使用:
java
ForkJoinPool.commonPool()
这就是坑点。
commonPool 是 JVM 级别的公共线程池。
你的订单详情、报表导出、异步通知、并行计算、其他三方库,都可能在用它。
你以为只是当前接口开了几个异步任务。
实际上可能是:
全应用都在抢同一个公共池。
三、为什么 commonPool 容易出事?
ForkJoinPool.commonPool 不是不能用。
它更适合 CPU 密集型、短小、可拆分的计算任务。
但很多业务代码里用它跑的是:
text
数据库查询
HTTP 调用
RPC 调用
Redis 查询
文件读取
第三方接口
这些都是 IO 密集型任务。
IO 任务最大的问题是:
线程会阻塞等待。
比如:
java
CompletableFuture.supplyAsync(() -> remoteClient.query(orderId));
如果远程接口卡 2 秒,执行这个任务的 worker 线程就被占 2 秒。
高峰期请求一多,每个请求再拆 5 个异步任务:
text
100 个请求
每个请求 5 个 CompletableFuture
瞬间 500 个异步任务进入 commonPool
如果其中一批任务被慢接口卡住,公共池就开始拥堵。
然后其他也依赖 commonPool 的任务跟着变慢。
最后表现出来就像整个服务都不稳。
四、还有一个隐蔽问题:并行不是免费午餐
很多人看到串行慢,就直接改并行。
但并行会放大下游压力。
串行时,一个请求可能是:
text
订单服务查 1 次
用户服务查 1 次
物流服务查 1 次
优惠券服务查 1 次
售后服务查 1 次
并行后,单个请求的总调用次数没变。
但瞬时并发变了。
以前是一个一个打下游。
现在是同时打下游。
如果入口 QPS 是 200,每个请求拆 5 个异步任务,下游瞬时压力可能变成:
text
200 * 5 = 1000 个并发调用
如果没有隔离和限流,下游一抖,当前服务也跟着抖。
所以 CompletableFuture 不是性能按钮。
它只是把等待时间重叠了。
同时也把并发压力打出去了。
五、最小复现
写一个模拟慢调用:
java
public String remoteCall(int i) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "ok-" + i;
}
不传线程池:
java
public void testCommonPool() {
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
int index = i;
futures.add(CompletableFuture.supplyAsync(() -> remoteCall(index)));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
观察线程:
text
ForkJoinPool.commonPool-worker-1
ForkJoinPool.commonPool-worker-2
ForkJoinPool.commonPool-worker-3
...
再看接口 RT、CPU、线程状态,你会发现:
text
大量任务等待执行
worker 线程被阻塞
调用方 join 卡住
接口线程也跟着等
这就是很多线上问题的缩小版。
六、正确写法:必须传业务线程池
生产环境里,业务异步任务不要裸用:
java
CompletableFuture.supplyAsync(() -> query());
要传明确的线程池:
java
CompletableFuture.supplyAsync(() -> query(), orderDetailExecutor);
配置一个业务隔离线程池:
java
@Configuration
public class OrderAsyncConfig {
@Bean("orderDetailExecutor")
public ThreadPoolTaskExecutor orderDetailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("order-detail-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
业务里使用:
java
@Resource(name = "orderDetailExecutor")
private Executor orderDetailExecutor;
public OrderDetail getOrderDetail(Long orderId) {
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(
() -> orderService.getOrder(orderId),
orderDetailExecutor
);
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(
() -> userService.getUserByOrderId(orderId),
orderDetailExecutor
);
CompletableFuture.allOf(orderFuture, userFuture).join();
return buildDetail(orderFuture.join(), userFuture.join());
}
至少这样有几个好处:
text
线程数可控
队列可控
拒绝策略可控
线程名可识别
不会污染 commonPool
七、线程池不是越大越好
有人看到 commonPool 不够用,第一反应是:
那我配一个 200 线程的池子。
这也危险。
因为你的线程池变大后,下游压力也会变大。
比如订单详情里并行查 5 个服务:
text
orderDetailExecutor 最大线程数 200
意味着最多可能同时打出 200 个下游调用
如果下游用户服务只能扛 50 并发,你这边开 200 线程,只是把问题从自己服务转移到了下游。
线程池参数要结合这些看:
text
入口 QPS
每个请求拆几个异步任务
每个任务平均耗时
下游服务承载能力
超时时间
是否允许降级
不要只盯着自己接口 RT。
并行提速,本质上是在消耗更多并发资源。
八、join 也可能把请求线程拖死
很多代码最后都会写:
java
CompletableFuture.allOf(futures).join();
这意味着当前请求线程会等所有异步任务完成。
如果某个异步任务卡住,当前 Tomcat 线程也会卡住。
所以必须设置超时。
不要让下游无限等。
比如:
java
CompletableFuture<User> userFuture =
CompletableFuture
.supplyAsync(() -> userService.getUserByOrderId(orderId), orderDetailExecutor)
.orTimeout(800, TimeUnit.MILLISECONDS);
或者允许降级:
java
CompletableFuture<Coupon> couponFuture =
CompletableFuture
.supplyAsync(() -> couponService.getCoupon(orderId), orderDetailExecutor)
.completeOnTimeout(Coupon.empty(), 500, TimeUnit.MILLISECONDS);
如果优惠券信息不是核心字段,超时后返回空对象,比拖死整个订单详情页更好。
九、异常别被 join 包住后没人处理
CompletableFuture 里的异常不会像同步代码那样直接在原位置抛出来。
比如:
java
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userService.getUserByOrderId(orderId), executor);
User user = userFuture.join();
如果异步任务里抛异常,join() 会抛:
text
CompletionException
真正原因在 cause 里。
所以日志不要只打:
java
log.error("查询失败", e);
要能看出是哪一个异步任务失败:
java
CompletableFuture<User> userFuture =
CompletableFuture
.supplyAsync(() -> userService.getUserByOrderId(orderId), executor)
.exceptionally(e -> {
log.error("query user failed, orderId={}", orderId, e);
return User.empty();
});
关键任务不要静默降级。
非关键展示字段可以降级。
这要按业务分清楚。
十、别让所有 CompletableFuture 共用一个池
和 @Async 一样,CompletableFuture 也要做线程池隔离。
不要整个项目只配一个:
java
@Bean("bizExecutor")
public Executor bizExecutor() {
// ...
}
然后所有异步任务都往里丢。
否则一个慢任务会拖垮所有异步场景。
建议至少按场景拆:
text
订单详情聚合线程池
报表导出线程池
消息通知线程池
低优先级日志线程池
线程池隔离的目的不是好看。
是为了让一个业务慢,不要拖死全站。
十一、上线前 checklist
看到 CompletableFuture,上线前建议过一遍:
| 检查项 | 风险 | 建议 |
|---|---|---|
| 是否传了 Executor | 默认 commonPool 不可控 | 必须传业务线程池 |
| 线程池是否隔离 | 慢任务拖垮全站 | 按业务拆线程池 |
| 队列是否有界 | 任务无限堆积 | 设置 queueCapacity |
| 拒绝策略是否明确 | 满了之后行为不明 | 失败、反压或降级 |
| 是否设置超时 | join 拖死请求线程 | orTimeout / completeOnTimeout |
| 异常是否处理 | 失败原因被包住 | 打出具体任务和参数 |
| 下游是否能扛住 | 并行放大压力 | 限流、降级、隔离 |
| 是否监控线程池 | 堆积不可见 | 监控活跃线程、队列、拒绝次数 |
十二、总结
CompletableFuture 很好用。
但它最危险的地方是:
代码看起来只是并行了几个任务,实际上可能把大量 IO 调用打进了默认公共线程池。
生产环境里记住三句话:
不要裸用
supplyAsync()。必须传业务线程池。
并行要配超时、降级、隔离和监控。
异步不是让任务消失。
它只是换了个地方执行。
如果那个地方没有边界,事故迟早会从那里冒出来。
下一篇准备写:Redis 延迟双删还是读到脏数据?一次缓存一致性事故
如果你也在排查 Java 后端线上问题,可以关注我。这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。