HttpClient遭遇Connection Reset异常分析(浅析部分源码)

背景

  前段时间测试反馈页面会偶先错误提示,刷新后就没有了。根据测试反馈的时间点查看kibana日志,发现有Connection reset异常。下面通过分析客户端和服务端相关源码进行分析根本原因,给出解决方案。
在这里插入图片描述

源码分析

HttpClient分析(version:4.5.13)

  1. 测试代码如下
    @Test
    public void shouldAnswerWithTrue() throws Exception {
    
        HttpClient httpClient = HttpClientBuilder.create()
                .build();
    
        HttpGet httpGet = new HttpGet("http://localhost:8080/demo/test");
    
        HttpResponse httpResponse = httpClient.execute(httpGet);
        System.out.println(EntityUtils.toString(httpResponse.getEntity(), "UTF-8"));
    
        Thread.sleep(2000);
    
        HttpResponse httpResponse1 = httpClient.execute(httpGet);
        System.out.println(EntityUtils.toString(httpResponse1.getEntity(), "UTF-8"));
    }
    
  2. 连接池获取连接
    2.1 调用栈截图如下在这里插入图片描述
    2.2 下面展示MainClientExec#execute相关代码
    public CloseableHttpResponse execute(
            final HttpRoute route,
            final HttpRequestWrapper request,
            final HttpClientContext context,
            final HttpExecutionAware execAware) throws IOException, HttpException {
     		/*
     		 ** 1. 从连接池CPool取出Future<CPoolEntry>
     		 **    ---> AbstractConnPool#lease
     		 ** 2. 返回ConnectionRequest
     		 **     2.1 操作上面future从连接池取连接
     		 */
     		final ConnectionRequest connRequest = connManager.requestConnection(route, userToken);
     		/**
     		   ** 1. 从连接池取连接:AbstractConnPool#getPoolEntryBlocking
     		           1.1 根据路由获取连接池:AbstractConnPool#getPool,没有则新建映射关系:routeToPool
     		           1.2 从连接池中获取空闲连接:RouteSpecificPool#getFree
     		           1.3 判断连接是否过期:PoolEntry#isExpired (见下面响应头的keep-alive的timeout值)
     		           1.4 如果没有,则创建该路由的连接:PoolingHttpClientConnectionManager#create,放入连接池中
     		   ** 2. 检查连接是否可用:BHttpConnectionBase#isStale
     		           2.1 检查间隔2s
     		           2.2 连接不是open状态时不需要检查
     		           2.3 尝试从socket读取数据,如果有问题重新执行第一步获取连接
     		HttpClientConnection managedConn = connRequest.get(timeout > 0 ? timeout : 0, TimeUnit.MILLISECONDS);
     		// 如果不是open状态,则建立路由连接:MainClientExec#establishRoute
     		establishRoute(proxyAuthState, managedConn, route, request, context);
     		/**
     		  ** 1. 发送请求:HttpRequestExecutor#doSendRequest
     		  ** 2. 处理响应:HttpRequestExecutor#doReceiveResponse
     		  		2.1 通过socket读取数据:SocketInputStream#read (异常点)
     		  		2.2 注意:如果在服务端连接关闭,此时执行SocketInputStream#read是没有异常
     		  */
     		response = requestExecutor.execute(request, managedConn, context);
     		// 如果请求头的Connection为keep-alive则为true:DefaultClientConnectionReuseStrategy#keepAlive
     		if (reuseStrategy.keepAlive(response, context)) {
     		    // 从响应头获取Keep-Alive,timeout=60s :DefaultConnectionKeepAliveStrategy#getKeepAliveDuration
     			final long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
     		}
     		
     }
    

tomcat分析(version:9.0.65)

在这里插入图片描述

  1. 在connector启动时,会开始acceptor线程等待接收请求(下面展示Acceptor#run重要节点逻辑)

    public void run() {
    	while (!stopCalled) {
    	
    		// 如果我们已经到达最大连接,等待(最大连接配置:server.tomcat.max-connections=1)
    		endpoint.countUpOrAwaitConnection();
    		
    		/** 
    		   ** 等待接收请求 NioEndpoint#serverSocketAccept
    		   ** 1. ServerSocketChannelImpl#serverSocketAccept 如果没有客户端请求则会阻塞
    		   */
    		socket = endpoint.serverSocketAccept();
    		/**
    		  ** 将socket传递给适当的处理器
    		  ** 1. 新建一个指定buffer的NioChannel : NioChannel channel = new NioChannel(bufhandler)
    		  ** 2. 将channel和endipoint包装为NioSocketWrapper,设置读写超时时间(默认1分钟)
    		  ** 3. NioEndpoint$Poller#register
    		  		3.1 socket注册SelectionKey.OP_READ事件
    		  **/
    		endpoint.setSocketOptions(socket)
    	}
    }
    
  2. NioEndpoint$Poller在初始化时开启selector,selector循环获取事件(监听读写事件)

    public void run() {
    	while (true) {
    		// selector获取注册的事件数
    		int keyCount = selector.select(selectorTimeout);
    		Iterator<SelectionKey> iterator =
                    keyCount > 0 ? selector.selectedKeys().iterator() : null;
             while (iterator != null && iterator.hasNext()) {
             	SelectionKey sk = iterator.next();
             	// 获取绑定在SelectionKey的socket
             	NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
             	
             	/** 
             	  ** 1. AbstractEndpoint#processSocket
             	  **     1.1 新建NioEndpoint$SocketProcessor
             	  **     1.2 将SocketProcessor丢入线程池执行
             	  */
             	processKey(sk, socketWrapper);
             }
    		/**
    		  ** 超时处理
    		  ** 1. 计算当前时间到上次socket读数据的时间的间隔:long delta = now - socketWrapper.getLastRead()
    		  ** 2. socket超时时间:long timeout = socketWrapper.getReadTimeout()
    		  ** 3. 如果delta>timeout则读超时,重置SocketProcessor的event为SocketEvent.ERROR
    		timeout(keyCount,hasEvents);
    	}
    }
    
  3. SocketProcessor最终会调业务的controller方法
    3.1 下面展示SocketProcessor#doRun部分代码

    protected void doRun() {
    	 if (event == null) {
                state = getHandler().process(socketWrapper, SocketEvent.OPEN_READ);
          } else {
          		/**
          		  ** AbstractProtocol#process
          		  ** 1. 如果SocketEvent为SocketEvent.ERROR,则修改SocketState为CLOSED
          		  */
                state = getHandler().process(socketWrapper, event);
        }
        // 超时,则AbstractEndpoint#countDownConnection连接次数减1,重新等待连接(参考上面acceptor线程)
        if (state == SocketState.CLOSED) {
              poller.cancelledKey(getSelectionKey(), socketWrapper);
          }
    }
    

    3.2 下图展示调用堆栈
    在这里插入图片描述

问题复现

  1. 启动springboot的web项目,在socket过期关闭的地方打上断点等待执行
    在这里插入图片描述
  2. 在通过socket连接发起http请求地方打上断点(MainClientExec#execute)
    2.1 第一个http请求直接跳过
    2.2 第二个同一个路由的http请求断点停留,等到服务端sokcet过期关闭该socket连接后执行
    在这里插入图片描述
    2.3 此时执行下一步SocketInputStream#socketRead就会捕获到ConnectionResetException异常
    在这里插入图片描述
  3. 最终在RetryExec#execute捕获异常并打印日志
    3.1 在捕获异常后,通过DefaultHttpRequestRetryHandler#retryRequest判断是否需要重试
    (1)默认连续重试三次
    (2)是否包含在不能重试的异常在这里插入图片描述
    3.2 打印出连接重置的debug日志
    在这里插入图片描述
    3.3 如果超过重试次数会打印如下日志
    在这里插入图片描述

束语

   虽然本地模拟复现了重置连接的异常,但只是抛出的debug日志,实际遇到异常却是error级别的,所以问题没有完全复现。在网上看到一篇类似的文章《HttpClient遭遇Connection Reset异常,如何正确配置》,对比发现确实自己也没有配空闲连接驱逐器,就死马当作活马医加了一下配置,发到生产查看效果,神奇的是发了之后确实再也没有发现该问题了。


版权声明:本文为weixin_40803011原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。