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 很快返回,还不足以证明系统已经摆脱了那次慢调用。