接口调多了就超时?一次 HttpClient 连接池泄漏的完整排查与修复
一条"调用次数多了就报
Timeout waiting for connection from pool"的线上问题,重启就好、跑一会又复现。本文记录完整的排查过程、HttpClient 连接池的归还原理、最终修复方案以及踩坑经验。文中内网域名已脱敏,仅保留排查所需的关键信息。
一、问题现象
某后台系统的一个查询接口(短信记录列表),平时用着没事,一旦被频繁调用一段时间后,就开始报:
csharp
org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool
几个典型特征:
- "调多了才抛":刚重启完好的,要跑一阵子才出现;
- 重启即恢复,然后周而复始;
- 不是必现,负载高、下游抖动时更容易触发。
而且这个问题之前已经"优化"过一版:减少了一次循环里的 Dubbo RPC 调用,并加了一段打印连接池状态的日志。结果问题原样还在。
"调多了才抛 + 重启恢复"------这两个特征组合在一起,基本可以锁定方向:这是资源泄漏,不是容量不够。容量不够会在并发时立刻抛,不会"跑一会才抛"。
二、排查过程
2.1 先搞清楚:这个 pool 是哪个 pool?
报错里的 connection from pool,第一反应要分清是 Dubbo 的连接池/线程池 ,还是 HTTP 客户端的连接池。这俩完全不是一回事,搞混了排查方向就全错。
- Dubbo 线程池满,报的是
RejectedExecutionException: Thread pool is EXHAUSTED; Timeout waiting for connection from pool是 Apache HttpClient 的ConnectionPoolTimeoutException,发生在从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]
两组数据说明了几件事:
leased: 1050 = max: 1050、available: 0:池子全被占满,一个可用连接都没有;- 47 分钟内
leased一直钉死在 max,available恒为 0------零波动。如果是正常的高并发占用,数字一定会来回跳;纹丝不动 = 连接被租出去就再也没回来; 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,只由响应体输入流的生命周期触发,共三个入口:
- 读到 EOF :
EofSensorInputStream.read()返回 -1 时,触发checkEOF->watcher.eofDetected()->ConnectionHolder调releaseConnection(),连接归还(可复用)或关闭; - close 流 :触发
watcher.streamClosed()-> 同样走到releaseConnection(); - 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 path :
IOUtils.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 的MainClientExec在execute()自身抛异常时已经会调connHolder.abortConnection()兜底释放。所以真正起作用的只有consumeQuietly,releaseConnection()加不加都对。这点在后面"经验"里再说。
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 出来的局部实例(不是共享单例),泄漏的表现会一样吗?欢迎评论区交流。