请求失败时,先看它停在了哪一步
同样是 2 秒后报「HTTP connect timed out」,可能是防火墙丢了包,也可能是目标 IP 根本不存在;同样是一条
ConnectException,JDK HttpClient 的异常里可能连「Connection refused」这几个字都没有。排查网络问题,第一步是判断请求停在了哪一关,而不是先调超时参数。
一个 HTTP 请求在拿到响应之前,要依次过几关:把域名解析成 IP,查路由表找到下一跳,把同网段的 IP 解析成 MAC 地址,完成 TCP 握手,最后才是 HTTP 本身。每一关失败,客户端看到的异常、等待的时间都不一样。
本文在一个自建的 Docker 网络里把这几种失败逐个造出来,用 JDK 21 的 HttpClient 和普通 Socket.connect 各访问一次,看它们分别报什么、等多久。只改了客户端容器自己网络命名空间里的路由和防火墙规则,不涉及任何真实地址,见文末配套实验。
一、七种情况在客户端的样子
服务端 8080 端口的 /ok 返回 200、/fail 返回 503,9090 端口没有进程监听。客户端命名空间里加了一条 unreachable 10.201.0.0/16 路由,并用 iptables 丢弃发往服务端 8081 端口的 SYN。HttpClient 的连接超时设为 2 秒,Socket.connect 的超时设为 10 秒:
| 场景 | JDK HttpClient | Socket.connect |
|---|---|---|
| 正常 | HTTP 200 | 连接成功 |
| 服务返回 503 | HTTP 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 毫秒以内。
二、怎么读这些信号
先看是立即失败,还是等满了超时。 立即失败说明某一关明确拒绝了请求,异常本身就告诉你是哪一关:解析不了、没有路由、端口没人听。等满超时说明请求发出去了,但没有等到任何回应,异常只能告诉你「没等到」,说不出原因。
「连接超时」背后至少有两种原因。 访问一个同网段但不存在的地址,内核要先用 ARP 问「这个 IP 的 MAC 是多少」。没有主机回答,内核默认广播 3 次、每次间隔 1 秒,约 3 秒后放弃,返回「No route to host」,Socket.connect 实测 3.1 秒拿到这个错误。但 HttpClient 的连接超时是 2 秒,比内核先到,于是它报的是 HttpConnectTimeoutException,和 SYN 被防火墙丢弃完全一样。只看异常分不出来,要看邻居表:
$ ip neigh show 172.31.7.99
172.31.7.99 dev eth0 FAILEDFAILED 说明这个 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 服务还在连旧地址。
配套实验
- codesphere-labs/network/request-failure-signatures:自建网络里造出七种情况,JDK HttpClient 与
Socket.connect的异常链和耗时,以及ip route get、ip neigh、ping、curl的输出(验证记录)
参考资料
- Linux man-pages:arp(7)(
mcast_solicit默认 3 次,retrans_time_ms默认 1000 毫秒) - Linux man-pages:ip-route(8)(
unreachable路由类型) - Linux man-pages:ip-neighbour(8)
- JDK 21 API:HttpClient.Builder.connectTimeout