Skip to content

一次 HTTP 调用超时了,对方到底执行了没有 ​

给 JDK HttpClient 设了 2 秒的请求超时,响应体卡住时它照样等了 5 秒;另一次调用在 2 秒时超时了,服务端却已经把名额预留好了,客户端一重试,又预留了一个。

远程调用的超时通常写成一两个配置项,但一次调用要经过好几个阶段:建立 TCP 连接、TLS 握手、发送请求、等服务端处理完返回响应头、读取响应体。每个超时只管其中一段。哪一段没人管,调用就可能卡得比预期久;失败发生在哪一段,决定了重试是否安全。

本文用 JDK 21 自带的 java.net.http.HttpClient 逐段验证。实验在容器里同时运行一个可控的服务端,让它分别卡在上面每个阶段,并在 JDK 21.0.12 和 25.0.4 上各跑一遍,两个版本的结果相同,见文末配套实验。

一、一次调用的五个阶段 ​

TCP 建连SYNTLS 握手发送请求等待响应头服务端处理、提交读取响应体两个超时都不管connectTimeout请求 timeout(不设 connectTimeout 时也管建连)整次调用的截止时间:sendAsync().get(截止时间) 后 cancel(true)← 失败时请求还没发出:可以直接重试失败时服务端可能已经执行:重试要带幂等键 →
图 1 · JDK HttpClient 实测:connectTimeout 管到 TLS 握手结束;请求 timeout 从发起调用开始,到收到响应头为止;响应体卡住时两者都不起作用,只能靠自己加的整次调用截止时间。以请求发出为界,左边的失败可以放心重试,右边的失败不知道服务端有没有执行

图中最重要的是中间那条竖线:请求在 TLS 握手完成之后才发出。竖线左边失败,服务端根本没有收到请求;竖线右边失败,服务端可能已经执行完了,只是响应没回来。前者可以直接重试,后者不行。

二、两个超时各管哪一段 ​

实验里 connectTimeout 设为 1 秒,请求 timeout 设为 2 秒,服务端在每个阶段都卡 5 秒:

卡住的阶段结果耗时
端口没有监听ConnectException约 20 ms
SYN 没有回应HttpConnectTimeoutException1.0 秒
TCP 已连上,TLS 握手没有回应HttpConnectTimeoutException1.0 秒
响应头迟迟不发HttpTimeoutException2.0 秒
响应头迟迟不发,不设请求 timeout等到服务端响应5.0 秒
响应头已到,响应体发到一半停住等到服务端发完,HTTP 2005.0 秒

另外两组只设请求 timeout、不设 connectTimeout:SYN 没有回应和 TLS 握手卡住,都在 2 秒时抛出 HttpConnectTimeoutException。

由此可以得到这几条结论:

  • connectTimeout 管到 TLS 握手结束。对 HTTPS 来说,握手完成才算连接建立。它只在需要新建连接时生效,复用连接池里的连接时不起作用。
  • 请求 timeout 从发起调用开始计时,到收到响应头为止。没有设 connectTimeout 时,建连和握手也由它管。
  • 响应体两个超时都不管。服务端发完响应头后卡住(慢查询在流式输出、下游在分块返回),调用会一直等下去。
  • 不设请求 timeout 就是永久等待,官方文档也明确写了这一点。HttpClient 默认两个超时都不设,建连时只受操作系统 TCP 重传次数的限制。

要给整次调用设上限,得在外面自己加一个截止时间:

java
HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofMillis(500))       // 建连 + TLS 握手
        .build();
HttpRequest request = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofSeconds(2))               // 从发起到收到响应头
        .POST(body)
        .build();

CompletableFuture<HttpResponse<String>> call = client.sendAsync(request, BodyHandlers.ofString());
try {
    return call.get(3, TimeUnit.SECONDS);             // 整次调用的截止时间,包括读完响应体
} catch (TimeoutException e) {
    call.cancel(true);                                // 放弃这次调用
    throw new CallDeadlineExceeded(uri, e);
}

实测响应体卡住时,这样写会在 3 秒准时返回。示例省略了 ExecutionException 的处理;参数都是演示值,实际取值见第四节。

三、超时之后,服务端可能已经做完了 ​

报名服务调用库存服务预留名额。库存服务收到请求,预留成功并提交了事务,然后因为别的原因(写日志阻塞、GC 停顿、网络抖动)3 秒后才返回响应。客户端在 2 秒时超时了。

客户端服务端POST /reserve预留名额已提交2 秒超时重试(1.5 秒后)无幂等键:再预留一个有幂等键:返回第一次的结果第一次的响应 3 秒才发出,客户端已经放弃
图 2 · 服务端预留名额后 3 秒才响应,客户端 2 秒超时、1.5 秒后重试。客户端两次看到的都是超时,但服务端第一次已经执行:不带幂等键时重试又预留了一个名额;带幂等键时服务端认出是同一次调用,直接返回第一次的结果

客户端看到的是 HttpTimeoutException,它没办法区分「对方没执行」和「对方执行了,但响应没回来」。实测等 1.5 秒后重试一次:

重试方式客户端看到的结果服务端实际预留
不带幂等键两次都超时2 次
带幂等键(Idempotency-Key 请求头)第一次超时,重试拿到第一次的结果1 次

不带幂等键时,服务端把重试当成一个新请求,又预留了一个名额;而客户端两次都没收到成功响应,可能还会继续重试。带上幂等键后,服务端认出这是同一次调用,直接返回第一次的结果。

幂等键要由调用方在第一次发出请求之前生成,每次重试都带同一个。被调用方要把幂等键和业务写入放在同一个事务里记录,重复的键返回上一次的结果。具体实现和并发下的边界见 可复用的服务端组件 中的幂等键实验;订单场景的唯一键约束见 订单、库存与数据一致性。

四、超时值和重试怎么定 ​

按阶段决定能不能重试。 结合第一节的竖线:

失败请求是否已发出重试
ConnectException、HttpConnectTimeoutException没有可以直接重试
HttpTimeoutException、读响应体时超时或断开已发出只在接口幂等或带幂等键时重试
收到 5xx已发出同上,并且要看服务端的错误语义

这张表只适用于新建连接的 HTTP/1.1 请求。连接复用对 DNS 切换也有影响,见 DNS 改了,为什么 Java 服务还在连旧地址。复用连接时,请求可能写到一个已经被对端关闭的连接上,失败发生在请求发出之后,要按第二行处理。

连接超时要短。 建连阶段失败,通常意味着目标不可达或者过载。TCP 丢了 SYN 之后要等重传,RFC 6298 规定的初始重传超时是 1 秒,之后指数增长,等得越久越不可能成功,不如尽快换一个实例。

请求超时要小于调用方剩下的预算。 如果上游给这次请求的总时间是 3 秒,已经花掉 1 秒,下游调用就不能再设 3 秒。一条调用链上每一层的超时都应该比上一层短,否则上游已经放弃了,下游还在白白干活。多层调用时超时怎样逐层递减、截止时间怎样往下传,见 每层都重试三次,最底下的服务收到二十七倍请求。

整次调用的截止时间要覆盖响应体。 只设请求 timeout,响应体卡住时没有任何保护。响应体大、可能流式返回的接口,尤其要在外面加截止时间。

重试次数和间隔要有上限。 超时往往说明对方已经过载,立刻重试会让压力翻倍。重试之间要有退避,次数要少,并且整个重试过程也要落在调用方的截止时间之内。多层都重试时次数会相乘,实测见重试放大那篇。

HttpClient 之外的客户端(OkHttp、Apache HttpClient、Spring 的 RestClient 背后的各种实现)也把超时分成建连、读取、整次调用几类,但名字和覆盖范围不一定和 HttpClient 对得上。换客户端时,先按它自己的文档核对每个配置项管哪一段,最好再用本文的办法让服务端卡在各个阶段测一次。

连接建立之前的几类失败(解析失败、无路由、邻居解析失败、连接被拒绝)在客户端的样子,见 请求失败时,先看它停在了哪一步。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权