4. HTTP 连接未复用:连接池配置的隐形陷阱

你有没有遇到过这种情况?接口平时响应挺快,一到高峰就隔几秒卡顿一次。监控上看 CPU、内存都正常,数据库也没慢查询,可接口就是时不时的抖动。

我之前在一个交易系统上就踩过这个坑。白天业务量上来,接口耗时从平均 30ms 突然跳到 2s,过几秒又恢复正常,来回反复。查了一圈最后发现问题出在 HTTP 连接没复用上。说白了,连接池配置不当,让服务端一直在重复建连、断连,白白浪费资源。

核心结论:HTTP 连接池不是"配了就行",参数不合理等于没配。连接未复用带来的延迟和资源损耗,往往比业务逻辑慢一个数量级。

4.1 现象:接口毛刺、QPS 上不去、大量 TIME_WAIT

连接未复用的最典型表现,就是 周期性毛刺。同一组机器,同样一段代码,流量低的时候一切正常,并发一上去就有了"呼吸效应"——每隔几秒抖一下。

当时我查了下机器网络状态,ss -s 一看,TIME_WAIT 状态的连接超过 5 万。每个请求都新建 TCP 连接,请求结束又关闭,大量的连接处于 TIME_WAIT 阶段,端口被占满,新请求只能等。

为什么会这样?因为 HTTP 请求没有复用底层连接。每次请求都走一遍三次握手、四次挥手。你想想看,一个接口业务逻辑只花 10ms,建连却要 50ms,吞吐量能上去才怪。

观察指标 正常复用 未复用/连接池耗尽
接口 P99 延迟 稳定,无明显毛刺 周期性飙升,是平均值的 10 倍以上
TCP TIME_WAIT 数量 < 100 数万到数十万
QPS 上限 接近理论峰值 远低于理论值,CPU 利用率上不去
连接池活跃连接数 长期平稳 反复从 0 飙到 max,再回落到 0

4.2 根因:连接池参数"形同虚设"

许多人觉得用上了 HttpClient 或者 RestTemplate 就自动有连接池。其实不是。默认配置往往非常保守,甚至根本没开启连接复用。

我记得 Spring Boot 2.x 早期版本,RestTemplate 底层默认用的是 SimpleClientHttpRequestFactory,它走的是 HttpURLConnection。这个实现默认不维护连接池。每次请求都新建 TCP 连接。

后来很多人换成了 HttpComponentsClientHttpRequestFactory,觉得用了 Apache HttpClient 就万事大吉。但 Apache HttpClient 的默认连接池参数也很保守——maxTotal 只有 20,maxPerRoute 只有 5。

注意:连接池参数不是越大越好。过大的连接池会让系统线程数飙升,反而增加上下文切换开销。连接池大小需要结合下游服务的处理能力来调整。

4.3 核心参数:看懂连接池的"三驾马车"

连接池配置绕不开三个核心参数。我一般把它们叫做"三驾马车":

  • maxTotal —— 连接池最大连接数。所有路由加起来的总上限。
  • maxPerRoute —— 每个目标主机的最大连接数。假设你只调一个下游,这个参数就等于实际可用连接数。
  • connectionRequestTimeout —— 从连接池获取连接的等待超时。如果连接池被占满,新请求会阻塞在这个时间上。

这里有一个很常见的配置错误:只配了 maxTotal,没配 maxPerRoute。默认情况下 maxPerRoute 只有 5。所以你即使把 maxTotal 设成 200,实际对每个下游还是只能建 5 个连接。流量稍微大一点,连接池就满了,请求开始排队。

参数 默认值 过高风险 过低风险
maxTotal 20 线程数膨胀,内存占用高 池不够用,请求排队
maxPerRoute 5 单下游负载过高,可能被打垮 单路由瓶颈,大量等待
connectionRequestTimeout 无(无限等待) 请求堆积,雪崩 正常流量下频繁超时

个人经验:我会把 connectionRequestTimeout 设为 1000~3000ms,宁可快速失败,也不让请求在连接池排队导致线程挂死。流量高峰时,快速失败 + 降级比死等更有价值。

4.4 实战案例:一个连接池参数引发的"毛刺"

说个我实际处理过的案例。一个订单查询接口,内部调用了三个下游服务。用的是 Apache HttpClient 的连接池。

上线后观察监控,接口 P99 延迟在 200ms 左右,但每隔 10~15 秒就有一个 P99 飙升到 2s 以上。业务方反馈说"接口一阵一阵的慢"。

排查过程:

  1. 先看 CPU、内存、GC —— 都正常,排除业务代码问题。
  2. 看网络 —— ss -t 发现 TIME_WAIT 数量在毛刺时冲高到 1 万以上。
  3. 看连接池监控 —— 活跃连接数在毛刺时撞到 maxPerRoute=5 的天花板。

问题清楚了:默认 maxPerRoute=5,接口并发稍微上来,连接池就满了。新请求需要等连接释放,等的时候占着线程,线程一多上下文切换加剧,接口就慢了。等前面的请求处理完释放连接,毛刺过去,然后下一波流量又来,周而复始。

修复方案:

  • maxTotal 从 20 调整为 200
  • maxPerRoute 从 5 调整为 50
  • connectionRequestTimeout 设为 2000ms

改完后毛刺消失,P99 降到 50ms 以下,QPS 翻了 4 倍。

// 改造前的连接池配置(默认值,形同虚设)
HttpClient httpClient = HttpClientBuilder.create()
    .setMaxConnTotal(20)
    .setMaxConnPerRoute(5)
    .build();

// 改造后的连接池配置(匹配并发量)
HttpClient httpClient = HttpClientBuilder.create()
    .setMaxConnTotal(200)
    .setMaxConnPerRoute(50)
    .setConnectionTimeToLive(30, TimeUnit.SECONDS)
    .evictExpiredConnections()
    .evictIdleConnections(10, TimeUnit.SECONDS)
    .build();

// 请求超时也要配套调整
RequestConfig config = RequestConfig.custom()
    .setConnectionRequestTimeout(2000)  // 从池中获取连接的等待时间
    .setConnectTimeout(1000)            // 建立 TCP 连接的超时
    .setSocketTimeout(5000)             // 等待数据的超时
    .build();

4.5 连接泄漏:另一种"隐形杀手"

参数配好了,连接池正常工作了,但还是有毛刺?那你可能要检查下连接泄漏。

连接泄漏是指——应用从连接池拿走了连接,用完之后没有归还。连接池里的可用连接一点点变少,最终耗尽。这时候新请求要么等,要么报错。

最常见的泄漏原因:响应流没关闭。比如用 HttpClient 调用接口,拿到 HttpResponse 后只读取了部分内容,没有把 InputStream 读完并关闭。连接无法被标记为空闲,也就没法归还到池里。

我曾经踩过这个坑:有一次排查连接泄漏,发现连接池的"租出连接数"一直在涨,从不下滑。最后定位到是某个同学在代码里用 Response.body().string() 之后,没有再调用 close()。你以为 string() 读完了就自动释放?不,底层的 stream 不一定关闭。一定要用 try-finally 或者 try-with-resources 保证关闭。

排查连接泄漏的快速手段:

  • 打开连接池的 log,观察 leased(租出)和 available(可用)数量变化
  • 使用 JMX 暴露连接池指标,实时监控
  • 压测时用 lsof -p [pid] | wc -l 看进程打开的文件句柄数

4.6 最佳实践:一套可落地的连接池配置模板

我一般会按以下原则来配置连接池。你可以根据实际情况微调:

  • maxPerRoute 设为目标下游接口的预期 TPS 乘上单次请求耗时(秒)。比如预期 TPS=100,平均耗时 200ms,那 maxPerRoute 至少 20
  • maxTotal 是所有下游 maxPerRoute 之和的 1.2~1.5 倍
  • connectionRequestTimeout 建议 1000~3000ms,拒绝排队等待
  • 开启 evictIdleConnections 和 evictExpiredConnections,定期清理空闲连接
  • 设置 connectionTimeToLive(TTL),让连接定期重建,避免长时间 TCP 连接被中间设备断开

个人习惯:我会在连接池初始化后,先做一轮小流量验证。用 curl 或者压测工具打少量请求,然后看 ss -t 确认连接数保持稳定,没有大量 TIME_WAIT 出现。这一步花不了 5 分钟,但能提前发现 80% 的连接配置问题。

最后说一句:连接池配置没有"万能参数"。你要结合下游的 RT、并发量、机器配置来调。关键是——监控先到位,参数再动手。没有监控的调优,就是盲调。

HTTP 连接池工作原理与故障链路 客户端发起请求 连接池管理器 检查可用连接 有可用 无可用 连接复用成功 请求到达后端 创建新连接 连接池是否已满 未满 连接创建成功 已满 连接池耗尽 请求排队 / 超时拒绝 ✅ 正常路径:连接复用 ❌ 故障路径:池耗尽 → 毛刺

这张图左侧是正常路径——连接复用成功,请求快速到达后端。右侧是故障路径——连接池耗尽后,请求开始排队等待,最终超时或被拒绝。接口毛刺就是右侧路径反复出现的结果。