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