一次 HTTP 调用超时了,对方到底执行了没有
给 JDK
HttpClient设了 2 秒的请求超时,响应体卡住时它照样等了 5 秒;另一次调用在 2 秒时超时了,服务端却已经把名额预留好了,客户端一重试,又预留了一个。
远程调用的超时通常写成一两个配置项,但一次调用要经过好几个阶段:建立 TCP 连接、TLS 握手、发送请求、等服务端处理完返回响应头、读取响应体。每个超时只管其中一段。哪一段没人管,调用就可能卡得比预期久;失败发生在哪一段,决定了重试是否安全。
本文用 JDK 21 自带的 java.net.http.HttpClient 逐段验证。实验在容器里同时运行一个可控的服务端,让它分别卡在上面每个阶段,并在 JDK 21.0.12 和 25.0.4 上各跑一遍,两个版本的结果相同,见文末配套实验。
一、一次调用的五个阶段
图中最重要的是中间那条竖线:请求在 TLS 握手完成之后才发出。竖线左边失败,服务端根本没有收到请求;竖线右边失败,服务端可能已经执行完了,只是响应没回来。前者可以直接重试,后者不行。
二、两个超时各管哪一段
实验里 connectTimeout 设为 1 秒,请求 timeout 设为 2 秒,服务端在每个阶段都卡 5 秒:
| 卡住的阶段 | 结果 | 耗时 |
|---|---|---|
| 端口没有监听 | ConnectException | 约 20 ms |
| SYN 没有回应 | HttpConnectTimeoutException | 1.0 秒 |
| TCP 已连上,TLS 握手没有回应 | HttpConnectTimeoutException | 1.0 秒 |
| 响应头迟迟不发 | HttpTimeoutException | 2.0 秒 |
响应头迟迟不发,不设请求 timeout | 等到服务端响应 | 5.0 秒 |
| 响应头已到,响应体发到一半停住 | 等到服务端发完,HTTP 200 | 5.0 秒 |
另外两组只设请求 timeout、不设 connectTimeout:SYN 没有回应和 TLS 握手卡住,都在 2 秒时抛出 HttpConnectTimeoutException。
由此可以得到这几条结论:
connectTimeout管到 TLS 握手结束。对 HTTPS 来说,握手完成才算连接建立。它只在需要新建连接时生效,复用连接池里的连接时不起作用。- 请求
timeout从发起调用开始计时,到收到响应头为止。没有设connectTimeout时,建连和握手也由它管。 - 响应体两个超时都不管。服务端发完响应头后卡住(慢查询在流式输出、下游在分块返回),调用会一直等下去。
- 不设请求
timeout就是永久等待,官方文档也明确写了这一点。HttpClient默认两个超时都不设,建连时只受操作系统 TCP 重传次数的限制。
要给整次调用设上限,得在外面自己加一个截止时间:
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 秒时超时了。
客户端看到的是 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 对得上。换客户端时,先按它自己的文档核对每个配置项管哪一段,最好再用本文的办法让服务端卡在各个阶段测一次。
连接建立之前的几类失败(解析失败、无路由、邻居解析失败、连接被拒绝)在客户端的样子,见 请求失败时,先看它停在了哪一步。
配套实验
- codesphere-labs/distributed/http-timeout-layers:可控服务端在建连、TLS 握手、响应头、响应体各阶段卡住时
HttpClient的表现,只设请求超时的两个场景,服务端已提交后带与不带幂等键的重试;JDK 21 与 25 各一遍(验证记录)
参考资料