调用下游服务的接口慢得离谱、机器上还堆了几万个 TIME_WAIT,我抓包才发现每发一个请求都在重新三次握手加 TLS 握手:一次 HTTP 连接没复用、每次新建连接的深度复盘
那个性能问题是压测时"QPS 怎么也上不去、延迟还高"才暴露的:我写了个服务,要频繁调用下游的一个 HTTP 接口。功能没问题,可一压测就发现:每个请求的耗时里,真正等下游处理的时间很短,但整体延迟却很高;更可疑的是,我上机器一看,netstat 里密密麻麻几万个 TIME_WAIT 状态的连接。我一开始以为是下游慢,直到抓了个包,看清每个请求前后发生了什么,后背发凉:我的代码每发一个 HTTP 请求,都在新建一个全新的 TCP 连接——请求前:TCP 三次握手 + (TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 的)TLS 握手;请求后:立刻关闭连接(四次挥手)。也就是说,每一次调用,大部分时间都耗在了"建立连接、握手"和"关闭连接"上,真正传输数据的时间反而很少;而连接建了又关、关了又建,我方主动关闭的连接大量堆积在 TIME_WAIT 状态(主动关闭方的正常现象,但量大了会耗尽端口/资源)。根因是:我要么每次请求都 new 一个新的 HTTP client、要么用了一个没有配置/启用连接复用(keep-alive、连接池)的 client——导致 TCP 连接用完一次就扔,完全没有复用。问题的根,是没有复用 HTTP 连接:每次请求都付出了昂贵的"建连+握手+关连"开销,而这些开销本可以通过"连接复用(keep-alive)+连接池"摊薄到几乎为零。这篇就把这次"连接没复用、每次新建连接"的坑,从头到尾复盘一遍。
故障现场:每个请求都新建一个连接
问题在于每次请求都新建连接(或用了不复用连接的 client),没有 keep-alive、没有连接池:
// ✗ 出问题的代码: 每次请求都 new 一个 client / 不复用连接
public String callDownstream(String url) {
// ✗ 每次调用都新建一个HttpClient(及其底层连接资源)
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).build();
HttpResponse<String> resp = client.send(req, BodyHandlers.ofString());
return resp.body();
// ✗ client/连接用完就被丢弃, 下次又得从头新建连接 → 每次都三次握手+TLS握手
}
// 或者(另一种常见错误): 用了连接但没启用keep-alive, 响应后连接就关了:
// conn.setRequestProperty("Connection", "close"); // ✗ 明确不复用
// 或用了不带连接池的底层实现, 每请求一连。
// 现象(压测时):
// - 单请求延迟高(大部分耗在建连/握手, 而非下游处理);
// - QPS上不去(连接建立成了瓶颈);
// - 机器上 netstat 显示几万个 TIME_WAIT(我方主动关了大量连接);
// - 严重时: 端口耗尽(本地端口用完)、报"Cannot assign requested address"。
// 为什么慢/为什么堆TIME_WAIT? 每次新建连接的代价:
// 1. TCP三次握手: 建立连接要1个RTT(往返); 跨机房RTT几十ms, 这就白白多几十ms;
// 2. TLS握手(HTTPS): 还要额外1~2个RTT做加密握手 → 又是几十~上百ms;
// 3. 传输数据: 真正的业务数据传输(可能很快);
// 4. 关闭连接: 四次挥手; 主动关闭的一方(我方)进入TIME_WAIT, 要等2MSL(如60s)才释放;
// 5. → 高频请求下, 每次都1+2+4的开销, 且TIME_WAIT连接快速堆积、占用端口和资源。
// 对比: 若复用连接(keep-alive), 一个连接握手一次后, 可连续承载很多请求 → 握手开销被摊薄到≈0。
// 关键: 每次请求新建TCP连接(+TLS握手)用完就关, 把昂贵的建连/握手/关连开销付了无数遍,
// 还堆积大量TIME_WAIT耗尽端口; 而这些本可通过"连接复用(keep-alive)+连接池"避免。
第一次看懂"原来我每个请求都在重新握手、连接用一次就扔"时,我又懊恼又恍然:"我以为发 HTTP 请求就是'把数据发过去、收回来'这么简单,完全没意识到背后建连接、握手的开销有多大,更没想到要去复用它。"这个坑最隐蔽的地方在于:它功能完全正常——每个请求都能正确拿到结果,低频时也察觉不到(慢一点点无所谓);只有在高频/高并发(压测、流量高峰)时,建连开销和 TIME_WAIT 堆积才被放大成严重的性能/资源问题。下面就来拆解,连接复用是怎么回事、该怎么正确配置。
第一件事:搞懂连接复用为什么如此重要
我顺着这次事故,把 HTTP/TCP 连接的建立开销和复用机制彻底理清了。
为什么"每次新建连接"很贵, "复用连接"很重要?
【核心: 建TCP连接(+TLS握手)要多个RTT、很贵; 每请求一建一关, 把这开销付无数遍还堆TIME_WAIT; 复用连接(keep-alive+连接池)把开销摊薄到≈0】
1. 建立一个连接的真实开销(高频下被放大):
- TCP三次握手: 1个RTT(往返时间); 同机房零点几ms, 跨机房/公网几十ms;
- TLS握手(HTTPS): 额外1~2个RTT(协商加密) → 又几十~上百ms;
- → 一次"建连+TLS"可能就是几十~上百ms, 而下游处理也许才几ms;
- 高频请求下, 每次都付这笔"固定开销" → 总延迟被建连主导, QPS被建连卡住。
2. 关闭连接的代价: TIME_WAIT
- 连接关闭时, 主动关闭的一方进入TIME_WAIT状态, 要等2MSL(常见60s)才释放;
- 每请求一关 → 大量连接堆在TIME_WAIT → 占用本地端口(端口有限~6万);
- 端口耗尽 → 无法建新连接, 报"Cannot assign requested address"。
3. 连接复用(HTTP keep-alive)的原理:
- HTTP/1.1默认keep-alive: 一个TCP连接处理完一个请求后【不关闭】, 留着给下个请求用;
- → 同一个连接, 握手一次, 之后连续承载N个请求 → 握手开销被N个请求摊薄, 每请求≈0;
- 也不用频繁关连接 → 不堆TIME_WAIT。
4. 连接池(Connection Pool):
- 维护一组可复用的、保持着的连接; 要发请求时从池里取一个用, 用完还回去(不关);
- 控制并发连接数、复用空闲连接、管理连接的健康/超时;
- → 客户端高效复用连接的标准做法。
5. 本文的错: 破坏了复用
- 每次new client / 不带池的实现 / Connection: close → 连接用一次就扔, 复用为0;
- → 退化成"每请求一建一关", 把上面所有开销付满。
6. 正确姿势: client要复用、连接池要配
- HTTP client(及其连接池)应当【创建一次、全局复用】, 而非每请求新建;
- 启用keep-alive、合理配置连接池大小/超时。
一句话: 建TCP连接(+TLS)要多个RTT很贵、关连接堆TIME_WAIT耗端口; 每请求新建连接=把这开销付无数遍;
应复用连接(HTTP keep-alive)+用连接池(client创建一次全局复用), 把建连握手开销摊薄到≈0。
这套认知,是整个坑的根。建立一个连接的真实开销:TCP 三次握手 1 个 RTT、TLS 握手额外 1~2 个 RTT,一次建连+TLS 可能几十~上百 ms,而下游处理也许才几 ms;高频下每次都付这笔固定开销,总延迟被建连主导。关闭的代价 TIME_WAIT:主动关闭方进入 TIME_WAIT 等 2MSL(~60s),每请求一关就大量堆积、占用本地端口(~6 万),端口耗尽报 Cannot assign requested address。连接复用(keep-alive):一个 TCP 连接处理完一个请求不关闭、留给下个请求,握手一次摊薄到 N 个请求、每请求≈0。连接池:维护一组可复用的连接,用完还回不关。本文的错:每次 new client/不带池/Connection: close,连接用一次就扔。正确姿势:client(及连接池)创建一次、全局复用,启用 keep-alive。一句话:建 TCP 连接(+TLS)要多个 RTT 很贵、关连接堆 TIME_WAIT 耗端口;每请求新建连接=把这开销付无数遍;应复用连接(keep-alive)+用连接池(client 创建一次全局复用),把建连握手开销摊薄到≈0。
第二件事:正解——复用 HTTP client、配置连接池、启用 keep-alive
搞懂了原理,正解就清晰了:把 HTTP client(及其连接池)创建一次、全局复用;启用 keep-alive;合理配置连接池大小和超时;别每次请求 new client。
// ====== 正解一: HttpClient 创建一次、全局复用(JDK HttpClient) ======
public class DownstreamService {
// ★ static/单例: 整个应用共用一个HttpClient(它内部自带连接池, 默认复用连接)
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
public String callDownstream(String url) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(3)) // 读超时(配合连接复用, 别忘超时, 同355篇)
.build();
// ✓ 复用CLIENT, 底层连接被复用, 不再每次握手
return CLIENT.send(req, BodyHandlers.ofString()).body();
}
}
// → 关键: HttpClient创建一次、复用; 它内部维护连接池, 对同一目标的请求会复用keep-alive连接。
// ====== 正解二: Apache HttpClient 显式配连接池(更可控) ======
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200); // 连接池最大连接数
cm.setDefaultMaxPerRoute(50); // 每个目标(host)最大连接数
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
.setKeepAliveStrategy(...) // keep-alive策略
.build();
// ★ 这个client(连接池)全局复用一份; 请求从池里取连接、用完归还(不关), 高效复用。
// 注意: response一定要正确close(或EntityUtils.consume), 否则连接不归还池 → 连接泄漏!
# ====== 配置与注意点 ======
# 1. client/连接池: 创建一次, 全局复用(单例/static); 绝不每请求new;
# 2. keep-alive: HTTP/1.1默认开启; 确认没被 Connection: close 关掉;
# 3. 连接池大小: 按并发量和下游承受力配 maxTotal / maxPerRoute, 别过大压垮下游、别过小成瓶颈;
# 4. 连接复用 ≠ 不要超时: 仍要配连接超时、读超时(复用不代表请求不会卡, 超时仍是命脉, 同355篇);
# 5. 空闲连接管理: 配空闲超时, 清理长时间不用的连接(对端可能已关);
# 6. HTTP client要正确释放response(关body/consume entity), 否则连接不归还 → 连接泄漏(连接池耗尽);
# 7. 高并发短连接的服务端: 也可调内核(SO_REUSEADDR等)缓解TIME_WAIT, 但根本还是复用连接。
# 核心: 复用连接是根本 —— HTTP client及连接池创建一次全局复用、启用keep-alive、配好池大小与超时、
# 正确释放response归还连接; 让一个连接握手一次后承载大量请求, 把建连开销摊薄、不再堆TIME_WAIT。
修复的核心,是"client 创建一次全局复用、启用 keep-alive、配好连接池"。正解一:HttpClient 创建一次、全局复用——static/单例,整个应用共用一个 client,它内部自带连接池、默认复用 keep-alive 连接。正解二:Apache HttpClient 显式配连接池(maxTotal/maxPerRoute、keep-alive 策略;但 response 一定要正确 close,否则连接不归还、连接泄漏)。注意点:client/连接池创建一次全局复用、确认 keep-alive 没被关、连接池大小按并发和下游配、连接复用≠不要超时(超时仍是命脉)、管理空闲连接、正确释放 response 归还连接。归根结底:复用连接是根本——HTTP client 及连接池创建一次全局复用、启用 keep-alive、配好池大小与超时、正确释放 response 归还连接;让一个连接握手一次后承载大量请求,把建连开销摊薄、不再堆 TIME_WAIT。
第三件事:网络调用性能与连接管理的其他常见坑
排查后我把网络调用性能、连接管理相关的其他坑也系统梳理了一遍。
网络调用性能与连接管理的其他常见坑
# 1. 连接不复用每次新建(本文): 建连开销付满+TIME_WAIT堆积。→ 复用client+连接池+keep-alive。
# 2. 没设超时(同355篇): 下游卡死拖垮自己。→ 连接超时+读超时必配。
# 3. 连接泄漏: response/socket用完不关, 连接不归还池。→ try-finally/try-with-resources关闭。
# 4. 连接池配置不当: 过小成瓶颈、过大压垮下游或耗资源。→ 按并发与下游能力配。
# 5. TIME_WAIT/CLOSE_WAIT堆积: 主动关堆TIME_WAIT, 不关对端FIN堆CLOSE_WAIT。→ 复用+正确关闭。
# 6. DNS解析慢/未缓存: 每次请求都解析域名。→ 缓存DNS/用连接池(连接复用也省了重复解析)。
# 7. 串行调用本可并行: 多个独立的下游调用串行等待。→ 并发调用(异步/并行)。
# 8. 没有重试/熔断/降级: 下游抖动直接失败或雪崩。→ 重试(幂等)+熔断+降级。
# 共同根源: 网络调用的成本, 远不止"传输数据"本身 —— 建连接、握手、DNS、关闭、等待, 都是成本;
# 很多网络性能问题, 都源于"忽视了数据传输之外的这些隐性成本", 尤其是反复地、不复用地付这些成本。
# 核心: 优化网络调用, 要把"传输之外的隐性成本"管起来 —— 复用连接(摊薄建连)、设超时(防卡死)、
# 正确关闭(防泄漏)、并行化(省等待)、加重试熔断(防抖动); 别只盯着传输, 隐性成本往往才是大头。
排查让我把网络调用性能与连接管理的其他坑也梳理清了。一、连接不复用每次新建(本文)。二、没设超时。三、连接泄漏(用完不关)。四、连接池配置不当。五、TIME_WAIT/CLOSE_WAIT 堆积。六、DNS 解析慢/未缓存。七、串行调用本可并行。八、没有重试/熔断/降级。它们的共同根源是:网络调用的成本远不止"传输数据"本身——建连接、握手、DNS、关闭、等待,都是成本;很多网络性能问题都源于"忽视了数据传输之外的这些隐性成本",尤其是反复地、不复用地付这些成本。核心是:优化网络调用,要把"传输之外的隐性成本"管起来——复用连接(摊薄建连)、设超时(防卡死)、正确关闭(防泄漏)、并行化(省等待)、加重试熔断(防抖动);别只盯着传输,隐性成本往往才是大头。下面这张图,是这次连接没复用坑的成因与解法:
第四件事:每次新建连接 vs 复用连接对比表
这次踩坑后,我把"每次新建连接"和"复用连接(keep-alive+连接池)"的关键区别对比成一张表。
| 维度 | 每次新建连接 | 复用连接(keep-alive+池) |
|---|---|---|
| 每请求握手 | 都要(TCP+TLS) | 仅首次, 后续复用 |
| 建连开销 | 每次都付(几十~百ms) | 摊薄到≈0 |
| 延迟 | 高(被建连主导) | 低(主要是下游处理) |
| QPS | 低(建连成瓶颈) | 高 |
| TIME_WAIT | 大量堆积, 耗端口 | 很少 |
| 资源 | 浪费(反复建关) | 高效(连接被复用) |
这张表把两种方式钉清了。核心是:差别在于"把昂贵的'建立连接'这件事做了多少遍"——每次新建,是"每办一件小事,都重新建立一次昂贵的通道,办完就拆";复用,是"建立一次通道,反复用它办很多事";而那个"建立通道"的成本(握手),是固定且昂贵的,办的事越多,摊到每件事上就越划算。它给我的最大启发是:对于"有昂贵的固定建立成本、但建立后可反复使用"的资源(连接、数据库会话、线程、对象、进程),"建立一次、反复复用(池化)"几乎总是远优于"每次用都重新建立、用完销毁";这就是"池化(pooling)"思想的精髓——用一组预先建好、可复用的资源,摊薄掉昂贵的建立成本(连接池、线程池、对象池都是这个道理)。这给了我一种处理"昂贵资源"的清醒:每当我要频繁使用一种"建立成本高"的资源时,都要先问:"它能复用吗?该不该池化, 建一次反复用、而不是每次重建?"——而不是图省事每次都新建一个;"识别昂贵的可复用资源、用池化/复用摊薄其建立成本",是性能优化里一个回报极高的通用模式。认清复用摊薄昂贵建立成本、对昂贵可复用资源做池化——是这个坑带给我的认知。
第五件事:这次事故暴露的"看不见的成本"
这次让我反思更深一层:我之所以忽略了连接复用,是因为"建立连接的开销"对我来说是"看不见的"。我把"看得见的成本 vs 看不见的成本"整理成表。
| 维度 | 看得见的成本 | 看不见的成本(易忽略) |
|---|---|---|
| HTTP 请求 | 传输数据、下游处理 | 建连、握手、DNS、关闭、TIME_WAIT |
| 是否写在代码里 | 是(client.send) | 否(藏在底层网络栈) |
| 低频时 | 感知得到 | 几乎无感 |
| 高频时 | 线性增长 | 可能成为主导/瓶颈 |
| 容易被优化吗 | 容易想到 | 容易被遗漏 |
这张表道出了问题的认知根源。核心是:我栽跟头,是因为我只看见了"看得见的成本"(发请求、传数据),却忽略了那一大片"看不见的成本"(建连、握手、关闭、TIME_WAIT)——它们没写在我的代码里、藏在底层网络栈中,低频时悄无声息,高频时却成了真正的瓶颈;"看不见"不等于"不存在", 它只是没在我的视野里, 却实实在在地在消耗。它给我的深刻启发是:一个系统的成本/开销,有"显性"和"隐性"之分——显性的(你主动写的逻辑)容易被关注和优化;隐性的(藏在框架、底层、基础设施里的)极易被遗忘,而后者常常才是性能问题的真凶;序列化/反序列化、GC、上下文切换、锁竞争、网络握手、内存拷贝……这些"看不见的开销",是区分"能用的系统"和"高性能系统"的关键。这给了我一种性能优化时的深度:排查性能问题、设计高性能系统时,要主动地把目光投向那些"看不见的、隐性的成本"——用抓包、profiling、监控(如这次的 netstat/抓包),把"底层到底在默默做什么、花了多少"显形出来;"看见那些看不见的成本、并把它们纳入优化视野",是从'只会优化显性逻辑'走向'能驾驭系统真实性能'的关键一步。认清看不见的成本不等于不存在、用工具把隐性开销显形并纳入优化——是这个连接复用坑带给我的工程态度。
第六件事:写网络调用代码时,我现在的自检习惯
现在每当我要写调用外部 HTTP 服务的代码,我都会先按这张图问自己:
这张图的精髓,是"client 复用、连接池 keep-alive、正确释放、配超时,一个都不能少"。每请求 new改成全局复用、没池化配连接池 keep-alive、用完不关正确释放归还、没超时补上超时。这套习惯,让我从"随手 new client 发请求"变成了"先确保连接复用、释放、超时都到位"——核心始终是:HTTP client 及连接池创建一次全局复用、启用 keep-alive、正确释放 response 归还连接、配好超时,把昂贵的建连握手开销摊薄、别每次新建连接堆 TIME_WAIT。
我立下的几条规矩
这场"接口慢、TIME_WAIT 堆几万个"的事故,换来了我写网络调用时,刻进骨子里的几条铁律:
- 建 TCP 连接(+TLS)要多个 RTT,很贵。跨机房一次握手就几十~上百 ms。
- 每请求新建连接=把建连握手开销付无数遍,还堆 TIME_WAIT 耗端口。
- HTTP client 及连接池创建一次、全局复用,绝不每请求 new。
- 启用 keep-alive,让一个连接握手一次后承载大量请求。开销摊薄到≈0。
- 正确释放 response/连接(关 body、try-finally),否则连接泄漏耗尽池。
- 连接复用不代表不要超时,连接超时+读超时仍是命脉。
- 关注"看不见的成本"(建连、握手、GC、序列化),它们常是真凶。
写在最后
回头看,这场由"每次请求都新建连接"引发的、接口慢且 TIME_WAIT 堆积的事故,真正教给我的,远不止"复用 HTTP client"这一个技巧。它让我对"一件事的成本, 不只在'做这件事本身', 还在'为了做这件事而做的准备和收尾'; 而当你高频地做它时, 那些'准备和收尾'的成本会被无情地放大",有了一次刻骨的体会。我栽跟头,是因为我把一次 HTTP 调用,简单地理解成了"把请求发过去、把响应收回来"这一个动作——我只看见了那个"核心动作"。可我忽略了:在那个核心动作的前面,有一整套昂贵的"准备工作"(建立连接、三次握手、TLS 协商);在它的后面,还有"收尾工作"(关闭连接、TIME_WAIT 等待);低频时,这些准备和收尾的成本无关紧要;可一旦我高频地重复这个调用,而且每次都重新准备、重新收尾,这些"边角成本"就累积、放大成了主导一切的瓶颈。这让我领悟到一个关于"核心成本与边际准备成本"的深刻认知:做一件会被"反复、高频"执行的事时,要特别警惕那些"每次执行都要重复付出的、固定的准备/收尾成本"——因为它们会被执行的频次无情地乘以放大;"一次性的准备成本"分摊到一次执行上微不足道,但乘以百万次,就成了天文数字;"把'能复用的准备工作'复用起来、只做一次, 而不是每次重做",是优化一切高频操作的核心思路。这给了我一种面对高频操作时的根本视角:识别一段"会被高频执行"的逻辑时,要拆开看"哪些是每次都必须做的(核心)、哪些是本可以'一次准备、多次复用'的(准备)"——然后把后者从高频路径里"提"出来, 只做一次(连接复用、对象复用、预编译、缓存、池化);"把可复用的准备工作从高频路径中剥离、复用化",是让高频系统跑得快的一条根本性原则。认清边际准备成本会被频次放大、把可复用的准备工作只做一次——这,是我用一次连接没复用的事故,换来的、关于网络编程、也关于如何优化一切高频操作的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写调用外部服务的代码时,先把那个 HTTP client 提成全局复用的、配好连接池,那我对着那几万个 TIME_WAIT 排查的这段时间,就值了。
—— 别看了 · 2026