记一次HttpClient连接池超时问题排查:Timeout waiting for connection from pool

记一次HttpClient连接池超时问题排查:Timeout waiting for connection from pool

情景

后端部署了新的代码,在几分钟后似乎所有的爬取相关的服务均不可用。服务频繁报错提示:「连接超时: 当前过于拥挤」。

获取连接池状态

首先立即打开日志查看错误消息,显示:Timeout waiting for connection from pool ,字面意思是从连接池中等待超时。为了了解这个时候的连接池状态如何,在查找资料后,使用以下的代码在出现异常的同时打印当前连接池状态。

for(HttpRoute route : customConnectionPool.getRoutes()){
    logger.info("{} :: {}", route.getTargetHost().getHostName() ,customConnectionPool.getStats(route));
}

PoolStats

连接池每个路由(可以认为一个Host:IP为一个路由/Route)的状态由PoolStats这个类表示。这个类有四个字段用于表示该路由对应状态下的连接,每个字段对应一种状态。

leased

“租用”,即表示当前正在被使用的连接。如果这个状态长期保持一个较高的数量,尤其是只增不减,应该要考虑到是连接用完后没有释放的问题了(后话)。

pending

“等待”,表示有请求在等待连接池可用连接,等待超时时间可以在RequestConfig.custom() 中使用setConnectionRequestTimeout设置(单位为ms),超时后即抛出题示异常。

available

“可用”,表示空闲的持久连接。根据源码文档,该路由的当前总存在连接数=leased+available。

max

表示路由最大可以创建的连接数,这个数量取决于PoolingHttpClientConnectionManagersetDefaultMaxPerRoute值,或者是由HttpClients.custom()setMaxConnPerRoute决定。

连接池的总大小(连接数量)的一个坑

最初代码是这样写的:

customConnectionPool = new PoolingHttpClientConnectionManager();
customConnectionPool.setDefaultMaxPerRoute(1500);

看起来似乎没什么问题。因为最初我认为连接池的最大连接数至少是等于DefaultMaxPerRoute的。之后查看日志发现出现了大量处于pending状态的请求,表示在等待连接池空闲,说明确实发生了大量的阻塞。

hostA[leased: 20; ==pending: 217==; available: 0; max: 1500]
hostB[leased: 0; ==pending: 15==; available: 0; max: 1500]
hostC[leased: 0; pending: 0; available: 0; max: 1500]
hostD[leased: 0; ==pending: 28==; available: 0; max: 1500]
hostE[leased: 0; ==pending: 388==; available: 0; max: 1500]

这里观察到==hostA[leased: 20==,说明问题可能出现在hostA上面,先尝试模拟获取一下连接池的最大连接数。

public static void main(String[] args) {
  PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
  cm.setDefaultMaxPerRoute(1500);
  System.out.println(cm.getTotalStats());
}

[leased: 0; pending: 0; available: 0; max: 20]

答案是20。因此即使设置了DefaultMaxPerRoute,也需要使用setMaxTotal设置连接池最大连接数量。

及时释放掉连接

既然连接池太小了,首先就是扩大连接池的MaxTotal,扩大连接池后也重现了连接的堆积。

hostA ==[leased: 255==; pending: 0; available: 1; max: 5000]

也确实修改了HostA爬取相关的代码,发现在修改的登录这一段,没有及时释放掉连接。

于是立马使用HttpClientUtils.closeQuietly(httpClient)关闭连接,结果发现他把整个连接池给关了。。。

好吧,应该使用的是HttpRequestBase的releaseConnection()来释放掉这个连接。

再配合一下HttpClientUtils.closeQuietly(httpResponse)(注意不是httpClient,与EntityUtils.consumeQuietly()效果相同)把流给关了。

加上这两句后确认问题得到了解决。

设置好超时时间

还有一个问题是,为何只有某个服务(hostC)在报错呢。查看日志,发现该服务报错时的耗时与setConnectionRequestTimeout的数值相当。看了一下RequestConfig的设置,发现默认情况下三个超时值都是-1,也就是永不超时。

RequestConfig requestConfig = RequestConfig.custom()
                .setConnectTimeout(5000)
                .setConnectionRequestTimeout(1000)
                .setSocketTimeout(8000)
                .build();

setConnectionRequestTimeout设置从连接池中取出连接的时限,设置这个可以防止产生大量处于等待状态下的连接阻塞整个后端。

setConnectTimeout设置向服务器发起连接的超时时间。

setSocketTimeout设置从服务器获取响应数据的超时时间。

最好还是设置一下各种服务的爬取时间吧。

开个线程来关闭无用连接

遇到这个问题,通常大家都这么想:能不能开个线程,定时把无用的连接给关掉呢。搜索了一些资料后,发现有人确实手写了个线程,不过这部分其实并不需要自己写。

在配置HttpClient的时候,使用.evictExpiredConnections()可以配置一个线程自动关闭过期的连接,使用.evictIdleConnections(...)可以配置自动关闭空闲超过指定时限的连接,看一下源码:

            if (evictExpiredConnections || evictIdleConnections) {
                final IdleConnectionEvictor connectionEvictor = new IdleConnectionEvictor(cm,
                        maxIdleTime > 0 ? maxIdleTime : 10, maxIdleTimeUnit != null ? maxIdleTimeUnit : TimeUnit.SECONDS,
                        maxIdleTime, maxIdleTimeUnit);
                closeablesCopy.add(new Closeable() {

                    @Override
                    public void close() throws IOException {
                        connectionEvictor.shutdown();
                        try {
                            connectionEvictor.awaitTermination(1L, TimeUnit.SECONDS);
                        } catch (final InterruptedException interrupted) {
                            Thread.currentThread().interrupt();
                        }
                    }

                });
                connectionEvictor.start();
            }

默认每10秒执行一次,在connectionEvictor里面具体干了什么呢:

this.thread = this.threadFactory.newThread(new Runnable() {
            @Override
            public void run() {
                try {
                    while (!Thread.currentThread().isInterrupted()) {
                        Thread.sleep(sleepTimeMs);
                        connectionManager.closeExpiredConnections();
                        if (maxIdleTimeMs > 0) {
                            connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS);
                        }
                    }
                } catch (final Exception ex) {
                    exception = ex;
                }

            }
        });

关键就是这句connectionManager.closeExpiredConnections()connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS)实现了关闭过期连接和空闲超过指定时限的连接。

参考资料

文章参考文献

HttpClient:Timeout waiting for connection from pool

httpclient连接池使用及简单分析

httpclient连接池释放异常和多余资源

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。