最后更新:2026-10-29
适用场景:JDK 17/21 自带 HttpClient,调用外部 HTTP 服务时超时或持续等待

接口日志里只有一个“调用第三方超时”,信息还不够。先确认它是客户端抛了超时异常,还是已经收到了一个 HTTP 504;再分清是建立连接慢、等待响应慢,还是业务线程一直没等到异步任务完成。这几个位置的超时配置,不能互相替代。

先把失败发生的位置记下来

同一笔调用至少记录目标服务、调用开始时间、耗时、异常类型、重试次数,以及能关联上下游的请求 ID。地址里含有敏感查询参数时要做脱敏,不要把鉴权头和完整请求体一起写进错误日志。

看到的结果能说明什么
HttpConnectTimeoutException建立连接没有在相应限制内完成,继续查连接路径
HttpTimeoutException请求等待触发超时,需要结合调用阶段和下游日志
已收到 HTTP 504某个网关返回了超时响应,不等于 Java 客户端自身超时
CompletableFuture 的等待超时调用方不再等待,不自动证明远端业务已经取消

证书、域名解析和连接拒绝等错误,也不应全部归为“读超时”。如果实际收到的是网关错误,可对照接口返回 502/504 的排查步骤,先确认哪一层给出的状态码。

建连超时和请求超时分开设

JDK HttpClient 的 connectTimeout 只在需要新建连接时起作用;复用已有连接时,它不会重新开始一段连接倒计时。请求级的 HttpRequest.Builder.timeout 则是另一项设置。官方 API 分别说明了两者的语义,不能只配其中一个就认为覆盖了整个业务链路。

下面是一个可独立保存为 HttpTimeoutProbe.java 的最小示例。它使用 JDK 自带客户端,不依赖 Spring;这里的两秒和五秒只作演示,不是生产环境统一标准:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

public class HttpTimeoutProbe {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();

    public static void main(String[] args) {
        URI uri = URI.create(args.length == 0
                ? "https://example.com/" : args[0]);
        HttpRequest request = HttpRequest.newBuilder(uri)
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();
        long started = System.nanoTime();
        try {
            HttpResponse<String> response = CLIENT.send(
                    request, HttpResponse.BodyHandlers.ofString());
            System.out.println("status=" + response.statusCode());
        } catch (HttpConnectTimeoutException e) {
            System.err.println("connect timeout");
        } catch (HttpTimeoutException e) {
            System.err.println("request timeout");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("interrupted");
        } catch (IOException e) {
            System.err.println("I/O error=" + e.getClass().getSimpleName());
        } finally {
            System.out.println("elapsedMs="
                    + (System.nanoTime() - started) / 1_000_000);
        }
    }
}

在装有 JDK 17 或 21 的测试环境中编译运行:

javac HttpTimeoutProbe.java
java HttpTimeoutProbe https://example.com/

HttpConnectTimeoutException 是 HttpTimeoutException 的子类,所以示例先捕获建连超时。示例中的 ofString() 适合小型测试响应,大文件或流式响应需要另行设计消费方式、资源释放和时间限制。

业务里通常应复用客户端,而不是每次请求重新创建一套连接资源。这里也没有展示自动重试,避免把“定位失败”悄悄变成了“连续调用多次”。HttpClient.Builder 文档和HttpRequest.Builder 文档可用于核对配置。

用同一网络路径做对照

在应用实例所在网络里,可以先用 curl 观察一个受控测试地址。下面命令使用 Bash:

curl -sS -o /dev/null --connect-timeout 2 --max-time 5 \
  -w 'code=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
  'https://example.com/'

这些时间大多是从请求开始累计到相应阶段的时间点,不应直接把每个字段都当成互不重叠的阶段耗时。查看 curl 退出状态和 HTTP 状态码,别只看输出里有几个小数。

对照实际业务时,还应保持方法、必要请求头、代理路径与目标地址一致。curl 使用的代理、证书信任和解析行为未必与 JVM 相同,因此 curl 成功只能帮助缩小范围,不能直接证明 Java 客户端配置正确。

如果下游根本没有对应请求,继续查 DNS、代理、TLS、连接建立和路由;如果下游已经收到请求,则把双方日志按请求 ID 对齐,查它停在数据库、其他依赖还是响应发送阶段。

给整个调用链留出明确的时间预算

连接等待、请求等待、连接池排队、重试间隔、响应体读取和业务处理,并不一定受同一个开关控制。尤其是流式响应与异步任务,不能把某个请求超时参数当作整个业务的总期限。还要核对上游网关允许等待多久,避免外层早已返回错误,内层仍持续堆积请求。

超时发生后,远端可能已经执行了部分甚至全部操作。下单、扣款和发送消息等请求,在没有幂等协议或结果查询机制时不能盲目重试;即使允许重试,也应有上限、退避和可观测的尝试次数。

最后用原来的失败路径复测,比较总耗时、错误率、实际下游调用次数与线程占用。把五秒改成五十秒,日志里少了超时,不代表系统的等待已经消失。

参考资料

JDK 21:HttpClient
JDK 21:HttpClient.Builder
JDK 21:HttpRequest.Builder
curl:命令行参数

更新记录

2026-10-29:创建文章,补充 JDK HttpClient 示例、超时分层和重试边界。