一个接口串行调 3 个上游改成 CompletableFuture 并行P95 从 1.8s 掉到 0.6s职位详情页要拉用户信息、企业信息、投递状态三份数据最早写的是 A 等 B、B 等 C 的串行页面 P95 1.8 秒测试直接吐槽转圈转到怀疑人生。改成 CompletableFuture 并行聚合后掉到 0.6 秒这篇把线程池隔离、超时、异常兜底一次讲清。先看串行有多慢三段请求每段 600ms三个接口互不依赖但代码是这么写的publicJobDetailVOdetail(StringjobId){UserVOuseruserClient.getUser(jobId);// 600msCompanyVOcompanycompanyClient.getCompany(jobId);// 600msApplyVOapplyapplyClient.getApplyStatus(jobId);// 600msreturnassemble(user,company,apply);}600 600 600 1800ms一个页面 1.8 秒。三个不相关的请求排着队等是最典型的能并行却串行。并行改造线程池必须自己建别用 commonPoolCompletableFuture 默认跑在ForkJoinPool.commonPool()上这个池子是全 JVM 共享的——别的异步任务、流式操作都在抢你一个阻塞式 HTTP 调用把公共线程占住整个应用跟着遭殃。正确做法是独立线程池按业务隔离ConfigurationpublicclassAsyncPoolConfig{Bean(bizPool)publicThreadPoolExecutorbizPool(){returnnewThreadPoolExecutor(8,16,60,TimeUnit.SECONDS,newArrayBlockingQueue(200),newThreadFactoryBuilder().setNameFormat(biz-pool-%d).build(),newThreadPoolExecutor.CallerRunsPolicy());}}队列 200、拒绝策略 CallerRuns满了就让调用线程自己跑宁可慢不能丢。线程池名一定要起排查线上问题全靠线程名认门。publicJobDetailVOdetail(StringjobId){CompletableFutureuserCompletableFuture.supplyAsync(()-userClient.getUser(jobId),bizPool);CompletableFuturecompanyCompletableFuture.supplyAsync(()-companyClient.getCompany(jobId),bizPool);CompletableFutureapplyCompletableFuture.supplyAsync(()-applyClient.getApplyStatus(jobId),bizPool);// 三个都完成后聚合或任意一个失败直接降级returnCompletableFuture.allOf(user,company,apply).exceptionally(e-null).thenApply(v-assemble(user.join(),company.join(),apply.join()));}allOf等三个都完成join()拿结果。任何一个抛异常exceptionally 兜住返回 null页面不至于整个 500——用户信息加载失败就先展示企业信息这是详情页能接受的降级。超时不能省上游挂了页面不能等 30 秒HTTP 客户端有自己的超时比如 3 秒但 CompletableFuture 没有默认超时——上游迟迟不返回join()会一直等。必须显式加超时CompletableFutureuserCompletableFuture.supplyAsync(()-userClient.getUser(jobId),bizPool).orTimeout(2500,TimeUnit.MILLISECONDS).exceptionally(e-null);// 超时/异常统一返回 nullorTimeout(2500ms)超时后这个 future 以TimeoutException完成exceptionally 兜底返回 null。页面最坏情况 单段超时 2.5s而不是无限等。三段里最慢的那段决定整体耗时理论最优就是最慢段的时间实测 0.6s 基本贴住了。踩坑get() 超时后任务还在后台偷偷跑现象加超时后接口是快了但日志里偶尔出现用户信息请求 5 秒后才完成——明明前端 2.5 秒就拿到响应了。排查看日志时间戳orTimeout触发后HTTP 调用还在继续日志 5 秒后打出来。原来是超时只是让调用方不等了任务本身没被取消——上游请求继续在跑占着线程池的线程。定位orTimeout/get(timeout)本质是放弃等待不是取消执行。线程池被这些僵尸任务占着高峰期线程耗尽新任务排队P95 反而恶化。解决两层配合——HTTP 客户端超时设 2 秒比 orTimeout 短让底层先抛线程池队列监控排队超过阈值告警。超时不是万能的底层超时 线程池水位监控一起上// 线程池水位监控activeCount 长时间贴 maxPoolSize 就该报警了Scheduled(fixedDelay60_000)publicvoidcheckPool(){intactivebizPool.getActiveCount();if(activebizPool.getMaximumPoolSize()){log.warn(bizPool 打满, active{}, queue{},active,bizPool.getQueue().size());}}可以直接复用的清单能并行的别串行互不依赖的上游调用用supplyAsync并行allOf聚合先算清楚哪些依赖哪些。线程池自己建别用commonPool()跑阻塞任务核心/最大/队列/拒绝策略按业务定线程名必起。每段都要 orTimeout上游挂了页面最多等单段超时不会无限挂exceptionally兜底返回 null 做降级。超时 ≠ 取消底层 HTTP 超时要比 orTimeout 短配合线程池水位监控防止僵尸任务占线程。聚合结果部分失败部分可用时降级策略先跟产品对齐——用户信息失败展示企业信息好过整个页面 500。这招不止详情页能用首页聚合、批量查询、报表组装凡是多个互不依赖的请求都值得过一遍。