两种由java http长连接(keep-alive)导致的问题

7,460 阅读4分钟

两种由http长连接(keep-alive)导致的问题,当然这两种问题都有多种原因导致,这里只分析针对keep-alive相关而产生的异常。

1 SocketException: Connection reset || SocketException: Connection reset reset by peer

通常的报错堆栈日志如下:

Caused by: java.net.SocketException: Connection reset
        at java.net.SocketInputStream.read(SocketInputStream.java:209)
        ...

或者

java.net.SocketException: Connection reset by peer: socket write error 
at java.net.SocketOutputStream.socketWrite0(Native Method) 

关于SocketException在官方文档有描述:java8 connection_release

本质原因是服务端通过TCP协议给客户端返回了RST消息,表示已经完成了发送和接收,如果客户端此时从流中读取数据时会发生Connection reset,往流中写数据时就会发生Connection reset Connection reset by peer。即先返回了RST,此时再读写的话就会发生SocketException。

而至于为什么服务端会返回RST消息,那有可能是http keep-alive 导致的问题了。

如果是springboot的服务器,那么默认的keep-alive timeout是60s,但在默认情况下是不会在response header中返回【Keep-Alive】的。

如果客户端使用的是apache httpclient 4.x版本,默认的keep-alive是读取response heade中Keep-Alive字段,没有的话就是无限(代码中返回-1)。"If the Keep-Alive header is not present in the response, HttpClient assumes the connection can be kept alive indefinitely",详细代码DefaultConnectionKeepAliveStrategy.class类设置的,代码如下:

public long getKeepAliveDuration(final HttpResponse response, final HttpContext context) {
        Args.notNull(response, "HTTP response");
        final HeaderElementIterator it = new BasicHeaderElementIterator(
                response.headerIterator(HTTP.CONN_KEEP_ALIVE));
        while (it.hasNext()) {
            final HeaderElement he = it.nextElement();
            final String param = he.getName();
            final String value = he.getValue();
            if (value != null && param.equalsIgnoreCase("timeout")) {
                try {
                    return Long.parseLong(value) * 1000;
                } catch(final NumberFormatException ignore) {
                }
            }
        }
        return -1;
    }

所以问题就比较明白了,虽然springboot有keep-alive 60s,但并没有在response返回Keep-Alive的header。客户端如果使用了apache httpclient 4.x,且没有手动设置Keep-Alive的话,就会导致服务端的超时是60s,客户端就是无限的,在某些特殊情况下,服务端关闭了链接,客户端还会获取这个连接继续收发数据,就会导致上面的问题。

解决方法也很简单,httpclient 4.x文档中也说要设置默认的超时时间,即给一个自定义的实现。 具体可以在创建的时候可以直接通过setKeepAliveStrategy传入一个固定的时长。

 HttpClients.custom().setMaxConnPerRoute(xxx)
        .setMaxConnTotal(xxx)
        .setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
            @Override
            public long getKeepAliveDuration(HttpResponse httpResponse,
                                             HttpContext httpContext) {
                return keepalive;
            }
        }).build();

今天发现httpclient发布了新的5.x版本,瞅了瞅代码,发现在keep-alive上实现上有了点小变化,默认返回的没有keep-alive头时不再是-1了,而是从RequestConfig 中getConnectionKeepAlive

@Override
    public TimeValue getKeepAliveDuration(final HttpResponse response, final HttpContext context) {
        Args.notNull(response, "HTTP response");
        final Iterator<HeaderElement> it = MessageSupport.iterate(response, HeaderElements.KEEP_ALIVE);
        while (it.hasNext()) {
            final HeaderElement he = it.next();
            final String param = he.getName();
            final String value = he.getValue();
            if (value != null && param.equalsIgnoreCase("timeout")) {
                try {
                    return TimeValue.ofSeconds(Long.parseLong(value));
                } catch(final NumberFormatException ignore) {
                }
            }
        }
        final HttpClientContext clientContext = HttpClientContext.adapt(context);
        final RequestConfig requestConfig = clientContext.getRequestConfig();
        return requestConfig.getConnectionKeepAlive();
    }

我们再看看RequestConfig中,如果你没有setConnectionKeepAlive的话,默认是DEFAULT_CONN_KEEP_ALIVE = TimeValue.ofMinutes(3);(源码setConnectionKeepAlive注释中写的1min,可能是写错了,也可能是想设置成1min,但配置成了跟requestTimeout一样3min了,不管怎样知道最终运行时是什么就可以了) 也就是三分钟。所以我们再构造HttpClients对象时就能通过RequestConfig设置ConnectionKeepAlive时间了,而不必setKeepAliveStrategy,当然也可以setKeepAliveStrategy。注意在httpclient4.x中RequestConfig是没有ConnectionKeepAlive属性的。

2 NoHttpResponseException: xxx.xxx.xxx.xxx failed to respond

Caused by: org.apache.http.NoHttpResponseException: xxx.xxx.xxx.xxx failed to respond
	at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:141)

我们再来看看第二种情况,是在解析response header时抛出的异常。说明请求已经发到服务器了,但是返回来的时候NoHttpResponse。这与1中的异常稍有不同,1中的异常是在应用层与本地网络层交互的时候就抛出了异常,请求并没有发送出去。但其实这里也是上面keep-alive导致socket异常结束发送RST的情况的一种。应用的socket.close()跟TCP的FIN上有语义上的不对等。这里可以理解成是两个步骤,请求到达服务器之后,socket.close()已经先关闭了,也就是应用返回了一个-1表示end of the stream,所以服务端无法处理请求,客户端报错。

简单总结就:

1 服务端返回了RST到客户端,但客户端准备继续使用这个连接读写就会导致SocketException: Connection reset。

2 客户端先使用了一个连接发送http的请求到了服务端,但此时服务由于一些原因返回了RST,导致了客户端的httpclient抛出 NoHttpResponseException

为了解决这种由服务端先于客户端关闭导致的问题,那就让客户端先于服务端主动关闭连接,也就是在客户端将keep-alive的值设置得小于服务端即可。像上面的例子中,我们可以把客户端中keep-alive时长设置得小于60s即可减少上面的异常。

造成这两种类型异常的原因还有很多,这里暂时根据自己遇到的做一些分析和处理。

补充知识点:

  1. 'Connection: Keep-Alive' header头只用来在HTTP 1.0中, 而在HTTP 1.1中默认都是Keep-Alive的,所以不需要再添加'Connection: Keep-Alive'的头。所以在springboot1.5以上版本中,即使你在请求的header中加了'Connection: Keep-Alive'的头,在返回的header中是没有Connection的。相关链接:github.com/spring-proj…

  2. curl 命令可以使用--http1.0 来强制走http1.0协议。