zoukankan      html  css  js  c++  java
  • 做支付遇到的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协议之上的,微信的退款是双向认证,不同的商户证书是不一样的。太复杂,至今不敢相信我们竟然在没有现场的情况下发现这个缺陷;
     
    其他故障总结案例:
     
    更多最新案例分析,请关注猿界汪汪队

  • 相关阅读:
    一个误解: 单个服务器程序可承受最大连接数“理论”上是“65535”
    Memcached 命令简介
    MySQL性能测试
    WCF 面向服务的SOAP消息
    WCF SOAP消息剖析
    深入探析 socket
    C#设计模式(适配器模式)
    LoadRunner中的异常处理
    反射调用性能比较(附源码)
    避免 TCP/IP 端口耗尽
  • 原文地址:https://www.cnblogs.com/donlianli/p/10954716.html
Copyright © 2011-2022 走看看