做支付遇到的HttpClient大坑

前言

HTTPClient大家应该都很熟悉,一个很好的抓网页,刷投票或者刷浏览量的工具。但是还有一项非常重要的功能就是外部接口调用,比如说发起微信支付,支付宝退款接口调用等;最近我们在这个工具上栽了一个大跟头,不怕大家笑话,拿出来跟大家分享一下;
过程描述
项目代码比较复杂,我为了直达问题,单独写了程序来说明;
我这里先重复一下导致问题的过程:程序源自于从.NET到Java的重构,开发使用了httpclient来调用微信支付的接口,设置了Httpclient的超时参数,为了提高性能,还遵循httpclient的推荐做法,将httpclient做成了单例;httpclient其他的参数都没有调整,使用的是默认参数;最终这种配置没能扛住网络的抖动,服务发生了雪崩。本篇博客也是“一个隐藏在支付系统很长时间的雷”的续篇;
 

缺陷复现

相信你对这个过程有很多疑点,下面我简化代码说一下这个问题;
我们现在要做的实验(demo)是这样的一个架构(先有架构才能显示出你是一名高级工程师,但是请原谅我简化的有点太简单)。
 
使用httpclient做客户端,然后使用多线程发起HTTP接口调用。为了模拟故障(包括网络故障和服务器服务故障),我们在服务器的接口sleep一段时间,然后观察服务器日志,如果客户端是多并发访问,httpclient是正常的。但如果客户端是一个一个请求过来的,那就说明使用httpclient的方式有问题。
好了,思路就是这样,我们开始通过代码来说明情况;
 
step1 服务器端程序
为了避免配置tomcat,我直接使用embed jetty,来启动一个8888端口的服务,这个服务什么都不做,就打印一下日志,然后sleep一下,出去时,再打印一次日志;一共两个类(如何引入maven依赖我就不写了);
public class JettyServerMain {
   public static void main(String[] args) throws Exception {
      Server server = new Server(8888);
      
      server.setHandler(new HelloHandler());

      server.start();
      server.join();
   }
}

class HelloHandler extends AbstractHandler {
   
   /**
    * 作为测试,在这个方法故意sleep 3秒,然后返回hello;
    */
   @Override
   public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
         throws IOException, ServletException {
      long threadId = Thread.currentThread().getId();
      Log.getLogger(this.getClass()).info("threadId="+threadId+" come in");
      try {
         Thread.sleep(3000);
      }
      catch(Exception e) {
         e.printStackTrace();
      }
      
      response.setStatus(HttpServletResponse.SC_OK);
      PrintWriter out = response.getWriter();

      out.println("hello+"+threadId);

      baseRequest.setHandled(true);
      Log.getLogger(this.getClass()).info("threadId="+threadId+" finish");
   }
}

  

 
step2 简化版httpclient(V1)
我们先写第一版的httpclient,即先通过httpclient调用一下刚才的程序,看是否好用;代码如下:
public class HTTPClientV1 {
    public static void main(String argvs[]){
        CloseableHttpClient httpClient = HttpClientBuilder.create().build();
        // 创建Get请求
        HttpGet httpGet = new HttpGet("http://localhost:8888");
        // 响应模型
        CloseableHttpResponse response = null;
        try {
            // 由客户端执行(发送)Get请求
            response = httpClient.execute(httpGet);
            // 从响应模型中获取响应实体
            HttpEntity responseEntity = response.getEntity();
           
            if (responseEntity != null) {
                System.out.println("响应内容为:" + EntityUtils.toString(responseEntity));
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                // 释放资源
                if (httpClient != null) {
                    httpClient.close();
                }
                if (response != null) {
                    response.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

  

step3 复用httpclient(V2)
我们从httpclient官方看到,推荐多线程复用httpclient;
 
因此,多线程复用httpclient单例,模拟同时发起10个请求;
public static void main(String argvs[]){  
    CloseableHttpClient httpClient = HttpClientBuilder.create().build();
    for(int i=0;i<10;i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                GetRequest(httpClient);
            }
        }).start();
    }
}

  

此时,应该允许一下看看效果;首选启动jetty,运行JettyServerMain
22:48:46.618 INFO  log: Logging initialized @897ms
22:48:46.655 INFO  Server: jetty-9.2.14.v20151106
22:48:47.051 INFO  ServerConnector: Started ServerConnector@5136ac92{HTTP/1.1}{0.0.0.0:8888}
22:48:47.052 INFO  Server: Started @1346ms

  

运行多线程请求HTTPClientV2,服务器端打印日志如下:
22:49:59.056 INFO  HelloHandler: threadId=15 come in
22:49:59.057 INFO  HelloHandler: threadId=14 come in
22:50:02.080 INFO  HelloHandler: threadId=14 finish
22:50:02.080 INFO  HelloHandler: threadId=15 finish
22:50:02.144 INFO  HelloHandler: threadId=15 come in
22:50:02.144 INFO  HelloHandler: threadId=19 come in
22:50:05.144 INFO  HelloHandler: threadId=19 finish
22:50:05.144 INFO  HelloHandler: threadId=15 finish
22:50:05.148 INFO  HelloHandler: threadId=19 come in
22:50:05.148 INFO  HelloHandler: threadId=14 come in
22:50:08.149 INFO  HelloHandler: threadId=19 finish
22:50:08.149 INFO  HelloHandler: threadId=14 finish
22:50:08.153 INFO  HelloHandler: threadId=15 come in
22:50:08.153 INFO  HelloHandler: threadId=19 come in
22:50:11.153 INFO  HelloHandler: threadId=19 finish
22:50:11.153 INFO  HelloHandler: threadId=15 finish
22:50:11.158 INFO  HelloHandler: threadId=14 come in
22:50:11.158 INFO  HelloHandler: threadId=19 come in
22:50:14.158 INFO  HelloHandler: threadId=19 finish
22:50:14.158 INFO  HelloHandler: threadId=14 finish

  

是不是感觉到有点惊奇?但从服务器端看,客户端在同一时间,只有2个请求过来,这两个请求完事之后,才会发下面的两个请求;如果服务器端sleep的不是3秒,而是10秒或者好几分钟,客户端会怎样?
step4 增加超时设置(V3)
能够想到超时,说明你一定是有一定技术储备的程序员了。核心代码如下:
// 创建Get请求
        HttpGet httpGet = new HttpGet("http://localhost:8888");
        RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(2000)
                .setConnectTimeout(2000)
                .build();
        httpGet.setConfig(requestConfig);

  

再跑一次,看看服务器端的输出
22:55:32.751 INFO  HelloHandler: threadId=15 come in
22:55:32.751 INFO  HelloHandler: threadId=14 come in
22:55:34.758 INFO  HelloHandler: threadId=19 come in
22:55:34.759 INFO  HelloHandler: threadId=21 come in
22:55:35.751 INFO  HelloHandler: threadId=15 finish
22:55:35.751 INFO  HelloHandler: threadId=14 finish
22:55:36.761 INFO  HelloHandler: threadId=23 come in
22:55:36.767 INFO  HelloHandler: threadId=14 come in
22:55:37.760 INFO  HelloHandler: threadId=19 finish
22:55:37.761 INFO  HelloHandler: threadId=21 finish
22:55:38.764 INFO  HelloHandler: threadId=15 come in
22:55:38.769 INFO  HelloHandler: threadId=19 come in
22:55:39.761 INFO  HelloHandler: threadId=23 finish
22:55:39.767 INFO  HelloHandler: threadId=14 finish
22:55:40.766 INFO  HelloHandler: threadId=21 come in
22:55:40.771 INFO  HelloHandler: threadId=23 come in
22:55:41.764 INFO  HelloHandler: threadId=15 finish
22:55:41.770 INFO  HelloHandler: threadId=19 finish
22:55:43.766 INFO  HelloHandler: threadId=21 finish
22:55:43.771 INFO  HelloHandler: threadId=23 finish

  

可以看到,因为有2秒的超时,所以在发起请求2秒后,服务器接收到后来的2个请求,此时服务器同时处理的请求有4个;为什么同时发起的有10个请求,服务器却做多同时只接收到4个请求呢?V3完整代码如下:
import java.io.IOException;

import org.apache.http.HttpEntity;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;

/**
* Date: 2019/5/22
* TIME: 21:25
* HTTPClient
*   1、共享httpclient
*   2、增加超时时间
* @author donlianli
*/
public class HTTPClientV3 {
    public static void main(String argvs[]){
        // 获得Http客户端(可以理解为:你得先有一个浏览器;注意:实际上HttpClient与浏览器是不一样的)
        CloseableHttpClient httpClient = HttpClientBuilder.create().build();
        
        for(int i=0;i<10;i++) {
        new Thread(new Runnable() {
@Override
public void run() {
GetRequest(httpClient);
}
        }).start();
        }
    }

private static void GetRequest(CloseableHttpClient httpClient) {
        // 创建Get请求
        HttpGet httpGet = new HttpGet("http://localhost:8888");
        RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(2000)
                .setConnectTimeout(2000)
                .build();
        httpGet.setConfig(requestConfig);
        // 响应模型
        CloseableHttpResponse response = null;
        try {
            // 由客户端执行(发送)Get请求
            response = httpClient.execute(httpGet);
            // 从响应模型中获取响应实体
            HttpEntity responseEntity = response.getEntity();
           
            if (responseEntity != null) {
                System.out.println("响应内容为:" + EntityUtils.toString(responseEntity));
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (response != null) {
                    response.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

}
}

  

这就是httpclient没有设置默认线程池的后果,赶快看看你们的代码是不是也有这个问题;
说到这边,有人说是因为连接池没有更改大小导致,其实是错误的,这个单独更改MaxTotal是不管用的,必须同时更改DefaultMaxPerRoute这个默认配置;
我们可以这样理解这两个参数,如果你访问的是一个域名,比如访问的是微信支付域名api.mch.weixin.qq.com,那么此时可以同时发起的请求受这两个参数影响。httpclient首先会从检查请求数是否超过DefaultMaxPerRoute,如果没有,则会再检查连接池中总连接数是否会超过MaxTotal大小。这两项都没有超过,才会新建立一个连接,反之则会等待连接池中其他线程释放。因此,同一时间向同一域名发起的总请求数<=DefaultMaxPerRoute<=MaxTotal;如果你使用httpclient不止向一个域名发起连接请求,那maxTotal会作为一个总的开关,来控制所有已经建立的网络连接数量;
还是上面的代码,如果想同时发起超过10个请求,就应该设置DefaultMaxPerRoute>10。代码(V5)如下:
   
 public static void main(String argvs[]){
    PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
    // 总连接数
    cm.setMaxTotal(200);
    // 这个至少要大于10
    cm.setDefaultMaxPerRoute(20);
        CloseableHttpClient httpClient = HttpClientBuilder.create()
        .setConnectionManager(cm).build();
        
        for(int i=0;i<10;i++) {
        new Thread(new Runnable() {
@Override
public void run() {
GetRequest(httpClient);
}
        }).start();
        }
    }
 

  

扩展延伸

一、httpclient默认采用了连接池来管理连接,所以,如果采用这种策略,那么connect_timeout参数一般没什么用,因为本身连接是之前已经建立好的,如果你本身没有设置等待从连接池中获取连接的超时时间(RequestConfig.ConnectionRequestTimeout),那么你设置的超时时间是根本不管用的,因为那个SocketTimeout是获取网络连接之后请求发出之后才会生效的参数;
二、其实httpclient是使用了池管理技术,连接数据库使用的dbcp,c3p0,阿里的druid,连接redis使用的jedis都采用了池技术,这3个参数在使用了池管理的组件中都存在。如果这些组件,没有设置这几个参数,一样会存在类似的问题;关于池管理技术,如果有空,我会再单独写一篇文章;
 
好了,整个过程已经复现完毕,三个重要参数也都解释的应该清楚;更多的参数设置及其含义,其实还能讲好几篇,我这里就不再细讲了,大家可以参考:https://blog.csdn.net/lovomap151/article/details/78879904
如果仍然有疑问,可以公众号(猿界汪汪队)私信我;所有用到的代码,可以在https://github.com/donlianli/easydig/tree/master/src/main/java/com/donlian/httpclient/defaultRoute 找到;
 
PS:其实在我们的支付项目中,这个问题隐藏的更深,支付和退款的超时不一样并且公用了同一个httpclient,退款把所有httpclient的连接都占用完毕导致用户无法支付;我们访问微信使用的https协议,https协议是构建在http协议之上的,微信的退款是双向认证,不同的商户证书是不一样的。太复杂,至今不敢相信我们竟然在没有现场的情况下发现这个缺陷;
 
其他故障总结案例:
 
更多最新案例分析,请关注猿界汪汪队

posted @ 2019-05-31 13:02 猿界汪汪队 阅读(...) 评论(...) 编辑 收藏