并行调下游,TP99飙到5秒

上周二下午三点,监控群炸了。

订单查询接口 TP99 从 50ms 跳到 5 秒,告警刷屏。Leader 看了一眼最近一次提交记录:一个同事把原来串行调下游商品、库存、优惠券的代码改成了 CompletableFuture 并行调用,提交信息写得很自信——"并行调下游,性能优化"。

优化完,RT 翻了 100 倍。


一把梭的"优化"

上线前的代码长这样:

// 商品详情页:需要同时查商品、库存、优惠券三个下游
public OrderDetailVO queryDetail(Long orderId) {
    // ① 查订单基本信息
    Order order = orderMapper.selectById(orderId);

    // ② 并行调用三个下游 —— "性能优化"
    CompletableFuture productFuture = CompletableFuture.supplyAsync(
        () -> productClient.getProduct(order.getProductId())   // HTTP调用, 耗时 ~100ms
    );
    CompletableFuture inventoryFuture = CompletableFuture.supplyAsync(
        () -> inventoryClient.getStock(order.getProductId())   // HTTP调用, 耗时 ~80ms
    );
    CompletableFuture couponFuture = CompletableFuture.supplyAsync(
        () -> couponClient.getCoupons(order.getUserId())       // HTTP调用, 耗时 ~150ms
    );

    // ③ 等全部完成
    CompletableFuture.allOf(productFuture, inventoryFuture, couponFuture).join();

    // ④ 组装返回
    return assemble(order,
        productFuture.get(), inventoryFuture.get(), couponFuture.get());
}

这段代码看起来没什么问题:三个下游原来串行 330ms,并行后理论上只要 150ms。QA 环境测了也没事。

上线两小时,TP99 炸了。


只有一个嫌疑人

先看现象。jstack 抓到的线程栈:

"ForkJoinPool.commonPool-worker-1" #23 daemon prio=5
   java.net.SocketInputStream.socketRead0(Native Method)
   ...
   at productClient.getProduct(ProductClient.java:42)

"ForkJoinPool.commonPool-worker-2" #24 daemon prio=5
   java.net.SocketInputStream.socketRead0(Native Method)
   ...
   at inventoryClient.getStock(InventoryClient.java:35)

"ForkJoinPool.commonPool-worker-3" #25 daemon prio=5
   java.net.SocketInputStream.socketRead0(Native Method)
   ...
   at couponClient.getCoupons(CouponClient.java:28)

"ForkJoinPool.commonPool-worker-4" #26 daemon prio=5
   java.net.SocketInputStream.socketRead0(Native Method)
   ...
   at promotionClient.queryPromo(PromotionClient.java:55)

// ⚠️ 总共就 7 个 worker 线程,全部卡在 IO 等待上

问题已经很清楚了。

supplyAsync() 不传线程池参数时,默认用的是 ForkJoinPool.commonPool()——一个 JVM 级别的全局共享线程池。它的线程数默认等于 CPU核心数 - 1。假设这台机器是 8 核,那只有 7 个线程

7 个线程要服务整个 JVM 里所有"裸奔"的 CompletableFuture,外加所有 parallelStream()。

而每个下游 HTTP 调用耗时 80~150ms。当 QPS 稍微上来,这 7 个线程全部被 IO 阻塞占满,后来的请求只能在队列里排队——排到最后,一个请求从进来到出去,光是等线程就等了 4 秒多。

这哪是并行优化,这是把所有请求塞进了一个单行道。


为什么"裸用"能跑两小时才炸

前两小时 QPS 低,7 个线程刚好够用。QPS 一过阈值,排队效应立刻放大。

来看一个简化版复现代码:

// ⚠️ 复现:模拟高并发下 commonPool 被 IO 任务占满
public class CommonPoolPollutionDemo {

    public static void main(String[] args) {
        // 打印 commonPool 的并行度(你的机器上大概率是 CPU核数-1)
        System.out.println("CommonPool parallelism: " +
            ForkJoinPool.commonPool().getParallelism());

        // 模拟 20 个并发请求,每个内部打 3 个"下游调用"
        for (int i = 0; i < 20; i++) {
            final int reqId = i;
            new Thread(() -> {
                long start = System.currentTimeMillis();

                // ← 三个 supplyAsync 都走 commonPool,共享 7 个线程
                CompletableFuture f1 = CompletableFuture.supplyAsync(
                    () -> { sleep(100); return null; });  // 模拟 IO
                CompletableFuture f2 = CompletableFuture.supplyAsync(
                    () -> { sleep(80);  return null; });  // 模拟 IO
                CompletableFuture f3 = CompletableFuture.supplyAsync(
                    () -> { sleep(150); return null; });  // 模拟 IO

                CompletableFuture.allOf(f1, f2, f3).join();

                long cost = System.currentTimeMillis() - start;
                System.out.printf("请求#%02d  耗时: %dms%n", reqId, cost);
            }).start();
        }
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {}
    }
}

8 核机器上跑这段代码,前几个请求确实在 150ms 左右完成——后面十几个直接排队到几百甚至上千毫秒:

CommonPool parallelism: 7
请求#00  耗时: 152ms    ← 正常
请求#01  耗时: 151ms    ← 正常
请求#04  耗时: 163ms    ← 还行
请求#07  耗时: 278ms    ← 开始排队
请求#10  耗时: 421ms    ← 恶化
请求#15  耗时: 687ms    ← 崩了
请求#18  耗时: 912ms    ← 彻底崩了

三个 100ms 的 IO,并行反而比串行还慢。这就是 commonPool 污染的威力。


不是不能用 CompletableFuture,是不能"裸用"

正确答案就一句话:为 IO 密集型任务分配独立线程池,永远不要依赖 commonPool。

// ✅ 正确姿势:为 IO 任务自定义线程池
public class OrderService {

    // 独立线程池:IO密集型,核心线程 = 2×CPU核心数
    private static final Executor IO_POOL = new ThreadPoolExecutor(
        16,                                    // 核心线程数
        32,                                    // 最大线程数
        60L, TimeUnit.SECONDS,                 // 空闲线程存活时间
        new LinkedBlockingQueue<>(200),        // 有界队列,防止内存溢出
        new ThreadFactoryBuilder()
            .setNameFormat("order-io-%d")      // 线程命名,方便排查
            .build(),
        new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略:交给调用线程执行,防止丢任务
    );

    public OrderDetailVO queryDetail(Long orderId) {
        Order order = orderMapper.selectById(orderId);

        // ← 显式传入 IO_POOL,不再裸用 commonPool
        CompletableFuture productFuture = CompletableFuture.supplyAsync(
            () -> productClient.getProduct(order.getProductId()), IO_POOL);
        CompletableFuture inventoryFuture = CompletableFuture.supplyAsync(
            () -> inventoryClient.getStock(order.getProductId()), IO_POOL);
        CompletableFuture couponFuture = CompletableFuture.supplyAsync(
            () -> couponClient.getCoupons(order.getUserId()), IO_POOL);

        // 加超时兜底,防止某个下游卡死拖垮整个接口
        try {
            CompletableFuture.allOf(productFuture, inventoryFuture, couponFuture)
                .get(2, TimeUnit.SECONDS);  // 2秒兜底超时
        } catch (TimeoutException e) {
            // 超时降级:返回缓存数据或默认值
            log.warn("下游超时,触发降级 orderId={}", orderId);
            return fallbackDetail(order);
        }

        return assemble(order,
            productFuture.getNow(null),
            inventoryFuture.getNow(null),
            couponFuture.getNow(null));
    }
}

几个关键点:

参数

为什么这么设

踩过的坑

核心线程 16

IO 密集型,2×CPU 核数起步,留有冗余

设太小排队;设太大线程切换开销吃掉收益

有界队列 200

防止无限制堆积导致 OOM

无界队列在高峰期内存暴涨

CallerRunsPolicy

队列满了让调用线程自己执行,天然限流

AbortPolicy 直接抛异常,请求全丢

2 秒超时

防止某个下游 hang 住拖死整个接口

不设超时,一个慢下游拖垮所有线程


再多想一步:什么时候可以"裸用"

但也不是一棍子打死。ForkJoinPool.commonPool() 的设计初衷是给短时 CPU 计算任务用的——work-stealing 机制在这种场景下效率极高。

如果任务满足以下三个条件,用 commonPool 是安全的:

  1. 纯 CPU 计算,无 IO 阻塞(无网络、无磁盘、无锁等待)
  2. 耗时极短,毫秒级以内
  3. 并发量可控,不会频繁大量提交

举个例子——对内存中的 List 做并行计算:

// ✅ 这种场景可以用 commonPool:纯 CPU 计算、毫秒级完成
List nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);
List results = nums.parallelStream()  // ← 走 commonPool
    .map(n -> n * n)
    .toList();

但只要涉及网络调用、数据库查询、文件读写,就必须走自定义线程池。没有例外。


线上排查三步法

如果你怀疑线上也有 commonPool 污染,按这个顺序排查:

  1. jstack 抓线程栈,搜 ForkJoinPool.commonPool-worker,看是否有大量线程卡在 socketRead、jdbc 等 IO 调用上
  2. Arthas 的 thread -b 找阻塞线程,thread -n 5 看 CPU 占用最高的 5 个线程
  3. 监控 commonPool 队列长度——如果 QueuedSubmissionCount 持续走高,说明任务在排队

确认污染后,按任务类型拆池:IO 任务用 ThreadPoolExecutor(线程数 2×CPU),CPU 任务用独立 ForkJoinPool(parallelism = CPU 核数),关键业务独占线程池。


一行 supplyAsync(),不传线程池参数——上线 2 小时,TP99 从 50ms 飙到 5 秒。

CompletableFuture 是好工具,但别裸用。

展开阅读全文

更新时间:2026-07-02

标签:科技   下游   线程   队列   核心   代码   上线   接口   优惠券   独立   内存

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight All Rights Reserved.
Powered By 61893.com 闽ICP备11008920号
闽公网安备35020302034844号

Top