问题背景
线上发布新版本后,突然有一天生产收到告警,发送Http请求失败,抛出NoHttpResponseException,如下图:
简要分析
经查阅资料,发现是服务端已经关闭了连接,但是客户端仍然还在使用这个连接发送请求,此时服务端收到请求后,会响应RST报文,客户端收到这个RST报文后就会抛NoHttpResponseException。
说到这,就要提一下KeepAlive机制,KeepAlive是HTTP的连接复用机制,在HTTP1.0时代,每个请求经过三次握手后,只会传输一次HTTP请求和响应报文后,就进入四次挥手关闭连接了。而TCP建立连接和关闭连接的代价是比较大的,导致HTTP1.0的通道利用率较低,时延较高。针对这个问题,推出了KeepAlive机制,一个TCP连接建立后,可以在上面发送多个HTTP报文,只有这个TCP连接的空闲时间达到超时时间,才会被关闭。HTTP1.1默认开启Keepalive。这里的关闭行为可能发生在客户端和服务端,比如客户端的Keepalive超时时间更短,则客户端就会先关闭连接,如果服务端配置的Keepalive超时时间更短,则服务端就会先关闭连接。
结合TCP四次挥手过程,再回到刚才的问题,仔细一想有可能服务端发的FIN包因为网络问题导致客户端没收到,客户端并没有关闭连接,而客户端继续复用这个TCP连接发HTTP请求的话,服务端会因为在四次挥手期间不接收报文而发送RST报文给客户端,客户端收到RST报文就会提示异常。
于是便找网络同事看下当时的网络是否有问题,但结果是网络正常,无问题。这个时候便想会不会联调环境也有这个问题(因为联调环境并没有开启监控,出了问题如果不看日志的话,根本发现不了),去看联调环境日志,发现这个问题是稳定复现,每隔一段时间都会出现一次,这时基本确定是代码问题了。
HttpClient连接池
当前新版本使用的HttpClient 客户端用的是Apache HttpClient 4.5.12版本,HttpClient 初始化配置如下:
public HttpClient getHttpClient() {
final RequestConfig requestConfig = RequestConfig.custom()
// 请求超时时间
.setConnectTimeout(5000)
// Socket读取数据的超时时间
.setSocketTimeout(5000)
// 从连接池获取连接的超时时间
.setConnectionRequestTimeout(5000)
.build();
final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
// 连接池最大连接数
connectionManager.setMaxTotal(400);
// 每条路由允许建立的最大连接数
connectionManager.setDefaultMaxPerRoute(2);
final HttpClientBuilder httpClientBuilder = HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.setConnectionManager(connectionManager);
return httpClientBuilder.build();
}
HttpClient 内置连接池,其连接池模型如下:
下面看看HttpClient 构造时的细节,具体看HttpClientBuilder.build()方法,在方法的最后可以看到如果开启了淘汰过期连接或者淘汰空闲连接,则会后台开启一个线程去定时清理。
但是我在设置连接池参数时,并没有设置这两个参数的值,默认都是false,看到这我怀疑是没有开启这个淘汰线程,导致连接已经超时被服务端关闭了,连接池并未及时移除,所以客户端复用了这个处于半连接状态的连接。
确定原因
此时,因为问题还得不到解决,为了不影响生产交易,运维回退到了上一个版本,很奇怪的是回退后观察了一段时间,不再报异常了,因为上个版本也没有开启淘汰线程,却是正常的。 这时我注意到了一个差异,就是旧版本有设置KeepAliveStrategy,而新版本没有设置
public HttpClient getHttpClient() {
final RequestConfig requestConfig = RequestConfig.custom()
// 请求超时时间
.setConnectTimeout(5000)
// Socket读取数据的超时时间
.setSocketTimeout(5000)
// 从连接池获取连接的超时时间
.setConnectionRequestTimeout(5000)
.build();
final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
// 连接池最大连接数
connectionManager.setMaxTotal(400);
// 每条路由允许建立的最大连接数
connectionManager.setDefaultMaxPerRoute(2);
ConnectionKeepAliveStrategy myStrategy = (response, context) -> {
HeaderElementIterator it = new BasicHeaderElementIterator
(response.headerIterator(HTTP.CONN_KEEP_ALIVE));
while (it.hasNext()) {
HeaderElement he = it.nextElement();
String param = he.getName();
String value = he.getValue();
if (value != null && "timeout".equalsIgnoreCase(param)) {
return Long.parseLong(value) * 1000;
}
}
return 5 * 1000;
};
final HttpClientBuilder httpClientBuilder = HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.setConnectionManager(connectionManager);
.setKeepAliveStrategy(myStrategy)
return httpClientBuilder.build();
}
于是继续看HttpClient 构造时对这个keepAliveStrategy参数的处理,发现如果不设置会用一个默认的KeepAliveStrategy
那这个 -1 代表什么呢,查看这个getKeepAliveDuration方法的引用,发现在HttpClient 执行请求链的MainClientExec中会用到这个方法
看日志打印如果是 -1,则会打印 Connection can be kept alive indefinitely,即连接将会永久保留。
到这里,问题已经找到了,就是这个KeepAliveStrategy没有设置,所以导致连接永远存在于连接池中,即时服务端已经超时关闭了该连接,后续客户端还是有可能复用这个处于半连接状态的连接。
原理探究
那究竟这个duration是怎么影响的,继续看代码发现这个duration 会更新到ConnectionHolder中的validDuration字段中,查看这个字段的引用发现,会在归还连接releaseConnection中用到。
归还连接时会更新该连接的过期时间,如果 time == -1,过期时间就会被更新成Long.MAX_VALUE,其中 validityDeadline 是每个连接的默认的空闲过期时间,默认也为Long.MAX_VALUE,即永远不会过期。
那为什么没有开启淘汰线程,也不会有问题呢?
这个就要从 向连接池中取连接的逻辑 找答案了,源码如下
原来在从连接池拿到连接后,会先判断下该连接是否过期,如果已经过期,则主动关闭该连接,重新从连接池中取。
旧版本虽然没有开启淘汰线程,但是有设置过期时间为5s,当连接过期后,下次取出该连接后,会主动关闭,重新取新的连接;而新版本既没有开启淘汰线程,也没有设置过期时间,即永远有效,则下次取出来的时候依赖认为该连接未过期,就继续用这个连接,导致发生了异常。
总结
在使用Apache HttpClient 连接池的时候,要注意以下几点:
1、最好设置KeepAliveStrategy过期策略,即设置过期时间,建议小于服务端的keep-alive时间,这样客户端连接池中的连接就可以提前主动关闭掉,而不会出现服务端已经超时关闭了,而客户端还在复用该连接。
2、单独开启淘汰线程,而不设置过期策略,仍然会出现这个问题,因为你不设置过期策略,就是永久有效,即使开启了淘汰线程,也扫描不到这个连接,无法主动关闭。