作支付遇到的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
*   一、共享httpclient
*   二、增長超時時間
* @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();
        }
    }
 

  

擴展延伸

1、httpclient默認採用了鏈接池來管理鏈接,因此,若是採用這種策略,那麼connect_timeout參數通常沒什麼用,由於自己鏈接是以前已經創建好的,若是你自己沒有設置等待從鏈接池中獲取鏈接的超時時間(RequestConfig.ConnectionRequestTimeout),那麼你設置的超時時間是根本無論用的,由於那個SocketTimeout是獲取網絡鏈接以後請求發出以後纔會生效的參數;
2、其實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協議之上的,微信的退款是雙向認證,不一樣的商戶證書是不同的。太複雜,至今不敢相信咱們居然在沒有現場的狀況下發現這個缺陷;
 
其餘故障總結案例:
 
更多最新案例分析,請關注猿界汪汪隊
相關文章
相關標籤/搜索