NoHttpResponseException排查

745 阅读6分钟

问题背景

线上发布新版本后,突然有一天生产收到告警,发送Http请求失败,抛出NoHttpResponseException,如下图:

企业微信截图_16685841747337.png

简要分析

经查阅资料,发现是服务端已经关闭了连接,但是客户端仍然还在使用这个连接发送请求,此时服务端收到请求后,会响应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报文就会提示异常。

image.png

于是便找网络同事看下当时的网络是否有问题,但结果是网络正常,无问题。这个时候便想会不会联调环境也有这个问题(因为联调环境并没有开启监控,出了问题如果不看日志的话,根本发现不了),去看联调环境日志,发现这个问题是稳定复现,每隔一段时间都会出现一次,这时基本确定是代码问题了。

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 内置连接池,其连接池模型如下:

image.png

下面看看HttpClient 构造时的细节,具体看HttpClientBuilder.build()方法,在方法的最后可以看到如果开启了淘汰过期连接或者淘汰空闲连接,则会后台开启一个线程去定时清理。

企业微信截图_16687640671275.png

企业微信截图_16687642397926.png

但是我在设置连接池参数时,并没有设置这两个参数的值,默认都是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

企业微信截图_16687663005215.png

企业微信截图_1668766321894.png

那这个 -1 代表什么呢,查看这个getKeepAliveDuration方法的引用,发现在HttpClient 执行请求链的MainClientExec中会用到这个方法

企业微信截图_16687664991604.png

看日志打印如果是 -1,则会打印 Connection can be kept alive indefinitely,即连接将会永久保留。

到这里,问题已经找到了,就是这个KeepAliveStrategy没有设置,所以导致连接永远存在于连接池中,即时服务端已经超时关闭了该连接,后续客户端还是有可能复用这个处于半连接状态的连接。

原理探究

那究竟这个duration是怎么影响的,继续看代码发现这个duration 会更新到ConnectionHolder中的validDuration字段中,查看这个字段的引用发现,会在归还连接releaseConnection中用到。

企业微信截图_1668767040996.png

企业微信截图_16687670932314.png

企业微信截图_16687671295533.png

归还连接时会更新该连接的过期时间,如果 time == -1,过期时间就会被更新成Long.MAX_VALUE,其中 validityDeadline 是每个连接的默认的空闲过期时间,默认也为Long.MAX_VALUE,即永远不会过期。

那为什么没有开启淘汰线程,也不会有问题呢?

这个就要从 向连接池中取连接的逻辑 找答案了,源码如下

企业微信截图_16687679593443.png

原来在从连接池拿到连接后,会先判断下该连接是否过期,如果已经过期,则主动关闭该连接,重新从连接池中取。

旧版本虽然没有开启淘汰线程,但是有设置过期时间为5s,当连接过期后,下次取出该连接后,会主动关闭,重新取新的连接;而新版本既没有开启淘汰线程,也没有设置过期时间,即永远有效,则下次取出来的时候依赖认为该连接未过期,就继续用这个连接,导致发生了异常。

总结

在使用Apache HttpClient 连接池的时候,要注意以下几点:
1、最好设置KeepAliveStrategy过期策略,即设置过期时间,建议小于服务端的keep-alive时间,这样客户端连接池中的连接就可以提前主动关闭掉,而不会出现服务端已经超时关闭了,而客户端还在复用该连接。
2、单独开启淘汰线程,而不设置过期策略,仍然会出现这个问题,因为你不设置过期策略,就是永久有效,即使开启了淘汰线程,也扫描不到这个连接,无法主动关闭。