CompletableFuture 的难点不只是把任务并行执行,更在于异常如何传播、多个任务如何收敛,以及失败后是否还能正确释放资源。本文结合 handle、exceptionally、whenComplete 和 allOf,梳理一套可维护的异步任务处理方式。
问题背景
在使用 CompletableFuture 组合多个远程调用时,最容易被忽略的部分不是线程池配置,而是异常语义:一个子任务失败后,其他任务会发生什么?主流程什么时候算完成?降级值是应该由谁提供?如果只在链尾随手写一个 exceptionally,代码往往能运行,却很难判断哪些失败被吞掉了,哪些结果其实并不完整。
例如,一个订单详情接口需要同时查询商品、库存和优惠信息。商品信息是核心数据,查询失败时应该让整个请求失败;优惠信息属于可选数据,失败后可以返回空优惠;库存查询即使失败,也应保留原始异常和请求上下文,方便定位问题。三类失败不能用同一种写法处理。
三种回调的职责不同
CompletableFuture 中常见的异常处理方法有三个:
exceptionally:只处理异常,并将异常转换成一个正常结果,适合明确的降级逻辑。handle:无论成功还是失败都会执行,同时接收结果和异常,适合统一转换结果。whenComplete:无论成功还是失败都会执行,但不改变原有结果,适合记录日志、指标和清理动作。
它们的关键区别是:exceptionally 和 handle 可以改变后续链路看到的结果,而 whenComplete 原则上只做旁路动作。
CompletableFuture<String> source = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("remote service failed");
});
CompletableFuture<String> recovered = source
.exceptionally(ex -> "fallback");
CompletableFuture<String> handled = source
.handle((value, ex) -> {
if (ex != null) {
return "fallback";
}
return value;
});
CompletableFuture<String> observed = source
.whenComplete((value, ex) -> {
if (ex != null) {
System.err.println("request failed: " + ex);
}
});recovered 和 handled 最终都可以得到字符串结果,异常已经被转换成了正常完成状态。observed 则仍然是异常完成,调用 join() 时依旧会抛出异常。也就是说,whenComplete 不是一个默认的异常吞噬器。
实际工程中,建议把三种职责分开:业务降级使用 exceptionally 或 handle,日志和指标使用 whenComplete,不要用日志回调顺便改变业务结果。
allOf 不会返回子任务结果
当多个任务需要并行执行时,常见写法是:
CompletableFuture<Product> productFuture = CompletableFuture.supplyAsync(
() -> productClient.find(productId), executor);
CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(
() -> stockClient.find(productId), executor);
CompletableFuture<Coupon> couponFuture = CompletableFuture.supplyAsync(
() -> couponClient.find(userId, productId), executor);
CompletableFuture<Void> all = CompletableFuture.allOf(
productFuture, stockFuture, couponFuture);这里的 all 只表示所有任务都完成,不会自动把三个结果封装出来。更重要的是,只要其中任意一个任务异常完成,all 也会异常完成;但它并不意味着其他任务会被取消。其他任务可能仍在执行,甚至已经向远程服务发出了请求。
一个更完整的实现可以这样写:
public OrderDetail loadOrderDetail(
long userId,
long productId,
Executor executor) {
CompletableFuture<Product> productFuture = CompletableFuture
.supplyAsync(() -> productClient.find(productId), executor)
.whenComplete((value, ex) -> record("product", ex));
CompletableFuture<Stock> stockFuture = CompletableFuture
.supplyAsync(() -> stockClient.find(productId), executor)
.whenComplete((value, ex) -> record("stock", ex));
CompletableFuture<Coupon> couponFuture = CompletableFuture
.supplyAsync(() -> couponClient.find(userId, productId), executor)
.exceptionally(ex -> {
record("coupon-fallback", ex);
return Coupon.empty();
});
return CompletableFuture.allOf(productFuture, stockFuture, couponFuture)
.thenApply(ignored -> new OrderDetail(
productFuture.join(),
stockFuture.join(),
couponFuture.join()))
.join();
}这段代码表达了明确的业务规则:商品和库存是核心依赖,失败时整体失败;优惠信息可以降级为空。join() 出现在 allOf 之后,因此三个任务已经完成,不会在这里重新等待。这里的 join() 不等于忽略异常:如果商品或库存失败,allOf 已经异常完成,thenApply 不会执行,最终调用者仍然能够感知失败。
不过,示例中的 join() 会将异常包装为 CompletionException。如果接口层需要区分业务异常、远程异常和系统异常,应在统一边界处解包,而不是在每个异步节点里到处捕获。
static Throwable unwrap(Throwable throwable) {
Throwable current = throwable;
while ((current instanceof CompletionException
|| current instanceof ExecutionException)
&& current.getCause() != null) {
current = current.getCause();
}
return current;
}一个更稳妥的结果收敛方式
如果希望显式获得每个任务的成功或失败状态,可以让每个子任务先转换为结果对象,而不是直接让异常向上传播:
record TaskResult<T>(T value, Throwable error) {
static <T> TaskResult<T> success(T value) {
return new TaskResult<>(value, null);
}
static <T> TaskResult<T> failure(Throwable error) {
return new TaskResult<>(null, error);
}
boolean isSuccess() {
return error == null;
}
}
static <T> CompletableFuture<TaskResult<T>> capture(
Supplier<T> action,
Executor executor) {
return CompletableFuture
.supplyAsync(action, executor)
.thenApply(TaskResult::success)
.exceptionally(ex -> TaskResult.failure(unwrap(ex)));
}调用时,所有任务都会正常完成,主流程可以集中判断:
CompletableFuture<TaskResult<Product>> product =
capture(() -> productClient.find(productId), executor);
CompletableFuture<TaskResult<Stock>> stock =
capture(() -> stockClient.find(productId), executor);
CompletableFuture<TaskResult<Coupon>> coupon =
capture(() -> couponClient.find(userId, productId), executor);
CompletableFuture.allOf(product, stock, coupon).join();
TaskResult<Product> productResult = product.join();
TaskResult<Stock> stockResult = stock.join();
TaskResult<Coupon> couponResult = coupon.join();
if (!productResult.isSuccess()) {
throw new IllegalStateException("product is required", productResult.error());
}
Stock stockValue = stockResult.isSuccess()
? stockResult.value()
: Stock.unknown();
Coupon couponValue = couponResult.isSuccess()
? couponResult.value()
: Coupon.empty();这种方式适合“部分成功”的聚合接口,但也有代价:异常不再自动中断链路,业务代码必须明确检查每个结果。如果团队没有统一约定,结果对象可能变成另一种被忽略的错误容器。
常见坑
1. 在 exceptionally 中返回 null
exceptionally(ex -> null) 会让异常变成一个成功的 null 结果,后续代码可能在更远的位置触发空指针,真正原因反而更难追踪。降级值应当是有业务含义的对象,例如空集合、明确的 Optional 或带状态的结果类型。
2. 在异步链中重复打印异常
同一个异常可能在 whenComplete、exceptionally 和接口统一异常处理器中分别被记录,最终日志出现三四份。建议约定:异步节点记录必要的上下文,最外层负责一次完整错误日志;指标可以单独上报,但不要复制整段堆栈。
3. 误以为 allOf 会取消其他任务
allOf 发现一个任务失败时,不会自动中断其他任务。若任务涉及数据库连接、HTTP 响应体或文件句柄,仍然必须在各自的 finally 或客户端规范中完成释放。需要取消时,可以保留每个 Future 并显式调用 cancel,但取消通常只是发送协作式信号,不保证底层阻塞调用立即停止。
4. 使用公共线程池承载阻塞调用
CompletableFuture.supplyAsync 不指定执行器时,会使用默认的公共线程池。数据库、HTTP、文件读写等阻塞任务不应无条件放进去,否则可能与其他异步任务争抢线程。应根据任务性质配置隔离的线程池,并设置队列容量、拒绝策略和监控指标。
5. 超时只处理结果,不一定停止工作
在支持的 JDK 版本中,可以使用 orTimeout 或 completeOnTimeout 控制 CompletableFuture 的完成状态。但超时后,已经提交到线程池的任务可能仍在执行,不能把“调用方不再等待”和“底层任务已经停止”混为一谈。需要真正减少资源消耗时,还要把截止时间或取消信号传递到下游客户端。
实践建议
第一,先定义每个依赖的业务等级:核心依赖失败是否阻断,非核心依赖失败如何降级,不能等到代码写完后再决定。
第二,在线程池边界创建异步任务,并明确指定执行器。不要让调用链中某个隐藏的 supplyAsync 悄悄切换到公共线程池。
第三,把异常处理放在正确的位置:节点级降级、链路级收敛、接口级统一转换,各自只负责一层。
第四,为每个异步任务记录任务名称、业务标识、耗时和最终状态。只记录“future failed”通常不足以定位问题。
第五,为组合逻辑补充测试:核心任务失败、可选任务失败、多个任务同时失败、超时后任务仍在执行,以及线程池拒绝提交。并发代码最需要验证的,往往不是成功路径,而是失败发生时系统还能否保持边界清晰。
总结
CompletableFuture 的价值不只是把几个方法并行起来,更在于它提供了一套异步结果的组合模型。exceptionally 负责有意识的降级,handle 负责统一转换,whenComplete 负责观察和清理;allOf 负责等待收敛,却不会替你组织结果,也不会自动取消其他任务。
当代码能够明确回答“谁失败会阻断、谁失败可以降级、异常在哪里记录、任务如何停止、资源由谁释放”时,异步链才真正具备工程上的可维护性。否则,越多的并行调用只会把一个简单的错误,变成一条难以解释的异常传播路径。