Skip to content

请求失败时,先看它停在了哪一步 ​

同样是 2 秒后报「HTTP connect timed out」,可能是防火墙丢了包,也可能是目标 IP 根本不存在;同样是一条 ConnectException,JDK HttpClient 的异常里可能连「Connection refused」这几个字都没有。排查网络问题,第一步是判断请求停在了哪一关,而不是先调超时参数。

一个 HTTP 请求在拿到响应之前,要依次过几关:把域名解析成 IP,查路由表找到下一跳,把同网段的 IP 解析成 MAC 地址,完成 TCP 握手,最后才是 HTTP 本身。每一关失败,客户端看到的异常、等待的时间都不一样。

本文在一个自建的 Docker 网络里把这几种失败逐个造出来,用 JDK 21 的 HttpClient 和普通 Socket.connect 各访问一次,看它们分别报什么、等多久。只改了客户端容器自己网络命名空间里的路由和防火墙规则,不涉及任何真实地址,见文末配套实验。

DNS 解析名字 → IP查路由表下一跳在哪邻居解析同网段 IP → MACTCP 握手SYN / SYN-ACKHTTP请求与响应无人回应SYN 被丢弃解析失败立即No route to host立即No route to host约 3 秒Connection refused立即HttpConnectTimeoutException等满连接超时503 等状态码不是异常「立即失败」和「等超时」是第一个分辨点:前者告诉你失败在哪一关,后者只告诉你没有等到回应
图 1 · 请求依次经过域名解析、路由查找、邻居解析、TCP 握手,最后才到 HTTP。越靠前的失败越快返回;邻居解析失败要等内核重试约 3 秒,SYN 被丢弃要等满连接超时;HTTP 5xx 是状态码,不是异常

一、七种情况在客户端的样子 ​

服务端 8080 端口的 /ok 返回 200、/fail 返回 503,9090 端口没有进程监听。客户端命名空间里加了一条 unreachable 10.201.0.0/16 路由,并用 iptables 丢弃发往服务端 8081 端口的 SYN。HttpClient 的连接超时设为 2 秒,Socket.connect 的超时设为 10 秒:

场景JDK HttpClientSocket.connect
正常HTTP 200连接成功
服务返回 503HTTP 503,不抛异常连接成功
域名不存在ConnectException ← UnresolvedAddressException,立即UnknownHostException,立即
目标网段没有路由ConnectException(No route to host),立即NoRouteToHostException,立即
同网段不存在的主机HttpConnectTimeoutException,约 2.0 秒NoRouteToHostException,约 3.1 秒
端口无人监听ConnectException ← ClosedChannelException,立即ConnectException(Connection refused),立即
SYN 被防火墙丢弃HttpConnectTimeoutException,约 2.0 秒SocketTimeoutException(Connect timed out),约 10.0 秒

表里「立即」都在 50 毫秒以内。

端口无人监听< 50 ms< 50 ms无路由< 50 ms< 50 ms域名不存在< 50 ms< 50 ms邻居解析失败2.0 秒3.1 秒SYN 被丢弃2.0 秒10.0 秒HttpClient 连接超时 2 秒HttpClientSocket.connect
图 2 · 同样的五种失败,JDK HttpClient(连接超时 2 秒)与 Socket.connect(超时 10 秒)的耗时。邻居解析失败在 HttpClient 里被 2 秒的连接超时截断,和 SYN 被丢弃看起来一样;Socket 等到了内核约 3 秒后返回的 No route to host

二、怎么读这些信号 ​

先看是立即失败,还是等满了超时。 立即失败说明某一关明确拒绝了请求,异常本身就告诉你是哪一关:解析不了、没有路由、端口没人听。等满超时说明请求发出去了,但没有等到任何回应,异常只能告诉你「没等到」,说不出原因。

「连接超时」背后至少有两种原因。 访问一个同网段但不存在的地址,内核要先用 ARP 问「这个 IP 的 MAC 是多少」。没有主机回答,内核默认广播 3 次、每次间隔 1 秒,约 3 秒后放弃,返回「No route to host」,Socket.connect 实测 3.1 秒拿到这个错误。但 HttpClient 的连接超时是 2 秒,比内核先到,于是它报的是 HttpConnectTimeoutException,和 SYN 被防火墙丢弃完全一样。只看异常分不出来,要看邻居表:

text
$ ip neigh show 172.31.7.99
172.31.7.99 dev eth0 FAILED

FAILED 说明这个 IP 在本网段里没有人应答,问题在地址本身:服务迁走了、配置里写错了 IP、目标机器已经下线。邻居表里有正常的 MAC 记录却连不上,才更可能是防火墙或安全组。

HttpClient 的异常信息比系统调用少。 端口无人监听时,内核返回的是「Connection refused」,Socket.connect 原样报出来;JDK HttpClient 的异常链是 ConnectException ← ClosedChannelException,整条链里都找不到「refused」。域名解析失败也一样,HttpClient 报的是 UnresolvedAddressException,而不是大家熟悉的 UnknownHostException。按异常消息做告警分类或重试判断时,要按实际使用的客户端测一遍,不能照搬别的客户端的消息文本。

503 不是异常。 服务返回 5xx 时,连接、握手、请求、响应全部成功,HttpClient 正常返回一个状态码为 503 的响应。只在 catch 里记录失败的代码,会把这类失败完全漏掉。

三、排查顺序 ​

按请求经过的顺序,从前往后查,每一步都有对应的命令:

这一关命令失败时看到
域名解析getent hosts <域名>、dig <域名>没有结果,或解析到了意外的地址
路由ip route get <IP>RTNETLINK answers: Host is unreachable,或走了意外的网卡
邻居解析ip neigh show <IP>FAILED、INCOMPLETE
TCP 握手curl -v --connect-timeout 2 http://<IP>:<端口>/Connection refused 立即返回,或超时
抓包确认tcpdump -ni eth0 host <IP>只有 SYN 没有回应;或收到 RST

两个容易误导人的地方:

  • ping 通不代表服务正常。 实测 ping 服务端 2 个包全部收到,同时 curl 连它没有进程监听的 9090 端口立即失败。ping 只说明 IP 层可达,说明不了端口有没有进程在监听、服务是否健康。
  • 在哪台机器上查很重要。 路由、邻居表、防火墙规则都是每个网络命名空间各自一份。容器里的请求失败,要进到这个容器的命名空间里查(nsenter 或 kubectl debug),在宿主机上查到的是另一份表。

四、放到代码里 ​

  • 记录完整的异常链。 只记最外层的 ConnectException,就丢掉了 UnresolvedAddressException、NoRouteToHostException 这些真正说明问题的信息。
  • 连接超时和请求超时分开设。 连接超时决定「连不上」要等多久,内网建连通常只要毫秒级,可以设得比请求超时短得多;请求超时决定「连上之后」要等多久,按接口本身的耗时定。两者混在一起,会让连不上的请求白白等上完整的请求超时。
  • 连接阶段的失败可以换一个实例重试。 解析失败、没有路由、连接被拒绝、连接超时,都发生在 TCP 连接建立之前,请求还没有发给服务端,换实例重试不会产生重复的业务操作。读超时则不同:请求可能已经被处理了,重试前要先确认幂等,见 一次 HTTP 调用超时了,对方到底执行了没有。
  • DNS 相关的失败,还要考虑缓存。 解析失败的结果同样会被 JVM 缓存,见 DNS 改了,为什么 Java 服务还在连旧地址。

配套实验

参考资料

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