一次 HttpClient 连接池泄漏的完整排查与修复

接口调多了就超时?一次 HttpClient 连接池泄漏的完整排查与修复

一条"调用次数多了就报 Timeout waiting for connection from pool"的线上问题,重启就好、跑一会又复现。本文记录完整的排查过程、HttpClient 连接池的归还原理、最终修复方案以及踩坑经验。

文中内网域名已脱敏,仅保留排查所需的关键信息。

一、问题现象

某后台系统的一个查询接口(短信记录列表),平时用着没事,一旦被频繁调用一段时间后,就开始报

csharp 复制代码
org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool

几个典型特征:

  1. "调多了才抛":刚重启完好的,要跑一阵子才出现;
  2. 重启即恢复,然后周而复始;
  3. 不是必现,负载高、下游抖动时更容易触发。

而且这个问题之前已经"优化"过一版:减少了一次循环里的 Dubbo RPC 调用,并加了一段打印连接池状态的日志。结果问题原样还在。

"调多了才抛 + 重启恢复"------这两个特征组合在一起,基本可以锁定方向:这是资源泄漏,不是容量不够。容量不够会在并发时立刻抛,不会"跑一会才抛"。

二、排查过程

2.1 先搞清楚:这个 pool 是哪个 pool?

报错里的 connection from pool,第一反应要分清是 Dubbo 的连接池/线程池 ,还是 HTTP 客户端的连接池。这俩完全不是一回事,搞混了排查方向就全错。

  • Dubbo 线程池满,报的是 RejectedExecutionException: Thread pool is EXHAUSTED
  • Timeout waiting for connection from poolApache HttpClientConnectionPoolTimeoutException,发生在从 PoolingHttpClientConnectionManager 借连接超时时。

巧的是,团队上一版加的"诊断日志"打的正好是 PoolingHttpClientConnectionManager 的状态------这从侧面印证了:大家都怀疑是 HTTP 连接池,但只加了观测、没修根因

2.2 理清调用链

顺着接口往下挖:

scss 复制代码
SmsMessageController#recordList
  └─ recordService.list(...)
       └─ SmsBaseService#request(...)                      // 拼 网关 URL
            └─ HttpClientUtils.sendPOSTUseAppKey(httpClient, ...)  // ← HTTP 连接池在这里被占用

关键信息:注入的 httpClient一个来自公共 JAR 的共享单例 bean ,全站多个 Service 都 @Autowired 同一个实例,共用同一个 PoolingHttpClientConnectionManager。也就是说:任意一处泄漏,都会拖垮所有人,而且它们大多打到同一个下游网关,共用同一条 per-route 子池。

2.3 看代码,找泄漏点

HttpClientUtils.sendPOSTUseAppKey 长这样(修复前):

java 复制代码
public static JSONObject sendPOSTUseAppKey(HttpClient httpClient, String url, Map paramMap, Header[] headers) {
    try {
        HttpPost httpPost = new HttpPost(url);
        httpPost.setHeaders(headers);
        if (MapUtils.isNotEmpty(paramMap)) {
            httpPost.setEntity(new UrlEncodedFormEntity(createRequestParam(paramMap), "UTF-8"));
        }
        HttpResponse httpResponse = httpClient.execute(httpPost);
        InputStream stream = httpResponse.getEntity().getContent();
        JSONObject jsonObject = JSON.parseObject(IOUtils.toString(stream, "utf-8"));
        assertResult(url, paramMap, jsonObject);
        return jsonObject;
    } catch (BusinessException e) {
        throw e;
    } catch (Exception e) {
        throw new BusinessException("", e.getMessage(), e);
    }
}

没有 finally、没有 EntityUtils.consume、没有 stream.close()------典型的"忘了消费响应体"嫌疑代码。

但这里有个疑问需要先解答:IOUtils.toString(stream) 不是把响应体读完了吗?读完不是应该会释放连接吗?那为什么还会漏?这个问题先放一放,看日志数据会更清楚。

2.4 日志实锤,并发现"第二条路由"

打开连接池状态日志开关,压测一段时间后抓到关键数据:

全局池(两个时间点,相隔约 20 分钟,数字纹丝不动):

yaml 复制代码
Connection Pool Stats: [leased: 1050; pending: 0; available: 0; max: 1050]
Connection Pool Stats: [leased: 1050; pending: 0; available: 0; max: 1050]

路由级(相隔约 6 分钟,两次完全一样):

yaml 复制代码
routePool stats: [route: {}->http://sms-gateway.internal:80][leased: 656][available: 0][pending: 0]
routePool stats: [route: {s}->https://oss-bucket.oss-cn-hangzhou.aliyuncs.com:443][leased: 394][available: 0][pending: 0]

两组数据说明了几件事:

  1. leased: 1050 = max: 1050available: 0:池子全被占满,一个可用连接都没有
  2. 47 分钟内 leased 一直钉死在 max,available 恒为 0------零波动。如果是正常的高并发占用,数字一定会来回跳;纹丝不动 = 连接被租出去就再也没回来;
  3. 656 + 394 = 1050两条路由正好把全局池填满

这里有个意外发现:不只是 sms 网关这条路由在漏,OSS 那条路由也在漏(394) 。如果只盯着 sendPOSTUseAppKey,OSS 那 394 永远修不掉,池子照样会被填满。

2.5 第二条路由的泄漏点

全局搜 httpClient.execute,除了 HttpClientUtils 那处,还有两处在 xxxxController,直接 HttpGet 去 OSS 取文件:

java 复制代码
// 修复前:下载活动数据时,先 GET OSS 拿元数据
HttpResponse httpResponse = httpClient.execute(httpUriRequest);
if (200 != httpResponse.getStatusLine().getStatusCode()) {
    log.error("get oss file error: ...", IOUtils.toString(httpResponse.getEntity().getContent(), ...));
    throw new BusinessException(ErrorCode.Common.HTTP_ERROR);
}
// 200 成功路径:只读了一个 Content-Length 头,响应体根本没读!
result.setOssUrl(title.getDataUrl());
result.setContentLength(Long.parseLong(httpResponse.getFirstHeader("Content-Length").getValue()));

200 成功路径只读了一个 header,响应体从头到尾没被读过,方法就 return 了。这就是一个 100% 复现的干净泄漏------每次成功调用必漏一个连接。这 394 就这么悄无声息攒起来的,而且因为是"成功"调用,连错误日志都不会有。

另一处下载文件的 IOUtils.copy(entity, outputStream),正常下完会读到 EOF(释放),但客户端中途断连时会读到一半抛异常,没 finally 兜底------也会漏。

至此,三条泄漏路径都找到了:

位置 路由 泄漏条件 复现难度
HttpClientUtils.sendPOSTUseAppKey sms IOUtils.toString 读到一半抛异常(网关 reset/读超时) 需下游抖动,不易复现
xxxController 下载元数据 OSS 200 成功路径从不读 body 每次成功必漏,极易复现
xxxController 下载文件 OSS 客户端中途断连 中等

2.6 closeExpiredConnections 为什么救不了场

排查时还看到一行"看起来在清理连接池"的代码:

java 复制代码
httpClient.getConnectionManager().closeExpiredConnections();

这行是上一版加的,看着像"清理连接池",实际对泄漏毫无作用 。原因放到第三节原理里一起讲,这里先给结论:它只清理空闲(available)连接,根本不碰租出(leased)的连接 ;而我们的池子 available: 0,它根本没东西可清理。

三、原理剖析:HttpClient 连接池到底怎么归还连接

前面留了个问题:IOUtils.toString 把响应体读完了,连接不是应该释放吗?要回答这个,得理解 HttpClient 连接池的归还机制。

3.1 池结构

PoolingHttpClientConnectionManager 里维护了一个总池 CPool,里面有两个集合:leased(已租出)和 available(可用)。同时按 host:port 维护多个 RouteSpecificPool,每个路由池也有自己的 leased/available。借连接时,按目标 host:port 找到对应路由池,从它的 available 里取。

3.2 归还连接的三个触发点(核心)

httpClient.execute() 返回的响应,其 entity 被框架包了一层:entity.getContent() 拿到的不是裸 socket 流,而是一个 EofSensorInputStream,它内部持有一个 watcher------ConnectionHolder(同时持有连接和连接池管理器)。

连接从 leased 回到 available,只由响应体输入流的生命周期触发,共三个入口:

  1. 读到 EOFEofSensorInputStream.read() 返回 -1 时,触发 checkEOF -> watcher.eofDetected() -> ConnectionHolderreleaseConnection(),连接归还(可复用)或关闭;
  2. close 流 :触发 watcher.streamClosed() -> 同样走到 releaseConnection()
  3. abort :触发 watcher.streamAbort() -> 中止释放。

关键点:ConnectionHolder 没有 finalizer,EofSensorInputStream 也不会在 GC 时归还。 所以如果这三个入口一个都没触发------既没读到 EOF、也没 close、也没 abort------连接就永久停在 leased,GC 也救不了。

3.3 回到那个问题:为什么 IOUtils.toString 能释放?

严格说,释放连接的不是 IOUtils.toString,而是"把响应体读到 EOF"这个动作。 IOUtils.toString 内部就是个 while ((len = read(buf)) != -1) 循环,最后一次 read() 返回 -1 的那一刻,上面那条 checkEOF -> eofDetected -> releaseConnection 的链路就触发了。释放发生在"读到 EOF"的瞬间,发生在 toString 返回之前。

这也解释了为什么是"部分泄漏"而不是"100% 泄漏":

  • happy pathIOUtils.toString 读完到 EOF -> 触发归还 -> 不漏
  • 异常路径wrappedStream.read() 读到一半抛 IOException(网关 reset / 读超时),没机会返回 -1,checkEOF 不触发 ->

所以 sendPOSTUseAppKey 是"正常调用归还、下游抖动时漏",积少成多。而 OSS 那处更彻底------连读都没读,EOF 永远不会到,每次成功必漏。

3.4 别把"重用策略"和"泄漏"搞混

HttpClient 还有个 DefaultConnectionReuseStrategy.keepAlive(),决定一条连接读完后是"放回 available 复用"还是"直接关闭"。它的判断之一是:响应体必须能自终止 (要么 Transfer-Encoding: chunked,要么有合法唯一的 Content-Length),否则 keepAlive()=false,连接被关闭而不是复用。

这件事会解释日志里 available: 0 的另一层原因(即便归还了也不复用),但要注意它和泄漏是两码事:

  • 关闭 的连接会从 leased 移除------不会让 leased 顶死;
  • 泄漏 是连接连归还都没触发------才会让 leased 钉死在 max。

我们日志里 leased 钉死 1050 不动,只能是"连归还都没触发"的真泄漏,不是重用策略导致的关闭。

3.5 closeExpiredConnections 为什么无效

closeExpiredConnections() 内部走的是 AbstractConnPool.closeExpired(),用的是 enumAvailable(...) ------只遍历 available(空闲)集合里过期的条目去关,leased 集合一个都不碰

我们的池子 available: 0,它遍历的是个空集,literally 没东西可关。而且即便能碰 leased,它也只认"keep-alive 过期"的空闲连接,是给空闲连接做保洁的,不是给"租出去没还"的连接做恢复的。

更根本的:池子侧没有任何机制能回收"leased 但被抛弃"的连接 。一旦 execute() 把连接租出去,池子就认为它"在用",除非应用主动触发 EOF/close/abort。这是为什么泄漏没法从 manager 侧修、只能在调用点修。

四、处理方案

核心思路:try/finally + EntityUtils.consumeQuietly(entity) 强制走 close 这条归还路径,无论成功还是异常,响应体都被消费、连接都归还。

4.1 HttpClientUtils.sendPOSTUseAppKey(修复后)

java 复制代码
public static JSONObject sendPOSTUseAppKey(HttpClient httpClient, String url, Map paramMap, Header[] headers) {
    HttpPost httpPost = new HttpPost(url);
    httpPost.setHeaders(headers);
    HttpResponse httpResponse = null;
    try {
        if (MapUtils.isNotEmpty(paramMap)) {
            httpPost.setEntity(new UrlEncodedFormEntity(createRequestParam(paramMap), "UTF-8"));
        }
        httpResponse = httpClient.execute(httpPost);
        InputStream stream = httpResponse.getEntity().getContent();
        JSONObject jsonObject = JSON.parseObject(IOUtils.toString(stream, "utf-8"));
        assertResult(url, paramMap, jsonObject);
        return jsonObject;
    } catch (BusinessException e) {
        throw e;
    } catch (Exception e) {
        throw new BusinessException("", e.getMessage(), e);
    } finally {
        // 消费响应体并释放连接,保证异常路径下连接也能归还连接池
        if (httpResponse != null) {
            EntityUtils.consumeQuietly(httpResponse.getEntity());
        }
        httpPost.releaseConnection();
    }
}

EntityUtils.consumeQuietly 内部会 close() 流,触发 ConnectionHolder.streamClosed() -> releaseConnection(),无论前面读没读到 EOF、有没有抛异常,都会触发归还。

这里多加的 httpPost.releaseConnection() 其实是冗余的防御代码:标准 HttpClient 4.5.x 的 MainClientExecexecute() 自身抛异常时已经会调 connHolder.abortConnection() 兜底释放。所以真正起作用的只有 consumeQuietlyreleaseConnection() 加不加都对。这点在后面"经验"里再说。

4.2 xxxController 两处 OSS 调用(修复后)

同样的套路,在 httpClient.execute 后包一层 try/finally

java 复制代码
HttpResponse httpResponse = httpClient.execute(httpUriRequest);
try {
    if (200 != httpResponse.getStatusLine().getStatusCode()) {
        log.error("get oss file error: ...", IOUtils.toString(httpResponse.getEntity().getContent(), ...));
        throw new BusinessException(ErrorCode.Common.HTTP_ERROR);
    }
    result.setOssUrl(title.getDataUrl());
    result.setContentLength(Long.parseLong(httpResponse.getFirstHeader("Content-Length").getValue()));
} finally {
    // 消费响应体、归还连接(成功路径此前只读 header 不读 body,会泄漏连接)
    EntityUtils.consumeQuietly(httpResponse.getEntity());
}

文件下载那处同理,finally 里加 consumeQuietly,覆盖客户端中途断连的场景。

五、验证

修复后怎么确认真的修好了?判据不是"不报错",而是 leased 不再顶到 max、available 回升

因为单纯把 maxTotal 调大也能让 Timeout 暂时不出现,但 available 仍是 0(连接不复用),那是假修复。只有真堵住泄漏,才会看到:

  • leased 在低位波动,不再持续攀升到 max;
  • available 出现非 0(连接被复用);
  • 全程不抛 Timeout waiting for connection from pool

最易复现的验证用例是 OSS 元数据下载那条(修复前每次成功必漏):连续调 50~100 次,看 OSS 路由的 leased 是否始终在个位数、available 是否回升。

六、总结与经验

回头看,这次排查有几条值得沉淀的经验:

1. "调多了才抛 + 重启恢复" = 泄漏,不是容量不够。 容量问题在并发时立刻抛;泄漏是累积的,跑一阵子才抛、重启清空就恢复。这个特征组合基本可以一秒定向。

2. HttpClient 响应体必须消费,否则连接不归还。 execute() 拿到 response 后,要么读到 EOF,要么 EntityUtils.consumeQuietly(entity),要么 close() 流。最稳的姿势是 try/finally + consumeQuietly,不依赖"恰好读到 EOF"。这是 HttpClient 4.x 的必修课。

3. closeExpiredConnections / closeIdleConnections 治不了泄漏。 它们只清理 available(空闲)连接,不碰 leased。拿它们当泄漏的解药是常见误区。池子侧没有回收"租出未还"连接的机制,泄漏只能在调用点修。

4. 共享连接池:一处泄漏,全站遭殃。 一个 httpClient bean 被多个 Service 共用时,排查不能只看报错接口的调用链,要把所有 httpClient.execute 的调用点都过一遍。这次就是靠路由级日志才发现"第二条路由"也在漏。

5. 别把 Dubbo pool 和 HTTP pool 搞混。 Timeout waiting for connection from pool 是 HttpClient 的;Dubbo 线程池满是 EXHAUSTED。搞混了方向全错。

6. 诊断要有数据,池 stats 是关键证据。 leased / available / pending / max 这组数字能区分很多情况:leased 顶死 = 泄漏;available 恒 0 = 不复用;pending > 0 = 在排队抢连接。没有这组数据,排查只能靠猜。反射拿 PoolingHttpClientConnectionManager 的内部状态虽然 hack,但排查时很管用。

7. 修复验证要看趋势,不是看"不报错"。 调大池子上限、加超时重试都能让错误暂时消失,但泄漏还在。验证必须看 leased 回落 + available 回升这个趋势。

8. 上一版"优化"为什么没用? 因为方向错了:减少 Dubbo N+1 调用(动的是 Dubbo 侧)、加连接池日志(只加观测)------都没碰到"响应体没消费"这个 HTTP 侧的真正根因。诊断做了,但没据此动手。观测是为了行动,不是为了自我安慰。


最后留一个延伸问题:如果你的项目里 httpClient 是各处 new 出来的局部实例(不是共享单例),泄漏的表现会一样吗?欢迎评论区交流。

相关推荐
wbs_scy2 小时前
仿 muduo 高并发服务器项目:封装 HTTP 请求响应并用状态机完成增量解析
运维·服务器·http
ShineWinsu2 小时前
对于Linux:http的解析
linux·网络·c++·网络协议·http·请求·响应
会编程的土豆18 小时前
MySQL 入门:库、表、行、主键是什么
linux·数据库·网络协议·http
BullSmall20 小时前
AFL++ HTTP Mode(网络 / HTTP 服务模糊测试)完整安装教程
网络·网络协议·http
凉凉的知识库1 天前
什么是 HTTP Keep-Alive?一文讲清连接复用机制
网络协议·http·面试
wlsh151 天前
HTTP 协议
http
巨量HTTP2 天前
Python爬虫动态换IP实战,彻底解决IP403封禁、限流问题(附完整代码)
爬虫·python·tcp/ip·http
TlSfoward2 天前
如何用 TLS 与 HTTP 指标降低误伤 TLSFOWARD抓包工具
网络·网络协议·http
沐苏瑶2 天前
计算机网络核心笔记:打通 TCP/UDP 与 HTTP/HTTPS 底层逻辑(重点上)
笔记·计算机网络·http·https