做支付遇到的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協議之上的,微信的退款是雙向認證,不同的商戶證書是不一樣的。太復雜,至今不敢相信我們竟然在沒有現場的情況下發現這個缺陷;
 
其他故障總結案例:
 
更多最新案例分析,請關注猿界汪汪隊


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM