CompletableFuture失败后返回空列表:异常没了,业务含义也变了

exceptionally(error -> List.of())能把一个失败的CompletableFuture转换成正常完成的空列表。但对业务来说,失败查询和成功查询到零条记录,并没有因此变成同一件事。

如果页面据此显示"暂无订单",用户就可能把服务故障理解成"我没有订单"。下面用JDK 21.0.11的本地程序,检查这个转换在哪里丢掉了含义。

先运行一条会失败的查询

错误对照中的Supplier固定抛IllegalStateException。调用方经过exceptionally后拿到\[\],不会再从这个Future的完成状态看见原来的失败。

这是API的异常恢复语义,不是CompletableFuture漏报异常。恢复成什么结果,需要由调用代码决定。JDK 21 CompletableFuture文档

修订版用一个明确的结果类型同时表示状态、数据和错误码。成功为空是OK + [],失败是FAILED + UPSTREAM_UNAVAILABLE。列表可以同样为空,状态仍然不同。

完整代码与实际结果

保存为EmptyFallbackDemo.java,无需外部依赖:

shell 复制代码
javac -encoding UTF-8 --release 21 EmptyFallbackDemo.java
java EmptyFallbackDemo
java 复制代码
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class EmptyFallbackDemo {
    enum Status { OK, FAILED }
    record QueryResult(Status status, List<String> rows, String errorCode) {
        QueryResult { rows = List.copyOf(rows); }
    }
    static Throwable root(Throwable e) {
        while (e instanceof CompletionException && e.getCause() != null) e = e.getCause();
        return e;
    }
    static CompletableFuture<QueryResult> query(Supplier<List<String>> loader, Executor executor) {
        return CompletableFuture.supplyAsync(loader, executor).handle((rows, error) -> {
            if (error == null) return new QueryResult(Status.OK, rows, "");
            // 对外只给稳定错误码;原始异常在服务端保留,避免把内部细节回给用户。
            System.out.println("服务端记录异常类型=" + root(error).getClass().getSimpleName());
            return new QueryResult(Status.FAILED, List.of(), "UPSTREAM_UNAVAILABLE");
        });
    }
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            Supplier<List<String>> down = () -> { throw new IllegalStateException("教学故障"); };
            List<String> hidden = CompletableFuture.supplyAsync(down, pool)
                    .exceptionally(error -> List.of()).get(5, TimeUnit.SECONDS);
            if (!hidden.isEmpty()) throw new AssertionError();
            System.out.println("吞异常后的响应=" + hidden);
            QueryResult empty = query(List::of, pool).get(5, TimeUnit.SECONDS);
            QueryResult failed = query(down, pool).get(5, TimeUnit.SECONDS);
            if (empty.status() != Status.OK || failed.status() != Status.FAILED) throw new AssertionError();
            System.out.println("成功但无记录=" + empty.status() + ", rows=" + empty.rows());
            System.out.println("查询失败=" + failed.status() + ", code=" + failed.errorCode());
            QueryResult data = query(() -> List.of("order-A"), pool).get(5, TimeUnit.SECONDS);
            List<QueryResult> parts = List.of(data, failed);
            long successes = parts.stream().filter(x -> x.status() == Status.OK).count();
            List<String> rows = parts.stream().filter(x -> x.status() == Status.OK)
                    .flatMap(x -> x.rows().stream()).toList();
            if (successes != 1 || !rows.equals(List.of("order-A"))) throw new AssertionError();
            System.out.println("聚合结果=PARTIAL, rows=" + rows + ", failedParts=" + (parts.size() - successes));
        } finally { pool.shutdownNow(); }
    }
}

本次输出:

text 复制代码
吞异常后的响应=[]
服务端记录异常类型=IllegalStateException
成功但无记录=OK, rows=[]
查询失败=FAILED, code=UPSTREAM_UNAVAILABLE
聚合结果=PARTIAL, rows=[order-A], failedParts=1

程序先验证吞异常版本确实返回空列表,再验证成功无记录与查询失败能被区分,最后验证聚合时保留成功数据与失败部分的数量。Future读取与线程关闭都有明确处理,没有无限等待。

本例只打印异常类型,未接入日志框架。实际服务应在安全范围内记录完整异常、请求标识及对应子查询,供排查使用;对外不直接暴露连接地址、SQL和堆栈。

部分成功,要由业务允许

代码最后把一份正常数据与一份失败结果放在一起,输出PARTIAL。它只是演示怎样保留区别,没有替所有接口决定"可以部分展示"。

如果缺少的是可选推荐内容,业务可能允许页面继续工作;如果缺少的是必须核验的订单或权限结果,不能因为另一部分成功,就继续做不受支持的操作。是否重试,也需要看错误类型、操作是否可重复,以及调用方还能等待多久。

本文没有实现HTTP、真实上游查询、重试或超时取消。错误Supplier是明确构造的教学故障。要把结果类型接回Spring接口,需要继续约定HTTP响应、前端提示与可执行动作,而不是只把字段加到一个DTO里就结束。

检查自己的降级代码,可以从一个返回空列表的catch或exceptionally开始:它处理的失败,最终会被页面解释成什么业务事实?

相关推荐
Gopher_HBo1 小时前
opentelemetrySDK源码解析
后端
知识搬运临时工1 小时前
一个模板把 AEM 拖进 Full GC 死亡螺旋:300% CPU、7 GB 堆、310 万行堆栈
java·aws
用户432935928051 小时前
CoAP还是MQTT:1万只Cat.1水表三个月带宽续航实测
java
UrbanJazzerati1 小时前
AutoHotkey v2 零基础快速入门 Part 10:给路线回放添加高精度计时,理解累计延迟与路线一致性
后端
70后写java的老码农1 小时前
Spring Boot 3 + ShardingSphere-JDBC 实战:一次把读写分离和分库分表跑通
后端
泡海椒1 小时前
04-饼图评级构成-信用评级构成
后端
知守观1 小时前
多模块架构演进:横向分层 vs 纵向领域,13 个真实项目的切分对照
java·后端·微服务
Java成神之路-1 小时前
Java String 为什么不可变?和 HashMap 到底有什么关系?
java
paopaokaka_luck2 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts