← Java

Java 异步调用:超时、取消与重试是三件事

拆开 CompletableFuture 的完成状态与底层请求生命周期,用截止时间、幂等键和重试预算约束异步链路。

Read in English →

工程学习与设计笔记。示例使用合成场景;适用版本与参考资料见正文。

适用范围:Java 17 的 CompletableFuture 与标准 HttpClient。示例展示 API 组合,不代表完整的生产客户端。

异步调用让线程不用一直阻塞,但没有让资源成本消失。一个请求在接口层已经超时,底层网络操作可能仍在继续;这时再发一次重试,系统可能同时处理两份工作。

给整条请求一个预算

假设入口请求允许 800 毫秒,里面有参数校验、排队、两个外部调用和响应组装。若每个外部调用各配置 800 毫秒,整体就很容易超出入口承诺。

更合理的做法是入口计算截止时间,每个阶段使用剩余预算,并为返回响应留出余量。计算进程内耗时时使用单调时钟,例如 System.nanoTime();跨服务传播时还需要明确时钟偏差与协议语义,不能直接传这个进程内的计数值。

连接超时与请求超时也不同。HttpClient 的连接超时针对建立新连接,复用已有连接时不能指望它约束整个请求;HttpRequest 的超时用于等待响应。语义分别见 HttpClient.Builder 与 HttpRequest.Builder 。流式响应还应专门设计读取期限与释放方式。

future 超时不等于底层工作停止

下面是一次 GET 请求的局部示例,类型来自 java.net、java.net.http、java.time 与 java.util.concurrent:

HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofMillis(150))
    .build(); // 服务级复用,不应为每次请求重新创建。

HttpRequest request = HttpRequest.newBuilder(
        URI.create("https://example.com/data"))
    .timeout(Duration.ofMillis(300))
    .GET()
    .build();

CompletableFuture<String> result = client
    .sendAsync(request, HttpResponse.BodyHandlers.ofString())
    .thenApply(response -> {
        if (response.statusCode() != 200) {
            throw new CompletionException(
                new IllegalStateException("HTTP " + response.statusCode()));
        }
        return response.body();
    })
    .orTimeout(350, TimeUnit.MILLISECONDS);

这里的 350 毫秒是局部 future 的等待上限,并没有包含进入这个片段之前的排队时间。orTimeout 改变 future 的完成状态,不提供对任意底层任务的停止保证;CompletableFuture.cancel 也不能被理解为一定中断正在执行的计算。准确契约见 CompletableFuture 文档 。

因此,需要同时观测逻辑请求是否超时,以及实际网络、连接、线程和缓冲区何时被释放。调用方已经返回之后,迟到结果应被丢弃或按约定处理,不能再修改同一次请求的响应状态。

回调运行在哪个线程也属于设计

没有 Async 后缀的回调可能由完成上一步的线程执行。如果回调里做重计算、同步 I/O 或获取竞争激烈的锁,就可能拖慢负责完成请求的线程。确实需要耗时处理时,使用显式、容量受控的执行器,并监控其排队时间。

同样,不要认为换成 thenApplyAsync 就自动隔离了依赖。未指定执行器的异步方法可能使用公共线程池;一个模块塞入大量阻塞任务,会影响其他使用者。不同任务类型的资源边界应在代码中看得见。

重试先回答“重复执行会怎样”

网络超时只说明调用方没及时拿到结果,不能证明服务端没执行。读请求通常更容易重试;扣款、创建订单、追加记录等写请求需要稳定幂等键,并由服务端保存处理结果。客户端每次重试重新生成键,会让幂等机制失效。

重试还要分错误:参数错误通常应直接失败,限流需要尊重服务端节奏,短暂网络故障才可能适合有限重试。退避与随机抖动可以减少客户端同时重试造成的尖峰,但不能创造下游容量。工程依据见 AWS 关于超时、重试与抖动的说明 。

我会把策略写成四个约束:总截止时间、最大尝试次数、单次调用上限和全局重试预算。下一次退避加最小执行时间超过剩余预算时,立即停止。重试应选定一个负责层,避免 SDK、服务包装和入口各重试三次,组合出意外的请求放大。

用故障验证生命周期

测试服务端延迟响应、连接建立失败、响应中断、限流,以及服务端成功但客户端超时。观察最终错误分类、实际请求次数、在途请求数量和资源回收时间。对写请求,再核对业务效果是否只发生一次。

一个可靠的异步客户端,应能解释每次调用为何结束、底层工作是否仍在执行、重试是否安全,以及总共消耗了多少预算。只看到 future 很快返回,还不足以证明系统已经摆脱了那次慢调用。

References

← 继续阅读专题回到顶部 ↑