
做接口聚合时,最容易写出这一类代码:用户、订单、风控三个远程调用同时发出去,最后等它们都回来。
不少人面试会说"用 CompletableFuture.allOf 并行执行"。这句话只说到了最外层。继续往下问,常见的坑马上就出来了:
allOf为什么拿不到三个结果?- 有一个任务抛异常,其他两个会不会停?
supplyAsync没传线程池到底跑在哪?- 请求超时后,下游调用真的停了吗?
这里不把 API 全背一遍,只把这个聚合接口该怎么收口讲清。
先别急着 join:allOf 返回的不是结果列表
allOf 的返回类型是 CompletableFuture<Void>。它的角色只是一个"都完成了"的闸门,不会替你把每个任务的结果打包好。
我一般会把结果 future 留在列表里,再在闸门之后取值:
java
List<CompletableFuture<UserPart>> parts = List.of(
profileFuture,
orderFuture,
riskFuture
);
CompletableFuture<Void> gate = CompletableFuture.allOf(
parts.toArray(CompletableFuture[]::new)
);
return gate.thenApply(ignored -> {
UserProfile profile = profileFuture.join();
List<Order> orders = orderFuture.join();
RiskLevel risk = riskFuture.join();
return UserView.of(profile, orders, risk);
});
这里的 join() 放在 thenApply 里面不是为了"等一等"。走到这里时,三个 future 已经都结束了,所以不会再把当前线程卡住。
更关键的是异常路径:JDK 21 的 CompletableFuture API 明确说明,只要传给 allOf 的 future 中有异常完成,返回的 future 也会异常完成。于是上面的 thenApply 不会执行,避免了把异常藏在半截结果里。

有个容易混淆的点:这不等于它会替你取消其他任务。某个下游失败时,已经发出去的请求仍可能继续跑完。要不要主动取消、要不要降级,得按业务自己定,别把 allOf 当成事务。
异常别散在每个 future 上
三路都写 exceptionally 看上去很"稳",实际常把真正的故障变成默认值。比如风控接口 500 了,代码悄悄给一个"低风险",排查时就很难知道是业务判断还是降级。
聚合接口先决定失败策略,再放处理点会清楚很多:
java
return gate
.thenApply(ignored -> UserView.of(
profileFuture.join(),
orderFuture.join(),
riskFuture.join()
))
.exceptionally(error -> {
Throwable cause = error.getCause() != null
? error.getCause()
: error;
log.warn("assemble user view failed, userId={}", userId, cause);
return UserView.degraded(userId);
});
join() 遇到异常会抛未经检查的 CompletionException,所以这里要看 getCause(),日志里才是下游真正抛出来的异常。
如果风控必须成功、订单可以暂时缺失,就别用一套通用的 exceptionally 糊过去。订单那一路单独降级,风控失败让整条请求失败,语义会直白很多。
默认线程池不是"随便能用"的线程池
不传 executor 的异步方法会使用该 stage 的默认异步执行设施。对 CompletableFuture 来说,常见运行环境下就是公共的 ForkJoinPool.commonPool()。
这在 demo 里没什么问题,放到服务端要多问一句:这些任务是 CPU 计算,还是会等数据库、HTTP、Redis?
I/O 等待任务混进公共池,最麻烦的不是偶尔慢,而是别的也依赖公共池的任务会一起被拖住。聚合接口通常直接给 I/O 任务一个有名字、有容量边界的 executor:
java
private final ExecutorService remoteIoPool = new ThreadPoolExecutor(
32,
32,
0,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500),
new ThreadFactory() {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public Thread newThread(Runnable runnable) {
return new Thread(
runnable,
"remote-io-" + sequence.incrementAndGet()
);
}
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
CompletableFuture<UserProfile> profileFuture =
CompletableFuture.supplyAsync(
() -> profileClient.fetch(userId),
remoteIoPool
);
线程数 32 和队列 500 不是答案,只是把"资源有限"写进代码。线上该看下游连接池、单次调用耗时和请求峰值再定。
还有一层经常漏掉:allOf 不会限制提交数量。一个请求聚合 3 个任务不显眼,流量一上来就是成千上万个 in-flight 调用。线程池队列只能挡住一部分,入口还应该有并发上限或按下游做 bulkhead。
orTimeout 只结束 future,不会替你拔网线
orTimeout 很好用:
java
CompletableFuture<UserProfile> profileFuture =
CompletableFuture.supplyAsync(
() -> profileClient.fetch(userId),
remoteIoPool
).orTimeout(300, TimeUnit.MILLISECONDS);
300 毫秒后,这个 future 会以超时异常完成。但底层 HTTP 客户端如果没有自己的 connect timeout、read timeout 和取消机制,请求可能还在路上。
所以我会把超时分两层配:
- future 层负责让聚合接口别一直等。
- HTTP 客户端层负责真的限制连接和读取。
面试里被问"allOf 怎么用",说完并行后补这几句就够了:它返回 Void,我在全成功的 continuation 里 join 结果;异常在聚合边界按业务降级;I/O 任务显式指定受控线程池;future 超时不等于下游请求已经停止。
文中的 API 行为按 JDK 21 CompletableFuture 官方文档校对。