一个接口串行调 3 个上游,改成 CompletableFuture 并行,P95 从 1.8s 掉到 0.6s
职位详情页要拉用户信息、企业信息、投递状态三份数据,最早写的是 A 等 B、B 等 C 的串行,页面 P95 1.8 秒,测试直接吐槽"转圈转到怀疑人生"。改成 CompletableFuture 并行聚合后掉到 0.6 秒,这篇把线程池隔离、超时、异常兜底一次讲清。
先看串行有多慢:三段请求,每段 600ms
三个接口互不依赖,但代码是这么写的:
publicJobDetailVOdetail(StringjobId){UserVOuser=userClient.getUser(jobId);// 600msCompanyVOcompany=companyClient.getCompany(jobId);// 600msApplyVOapply=applyClient.getApplyStatus(jobId);// 600msreturnassemble(user,company,apply);}600 + 600 + 600 = 1800ms,一个页面 1.8 秒。三个不相关的请求排着队等,是最典型的"能并行却串行"。
并行改造:线程池必须自己建,别用 commonPool
CompletableFuture 默认跑在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){CompletableFutureuser=CompletableFuture.supplyAsync(()->userClient.getUser(jobId),bizPool);CompletableFuturecompany=CompletableFuture.supplyAsync(()->companyClient.getCompany(jobId),bizPool);CompletableFutureapply=CompletableFuture.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()会一直等。必须显式加超时:
CompletableFutureuser=CompletableFuture.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(fixedDelay=60_000)publicvoidcheckPool(){intactive=bizPool.getActiveCount();if(active>=bizPool.getMaximumPoolSize()){log.warn("bizPool 打满, active={}, queue={}",active,bizPool.getQueue().size());}}可以直接复用的清单
- 能并行的别串行:互不依赖的上游调用用
supplyAsync并行,allOf聚合;先算清楚哪些依赖哪些。 - 线程池自己建:别用
commonPool()跑阻塞任务;核心/最大/队列/拒绝策略按业务定,线程名必起。 - 每段都要 orTimeout:上游挂了页面最多等单段超时,不会无限挂;
exceptionally兜底返回 null 做降级。 - 超时 ≠ 取消:底层 HTTP 超时要比 orTimeout 短,配合线程池水位监控,防止僵尸任务占线程。
- 聚合结果部分失败部分可用时,降级策略先跟产品对齐——用户信息失败展示企业信息,好过整个页面 500。
这招不止详情页能用,首页聚合、批量查询、报表组装,凡是"多个互不依赖的请求"都值得过一遍。