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