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 负责等待收敛,却不会替你组织结果,也不会自动取消其他任务。

当代码能够明确回答“谁失败会阻断、谁失败可以降级、异常在哪里记录、任务如何停止、资源由谁释放”时,异步链才真正具备工程上的可维护性。否则,越多的并行调用只会把一个简单的错误,变成一条难以解释的异常传播路径。

最后修改:2026 年 10 月 05 日
如果觉得我的文章对你有用,请随意赞赏