当一个接口需要调用多个下游服务来拼装返回数据时,最容易被忽略的性能瓶颈就是:这些互不依赖的 RPC 被串行执行了。本文以笔记详情接口为例,讲解如何用 CompletableFuture 把串行调用改造成并行,让接口耗时从"相加"变成"取最大"。
一、问题:同步串行调用拖慢接口
笔记详情接口要展示发布者的昵称、头像,以及笔记正文,需要分别调用两个下游服务:
- 用户服务:根据用户 ID 查昵称、头像;
- KV 键值服务:根据内容 ID 查笔记正文。
改造前是同步阻塞、串行执行的:
调用户服务(耗时 T1)→ 等返回 → 调 KV 服务(耗时 T2)→ 等返回
接口总耗时 = T1 + T2
关键点在于:这两个调用之间没有数据依赖,谁先谁后都不影响结果。串行执行白白把耗时叠加了,而并行执行时总耗时只取决于最慢的那个:
同时发起两个请求
接口总耗时 ≈ max(T1, T2)
下游服务越多、单次调用越慢,并行的收益就越明显。
二、CompletableFuture 是什么
CompletableFuture 是 Future 的扩展,用于简化 Java 异步编程。相比传统 Future,它能更容易地组合和变换异步任务的结果,并提供了丰富的 API 处理错误与取消。
核心 API 一览:
| API | 作用 |
|---|---|
supplyAsync(任务, 线程池) |
提交一个有返回值的异步任务 |
completedFuture(值) |
返回一个"已完成"的 Future,用于兜底 |
allOf(f1, f2, ...) |
等待所有 Future 完成,返回新的 CompletableFuture |
thenApply(函数) |
全部完成后对结果做转换 |
join() |
获取结果,不抛受检异常 |
exceptionally(函数) |
异常时返回默认值 |
cancel(true) |
尝试取消任务 |
三、改造实战:笔记详情接口
用 CompletableFuture.supplyAsync 同时发起两个下游调用,allOf 等待全部完成,再在 thenApply 里用 join() 取出结果拼装 VO:
// ① 异步调用用户服务(第二个参数传入自定义线程池)
CompletableFuture<FindUserByIdRspDTO> userResultFuture = CompletableFuture
.supplyAsync(() -> userRpcService.findById(creatorId), threadPoolTaskExecutor);
// ② 异步调用 KV 键值服务获取笔记内容
CompletableFuture<String> contentResultFuture = CompletableFuture.completedFuture(null);
if (Objects.equals(noteDO.getIsContentEmpty(), Boolean.FALSE)) {
contentResultFuture = CompletableFuture
.supplyAsync(() -> keyValueRpcService.findNoteContent(noteDO.getContentUuid()), threadPoolTaskExecutor);
}
// ③ allOf 等待两个异步任务都完成,thenApply 中拼接结果
CompletableFuture<FindNoteDetailRspVO> resultFuture = CompletableFuture
.allOf(userResultFuture, contentResultFuture)
.thenApply(s -> {
FindUserByIdRspDTO user = userResultFuture.join(); // 取用户服务结果
String content = contentResultFuture.join(); // 取 KV 服务结果
// ... 构建 FindNoteDetailRspVO 返回
return vo;
});
// ④ 阻塞等待最终拼装结果
FindNoteDetailRspVO findNoteDetailRspVO = resultFuture.get();
三个容易被忽略的细节
-
为什么 supplyAsync 要传自定义线程池
不传的话默认跑在全局共享的
ForkJoinPool.commonPool()上,多个接口并发异步时线程互相抢占。传自定义的ThreadPoolTaskExecutor,给"下游并发调用"一个专属、可控、有名字的线程池,这也是为什么项目里要单独配线程池配置类。 -
completedFuture(null) 的巧思
KV 内容可能为空(比如视频笔记没有文字正文),先给一个"已完成"的 Future 兜底,再按需决定是否真正发起调用。这样
allOf和join的处理逻辑不用写两套分支,结构更统一。 -
join() 与 get() 的区别
get()会抛受检异常ExecutionException,而join()不抛受检异常,所以能在thenApply的 lambda 里直接使用,这也是示例代码选join()的原因。
四、与缓存优化的关系
接口性能优化通常是叠加的:
- 缓存(Caffeine + Redis 二级缓存):解决"读数据快不快"——热点数据不落库、不穿透;
- CompletableFuture 并行化:解决"拼装数据等不等得久"——多个下游 RPC 同时发起。
两者方向不同、互不冲突:缓存已经把单次读取压到最快,并行化则把"多次等待"压缩成"一次等待"。高并发场景下,这两板斧往往要一起上。
五、总结
- 多个互不依赖的下游调用,应该用 CompletableFuture 并行执行,耗时从"相加"变"取最大";
- 核心三板斧:
supplyAsync提交任务、allOf等待完成、thenApply + join拼装结果; - 别忘了传自定义线程池,别让并发任务挤在默认的公共池里;
- 缓存优化读取、并行优化拼装,两者叠加才是接口性能的完整解法。