
上周二下午三点,监控群炸了。
订单查询接口 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 污染的威力。
正确答案就一句话:为 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 是安全的:
举个例子——对内存中的 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 污染,按这个顺序排查:
确认污染后,按任务类型拆池:IO 任务用 ThreadPoolExecutor(线程数 2×CPU),CPU 任务用独立 ForkJoinPool(parallelism = CPU 核数),关键业务独占线程池。
一行 supplyAsync(),不传线程池参数——上线 2 小时,TP99 从 50ms 飙到 5 秒。
CompletableFuture 是好工具,但别裸用。
更新时间:2026-07-02
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight All Rights Reserved.
Powered By 61893.com 闽ICP备11008920号
闽公网安备35020302034903号