2024 年我们一个对外调用很频繁的服务,出过一次诡异的故障:服务自身的接口逻辑很简单,可它调用第三方 API 的耗时,平时几十毫秒,高峰期却会莫名其妙飙到一两秒。更奇怪的是,运维同学反馈这台机器上 TIME_WAIT 状态的 TCP 连接堆了好几万个,端口几乎被耗尽。一开始我们怀疑是第三方慢,联系对方,对方说他们那边一切正常。排查了很久才定位到根子——我们的 HTTP 客户端用得太随意了,每发一次请求就新建一个连接、用完就扔,连接池形同虚设,大量连接在 TIME_WAIT 里堆积。投了几天把所有 HTTP 客户端的用法梳理了一遍,本文复盘这次实战。
问题背景
业务:聚合服务,需要频繁调用多个第三方 HTTP API
事故现象:
- 调第三方 API 耗时,平时 30-50ms,高峰飙到 1-2s
- 机器上 TIME_WAIT 连接数堆到 4 万+
- 偶尔报 "Cannot assign requested address"(端口耗尽)
现场排查:
# 1. 看 TCP 连接状态分布
$ netstat -an | awk '/^tcp/{print $6}' | sort | uniq -c
41203 TIME_WAIT <- 4 万多个 TIME_WAIT!
312 ESTABLISHED
88 CLOSE_WAIT
# 2. 看 HTTP 客户端代码
public String callApi(String url) {
// 每次调用都 new 一个 HttpClient!
CloseableHttpClient client = HttpClients.createDefault();
HttpGet get = new HttpGet(url);
CloseableHttpResponse resp = client.execute(get);
return EntityUtils.toString(resp.getEntity());
// client 没关、resp 没关
}
# 3. 问题链条:
# - 每次请求 new 一个 client -> 每次都新建 TCP 连接
# - 用完连接直接断开 -> 主动关闭方进入 TIME_WAIT(默认 60s)
# - 高 QPS 下,TIME_WAIT 来不及释放就堆积,最终端口耗尽
根因:
1. 没有复用连接:每次请求新建 client、新建 TCP 连接
2. 每次新建连接 = 每次都付 TCP 三次握手 + TLS 握手的开销 -> 慢
3. 连接用完即弃,大量连接积压在 TIME_WAIT
4. response/client 没关闭,还存在连接泄漏
修复 1:为什么必须用连接池
=== 一次"新建连接"的 HTTP 请求,要付出多少代价 ===
1. DNS 解析(若没缓存)
2. TCP 三次握手:1 个 RTT
3. TLS 握手(HTTPS):2 个 RTT(TLS 1.2)
4. 发送 HTTP 请求 + 等待响应
5. TCP 四次挥手关闭连接
跨机房 RTT 按 30ms 算,光握手就要 30 * 3 ≈ 90ms,
而真正的业务处理可能才 20ms —— 握手开销远大于干活本身。
=== 连接池做了什么 ===
连接池把"用完的连接"留下来,下次请求直接复用:
- 复用连接 = 跳过 TCP 握手 + TLS 握手,直接发请求
- 一条连接服务成千上万次请求,握手成本被极度摊薄
=== 关于 TIME_WAIT ===
TCP 连接【主动关闭方】关闭后,会进入 TIME_WAIT 状态,
默认持续 2*MSL(Linux 上约 60s)才真正释放。
每次新建-关闭连接,就产生一个 TIME_WAIT。
高 QPS 下不复用连接 -> TIME_WAIT 海量堆积 -> 端口耗尽。
=== 结论 ===
任何"频繁调用"的 HTTP 场景,客户端都必须:
1. 全局复用同一个 client 实例(client 内部带连接池)
2. 让连接池真正复用连接,而不是用完就断
这不是优化项,是必备项。
修复 2:连接池的正确配置
// === 错误:每次请求 new 一个 HttpClient ===
// CloseableHttpClient client = HttpClients.createDefault(); // 错!
// === 正确:全局单例 client + 连接池管理器 ===
// Apache HttpClient 5
@Bean
public CloseableHttpClient httpClient() {
PoolingHttpClientConnectionManager cm =
PoolingHttpClientConnectionManagerBuilder.create()
// 连接池总连接数上限
.setMaxConnTotal(500)
// 每个目标路由(host:port)的最大连接数
.setMaxConnPerRoute(50)
.build();
return HttpClients.custom()
.setConnectionManager(cm)
// 连接保活策略,定期清理失效连接
.evictIdleConnections(TimeValue.ofSeconds(30))
.evictExpiredConnections()
.build();
// 这个 client 全局复用,不要每次 new
}
// === 两个关键参数的含义 ===
// maxConnTotal :整个连接池最多维持多少连接
// maxConnPerRoute:对【单个目标地址】最多用多少连接
//
// 坑:maxConnPerRoute 默认只有 2!
// 如果你的服务高并发地只调一个第三方 host,
// 默认配置下最多只有 2 条连接能并行,其他请求全在排队等连接,
// "调用变慢"就是这么来的 —— 不是第三方慢,是卡在等连接。
// 解法:按"对该 host 的并发量"把 maxConnPerRoute 调到合适值。
// === OkHttp 的连接池(同样要全局单例)===
OkHttpClient client = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(
100, // 最大空闲连接数
5, TimeUnit.MINUTES)) // 空闲连接存活时间
.build();
// OkHttpClient 设计上就该全局单例,它内部自带连接池和线程池。
修复 3:连接泄漏 —— response 必须关闭
// === 坑:response / entity 没有关闭,连接无法归还连接池 ===
// 一个连接借出去用完,必须"归还"给连接池才能被下次复用。
// 归还的动作,是通过【关闭 response】触发的。
// 忘记关 -> 连接一直被占着 -> 连接池很快被占满 -> 后续请求全部等待。
// === 错误写法 ===
public String callApi(String url) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse resp = httpClient.execute(get); // 借出连接
return EntityUtils.toString(resp.getEntity());
// resp 没关!这条连接永远归还不了连接池
}
// === 正确写法:try-with-resources 确保关闭 ===
public String callApi(String url) {
HttpGet get = new HttpGet(url);
try (CloseableHttpResponse resp = httpClient.execute(get)) {
// 必须完整消费掉 entity 内容,连接才能干净归还
return EntityUtils.toString(resp.getEntity());
} catch (IOException e) {
throw new ApiException("调用失败: " + url, e);
}
// try 块结束,resp 自动 close,连接归还连接池供下次复用
}
// === OkHttp 同样:response.body() 必须关闭 ===
public String callApiOkHttp(String url) {
Request request = new Request.Builder().url(url).build();
try (Response response = okHttpClient.newCall(request).execute()) {
return response.body().string(); // body 读取后随 try 关闭
} catch (IOException e) {
throw new ApiException("调用失败", e);
}
}
// OkHttp 的坑:如果只 execute 不读 body、不 close,
// 连接会泄漏。response 一定要 close,或完整读完 body。
// === 排查连接泄漏 ===
// 现象:连接池"用着用着就满了",请求全卡在获取连接。
// 开 HttpClient 的 DEBUG 日志,能看到连接借出/归还,
// 借出多、归还少,就是泄漏。
// logging.level.org.apache.hc.client5=DEBUG
修复 4:超时配置 —— 一个都不能少
// === HTTP 调用有三个独立的超时,必须全配 ===
RequestConfig requestConfig = RequestConfig.custom()
// 1. 连接超时:建立 TCP 连接的最长时间
.setConnectTimeout(Timeout.ofMilliseconds(2000))
// 2. 响应超时(socket 读超时):
// 连接建立后,等待数据返回的最长时间
.setResponseTimeout(Timeout.ofMilliseconds(5000))
// 3. 从连接池获取连接的超时:
// 连接池没有空闲连接时,等多久就放弃
.setConnectionRequestTimeout(Timeout.ofMilliseconds(1000))
.build();
// === 为什么三个超时都重要 ===
// 不配 connectTimeout:对端机器宕机 / 网络不通时,
// 连接会一直 hang(系统默认可能几十秒甚至更久),线程被占死
// 不配 responseTimeout:对端接受了连接但迟迟不返回数据,
// 你的线程就一直等 -> 这正是事故里"高峰飙到 1-2s"的元凶之一
// 不配 connectionRequestTimeout:连接池被占满时,
// 请求会无限期等待空闲连接,看起来就是"卡住了"
// === 一个血的教训:没有超时的调用 = 定时炸弹 ===
// 下游某次抽风,响应变慢,你的线程因为没有超时全部被它拖住,
// 线程池耗尽,你的服务也跟着挂 —— 故障就这样传染过来。
// 所有跨网络调用,必须设置有限的、合理的超时。
// === 超时值怎么定 ===
// 参考下游接口的 P99 耗时,留一定余量,但别留太多。
// 比如下游 P99 是 200ms,responseTimeout 设 1s 比较合理 ——
// 既容忍正常抖动,又能在下游真出问题时快速失败。
修复 5:连接保活与失效连接处理
// === 坑:连接池里的"陈旧连接"——拿出来已经是死的 ===
// 连接在池里闲置时,对端(或中间的网关/LB)可能已经把它关了,
// 但连接池这边还以为它是好的。
// 下次借这条"假活"的连接发请求 -> 直接失败
// -> NoHttpResponseException / SocketException。
// === 解法 1:定期清理空闲连接和过期连接 ===
HttpClients.custom()
.setConnectionManager(cm)
.evictIdleConnections(TimeValue.ofSeconds(30)) // 闲置 30s 的连接清掉
.evictExpiredConnections() // 清掉已过期连接
.build();
// === 解法 2:连接存活时间不要超过对端的 keep-alive ===
// 服务端通常会在响应头里告知 Keep-Alive 超时,
// 客户端连接的存活时间应 <= 服务端的 keep-alive 时间,
// 否则就会用到被服务端单方面关闭的连接。
ConnectionKeepAliveStrategy keepAliveStrategy = (response, context) -> {
// 解析服务端返回的 Keep-Alive 头,据此决定连接保活多久
// 没有就给一个保守的默认值,比如 30s
return TimeValue.ofSeconds(30);
};
// === 解法 3:对幂等请求做有限重试 ===
// 万一真拿到一条失效连接,对 GET 这类幂等请求可以重试一次。
// ⚠ 非幂等请求(POST 下单/支付)绝对不能盲目重试 ——
// 可能导致重复下单,重试要配合幂等设计。
// === HTTP keep-alive 是什么 ===
// keep-alive 让一条 TCP 连接在一次请求-响应后【不立即关闭】,
// 而是保留下来给后续请求复用。
// 连接池能复用连接的前提,就是 keep-alive 开着(HTTP/1.1 默认开)。
// 如果请求/响应头里出现 Connection: close,连接用完就会被关,
// 连接池就失去了复用能力 —— 排查时也要留意这个头。
修复 6:HTTP 客户端监控
# 连接池和外部调用是重点监控对象
groups:
- name: http-client
rules:
# 1. 连接池使用率过高(连接快不够用了)
- alert: HttpPoolNearExhausted
expr: http_pool_leased_connections / http_pool_max_connections > 0.85
for: 5m
annotations:
summary: "HTTP 连接池使用率 > 85%,排查泄漏或调大 maxConnPerRoute"
# 2. 获取连接等待时间过长(池里没有空闲连接)
- alert: HttpPoolWaitHigh
expr: http_pool_pending_connections > 50
for: 3m
annotations:
summary: "大量请求在等待连接,连接池容量不足或有连接泄漏"
# 3. 外部调用错误率
- alert: HttpClientErrorRate
expr: rate(http_client_requests_errors_total[5m])
/ rate(http_client_requests_total[5m]) > 0.05
for: 5m
annotations:
summary: "{{ $labels.uri }} 外部调用错误率 > 5%"
# 4. 外部调用延迟过高
- alert: HttpClientLatencyHigh
expr: http_client_requests_seconds{quantile="0.99"} > 2
for: 5m
annotations:
summary: "{{ $labels.uri }} 外部调用 P99 > 2s"
# 5. TIME_WAIT 连接数过高(连接没被复用的信号)
- alert: TimeWaitHigh
expr: node_sockstat_TCP_tw > 20000
for: 5m
annotations:
summary: "TIME_WAIT 连接 > 2 万,排查 HTTP 客户端是否复用连接"
优化效果
指标 治理前 治理后
=============================================================
HttpClient 实例 每次请求 new 全局单例 + 连接池
连接复用 完全不复用 复用率 98%+
调第三方 API P99 高峰 1-2s 40-90ms
TCP 握手开销 每次都付 几乎为 0(复用连接)
TIME_WAIT 连接数 4 万+ 稳定 < 2000
端口耗尽报错 偶发 0
maxConnPerRoute 默认 2(瓶颈) 按并发设为 50
response 关闭 经常忘关(泄漏) try-with-resources
三个超时 基本没配 连接/响应/取连接全配
HTTP 客户端可观测 无 连接池/延迟/TIME_WAIT 监控
治理过程:
- 定位连接未复用根因:0.5 天
- HttpClient 改全局单例 + 连接池:1 天
- 全量排查 response 泄漏 + try-with-resources:1.5 天
- 超时配置 + 连接保活策略:1 天
- 监控接入 + 压测验证:1 天
避坑清单
- 每次请求 new 一个 HttpClient 是大忌,client 必须全局单例复用
- 新建连接要付 TCP 握手 + TLS 握手代价,跨机房光握手就近百毫秒
- 不复用连接会产生海量 TIME_WAIT,高 QPS 下堆积直至端口耗尽
- maxConnPerRoute 默认只有 2,高并发调单一 host 务必按并发量调大
- response/entity 用完必须关闭,否则连接归还不了连接池,池很快耗尽
- 用 try-with-resources 确保 response 一定被关闭,杜绝连接泄漏
- 连接超时、响应超时、获取连接超时三者必须全配,缺一个就有 hang 死风险
- 没有超时的跨网络调用是定时炸弹,下游一抖动就会把你的线程拖死
- 连接池里的陈旧连接可能已失效,要定期清理空闲/过期连接
- 客户端连接存活时间应小于等于服务端 keep-alive,否则会用到已被关闭的连接
总结
这次故障排查,最让我感慨的是它的"反差感":我们的服务自身代码简单到几乎挑不出毛病,接口逻辑清清爽爽,可它就是会在高峰期慢成那样、就是会把机器的端口耗尽。我们一开始本能地去怀疑第三方,因为"我的代码看起来没问题",但第三方那边一切正常,这就把矛头逼回到了我们自己身上——问题不在业务逻辑里,而在那段我们几乎从不正眼看的、负责把 HTTP 请求发出去的"基础设施代码"里。罪魁祸首,是一个看起来人畜无害的写法:每次要调用第三方,就 new 一个新的 HttpClient。这行代码语法完全正确,功能也完全正确,请求确实发出去了、响应也确实回来了,所以它能在测试环境、在低流量时一直安然无恙地运行。但它隐藏着一个致命的代价:每一个新的 HttpClient 都意味着一条全新的 TCP 连接,而每一条全新的 TCP 连接,都要付出三次握手的成本,如果是 HTTPS 还要再叠加 TLS 握手的成本,跨机房的情况下,光是这些握手就要花掉近百毫秒,而我们真正的业务处理可能才二十毫秒——我们把绝大部分时间花在了"建立连接"这个纯粹的开销上,却没花在干活上。这就是连接池存在的全部意义:它把用过的连接保留下来,下一次请求直接捡起一条现成的连接来用,跳过所有握手,让一条连接的建立成本被后续成千上万次请求摊薄到几乎为零。而我们每次 new client、用完即弃的做法,等于把连接池这个机制彻底架空了。更糟的是,每一条被主动关闭的 TCP 连接,都会在操作系统里进入一个叫 TIME_WAIT 的状态,默认要滞留约六十秒才释放,高 QPS 下连接建了又关、关了又建,TIME_WAIT 就像没人清理的垃圾一样越堆越多,最终堆到把本地端口都占光,于是出现了"无法分配地址"这种听起来匪夷所思的错误。想明白这条因果链,治理的方向就非常清楚了。第一,HttpClient 必须全局单例、长期复用,让它内部的连接池真正运转起来。第二,连接池有一个特别容易被忽略的默认值——对单个目标地址的最大连接数 maxConnPerRoute 默认只有 2,如果你的服务高并发地只调一个第三方,默认配置下永远只有两条连接在干活,其余请求全部排队等连接,这种"慢"看起来像是下游慢,实则是卡在自己的连接池门口。第三,借出去的连接必须能归还,而归还这个动作是靠关闭 response 来触发的,忘记关 response 就是连接泄漏,池子会用着用着就空了。第四,连接、响应、从池里获取连接这三个超时一个都不能少,没有超时的网络调用就是一颗定时炸弹,下游任何一次抽风,都会顺着这条没有超时保护的调用,把你的线程一个个拖死。这次复盘之后我形成了一个习惯:任何涉及"跨网络调用"的代码,我都不会再把它当成一行简单的 client.execute() 一带而过,因为网络这一跳里,藏着握手、连接复用、超时、连接保活这一整套需要认真对待的东西——它平时安静得让你忘记它的存在,但它一旦出问题,就是整台机器的端口耗尽。
—— 别看了 · 2026