一个 CompletableFuture,把 CPU 打满了:默认线程池的线上坑

一个 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 的真实踩坑复现。

相关推荐
free352 小时前
垃圾回收(GC)入门:从手动管理到自动回收的底层逻辑与演进
后端·架构
洛卡卡了2 小时前
Claude Code 项目级 Skill:团队协作场景下的使用场景
人工智能·后端·claude
用户762352425912 小时前
SpringAi 1.1更新说明
后端
程序员黎剑2 小时前
我把算卦变成了一段if-else
java·vue.js·spring boot·后端·ai编程
SimonKing2 小时前
Base62编码生成6位不重复短码的背后设计
java·后端·程序员
IT_陈寒2 小时前
JavaScript的隐式类型转换又双叒坑了我一次!
前端·人工智能·后端
她说彩礼65万3 小时前
Asp.net core 返回类型
数据库·后端·asp.net
小呀小叮当~3 小时前
Telegram 有哪些实用功能?一文了解
前端·人工智能·后端·其它
卷福同学4 小时前
AI编程出海第一步:别急着写代码,先找到老外真正愿意付费的需求
人工智能·后端·面试