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

一个 CompletableFuture,把 CPU 打满了:默认线程池的线上坑
一个 CompletableFuture把 CPU 打满了默认线程池的线上坑接口明明只是想并行查几个服务。加上CompletableFuture后响应一开始确实快了。结果高峰期 CPU 打满、接口超时、异步任务堆积最后发现所有任务都挤进了默认的ForkJoinPool.commonPool。一、事故现场线上有个订单详情接口。页面需要展示这些信息订单基本信息 用户信息 物流信息 优惠券信息 售后状态一开始是串行查publicOrderDetailgetOrderDetail(LongorderId){OrderorderorderService.getOrder(orderId);UseruseruserService.getUser(order.getUserId());LogisticslogisticslogisticsService.getLogistics(orderId);CouponcouponcouponService.getCoupon(orderId);AfterSaleafterSaleafterSaleService.getAfterSale(orderId);returnbuildDetail(order,user,logistics,coupon,afterSale);}接口 RT 偏高。于是开发同学改成了并行publicOrderDetailgetOrderDetail(LongorderId){CompletableFutureOrderorderFutureCompletableFuture.supplyAsync(()-orderService.getOrder(orderId));CompletableFutureUseruserFutureCompletableFuture.supplyAsync(()-userService.getUserByOrderId(orderId));CompletableFutureLogisticslogisticsFutureCompletableFuture.supplyAsync(()-logisticsService.getLogistics(orderId));CompletableFutureCouponcouponFutureCompletableFuture.supplyAsync(()-couponService.getCoupon(orderId));CompletableFutureAfterSaleafterSaleFutureCompletableFuture.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(SupplierUsupplier)传线程池CompletableFuture.supplyAsync(SupplierUsupplier,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();}returnok-i;}不传线程池publicvoidtestCommonPool(){ListCompletableFutureStringfuturesnewArrayList();for(inti0;i1000;i){intindexi;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(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(16);executor.setMaxPoolSize(32);executor.setQueueCapacity(500);executor.setThreadNamePrefix(order-detail-);executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.initialize();returnexecutor;}}业务里使用Resource(nameorderDetailExecutor)privateExecutororderDetailExecutor;publicOrderDetailgetOrderDetail(LongorderId){CompletableFutureOrderorderFutureCompletableFuture.supplyAsync(()-orderService.getOrder(orderId),orderDetailExecutor);CompletableFutureUseruserFutureCompletableFuture.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 线程也会卡住。所以必须设置超时。不要让下游无限等。比如CompletableFutureUseruserFutureCompletableFuture.supplyAsync(()-userService.getUserByOrderId(orderId),orderDetailExecutor).orTimeout(800,TimeUnit.MILLISECONDS);或者允许降级CompletableFutureCouponcouponFutureCompletableFuture.supplyAsync(()-couponService.getCoupon(orderId),orderDetailExecutor).completeOnTimeout(Coupon.empty(),500,TimeUnit.MILLISECONDS);如果优惠券信息不是核心字段超时后返回空对象比拖死整个订单详情页更好。九、异常别被 join 包住后没人处理CompletableFuture里的异常不会像同步代码那样直接在原位置抛出来。比如CompletableFutureUseruserFutureCompletableFuture.supplyAsync(()-userService.getUserByOrderId(orderId),executor);UseruseruserFuture.join();如果异步任务里抛异常join()会抛CompletionException真正原因在cause里。所以日志不要只打log.error(查询失败,e);要能看出是哪一个异步任务失败CompletableFutureUseruserFutureCompletableFuture.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 的真实踩坑复现。