起因
6.1大促值班發現的一個問題,一個rpc介面在0~2點使用者下單高峰的時候表現rt高(超過1s,實際上針對性優化過的介面rt超過這個值也是有問題的,通常rpc介面裡面即使邏輯複雜,300ms應該也搞定了),可以理解,但是在4~5點的時候介面的tps已經不高了,耗時依然在600ms~700ms之間就不能理解了。
查了一下,裡面有段呼叫支付寶http介面的邏輯,但是每次都new一個HttpClient出來發起呼叫,呼叫時長大概在300ms+,所以導致即使在非高峰期介面耗時依然非常高。
問題不難,寫篇文章系統性地對這塊進行一下總結。
用不用執行緒池的差別
本文主要寫的是“池”對於系統效能的影響,因此開始連線池之前,可以以執行緒池的例子作為一個引子開始本文,簡單看下使不使用池的一個效果差別,程式碼如下:
/** * 執行緒池測試 * * @author 五月的倉頡https://www.cnblogs.com/xrq730/p/10963689.html */ public class ThreadPoolTest { private static final AtomicInteger FINISH_COUNT = new AtomicInteger(0); private static final AtomicLong COST = new AtomicLong(0); private static final Integer INCREASE_COUNT = 1000000; private static final Integer TASK_COUNT = 1000; @Test public void testRunWithoutThreadPool() { List<Thread> tList = new ArrayList<Thread>(TASK_COUNT); for (int i = 0; i < TASK_COUNT; i++) { tList.add(new Thread(new IncreaseThread())); } for (Thread t : tList) { t.start(); } for (;;); } @Test public void testRunWithThreadPool() { ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 100, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); for (int i = 0; i < TASK_COUNT; i++) { executor.submit(new IncreaseThread()); } for (;;); } private class IncreaseThread implements Runnable { @Override public void run() { long startTime = System.currentTimeMillis(); AtomicInteger counter = new AtomicInteger(0); for (int i = 0; i < INCREASE_COUNT; i++) { counter.incrementAndGet(); } // 累加執行時間 COST.addAndGet(System.currentTimeMillis() - startTime); if (FINISH_COUNT.incrementAndGet() == TASK_COUNT) { System.out.println("cost: " + COST.get() + "ms"); } } } }
邏輯比較簡單:1000個任務,每個任務做的事情都是使用AtomicInteger從0累加到100W。
每個Test方法執行12次,排除一個最低的和一個最高的,對中間的10次取一個平均數,當不使用執行緒池的時候,任務總耗時為16693s;而當使用執行緒池的時候,任務平均執行時間為1073s,超過15倍,差別是非常明顯的。
究其原因比較簡單,相信大家都知道,主要是兩點:
- 減少執行緒建立、銷燬的開銷
- 控制執行緒的數量,避免來一個任務建立一個執行緒,最終記憶體的暴增甚至耗盡
當然,前面也說了,這只是一個引子引出本文,當我們使用HTTP連線池的時候,任務處理效率提升的原因不止於此。
用哪個httpclient
容易搞錯的一個點,大家特別注意一下。HttpClient可以搜到兩個類似的工具包,一個是commons-httpclient:
<dependency> <groupId>commons-httpclient</groupId> <artifactId>commons-httpclient</artifactId> <version>3.1</version> </dependency>
一個是httpclient:
<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.8</version> </dependency>
選第二個用,不要搞錯了,他們的區別在stackoverflow上有解答:
即commons-httpclient是一個HttpClient老版本的專案,到3.1版本為止,此後專案被廢棄不再更新(3.1版本,07年8.21釋出),它已經被歸入了一個更大的Apache HttpComponents專案中,這個專案版本號是HttpClient 4.x(4.5.8最新版本,19年5.30釋出)。
隨著不斷更新,HttpClient底層針對程式碼細節、效能上都有持續的優化,因此切記選擇org.apache.httpcomponents這個groupId。
不使用連線池的執行效果
有了工具類,就可以寫程式碼來驗證一下了。首先定義一個測試基類,等下使用連線池的程式碼演示的時候可以共用:
/** * 連線池基類 * * @author 五月的倉頡https://www.cnblogs.com/xrq730/p/10963689.html */ public class BaseHttpClientTest { protected static final int REQUEST_COUNT = 5; protected static final String SEPERATOR = " "; protected static final AtomicInteger NOW_COUNT = new AtomicInteger(0); protected static final StringBuilder EVERY_REQ_COST = new StringBuilder(200); /** * 獲取待執行的執行緒 */ protected List<Thread> getRunThreads(Runnable runnable) { List<Thread> tList = new ArrayList<Thread>(REQUEST_COUNT); for (int i = 0; i < REQUEST_COUNT; i++) { tList.add(new Thread(runnable)); } return tList; } /** * 啟動所有執行緒 */ protected void startUpAllThreads(List<Thread> tList) { for (Thread t : tList) { t.start(); // 這裡需要加一點延遲,保證請求按順序發出去 try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } } } protected synchronized void addCost(long cost) { EVERY_REQ_COST.append(cost); EVERY_REQ_COST.append("ms"); EVERY_REQ_COST.append(SEPERATOR); } }
接著看一下測試程式碼:
/** * 不使用連線池測試 * * @author 五月的倉頡https://www.cnblogs.com/xrq730/p/10963689.html */ public class HttpClientWithoutPoolTest extends BaseHttpClientTest { @Test public void test() throws Exception { startUpAllThreads(getRunThreads(new HttpThread())); // 等待執行緒執行 for (;;); } private class HttpThread implements Runnable { @Override public void run() { /** * HttpClient是執行緒安全的,因此HttpClient正常使用應當做成全域性變數,但是一旦全域性共用一個,HttpClient內部構建的時候會new一個連線池 * 出來,這樣就體現不出使用連線池的效果,因此這裡每次new一個HttpClient,保證每次都不通過連線池請求對端 */ CloseableHttpClient httpClient = HttpClients.custom().build(); HttpGet httpGet = new HttpGet("https://www.baidu.com/"); long startTime = System.currentTimeMillis(); try { CloseableHttpResponse response = httpClient.execute(httpGet); if (response != null) { response.close(); } } catch (Exception e) { e.printStackTrace(); } finally { addCost(System.currentTimeMillis() - startTime); if (NOW_COUNT.incrementAndGet() == REQUEST_COUNT) { System.out.println(EVERY_REQ_COST.toString()); } } } } }
注意這裡如註釋所說,HttpClient是執行緒安全的,但是一旦做成全域性的就失去了測試效果,因為HttpClient在初始化的時候預設會new一個連線池出來。
看一下程式碼執行效果:
324ms 324ms 220ms 324ms 324ms
每個請求幾乎都是獨立的,所以執行時間都在200ms以上,接著我們看一下使用連線池的效果。
使用連線池的執行結果
BaseHttpClientTest這個類保持不變,寫一個使用連線池的測試類:
/** * 使用連線池測試 * * @author 五月的倉頡https://www.cnblogs.com/xrq730/p/10963689.html */ public class HttpclientWithPoolTest extends BaseHttpClientTest { private CloseableHttpClient httpClient = null; @Before public void before() { initHttpClient(); } @Test public void test() throws Exception { startUpAllThreads(getRunThreads(new HttpThread())); // 等待執行緒執行 for (;;); } private class HttpThread implements Runnable { @Override public void run() { HttpGet httpGet = new HttpGet("https://www.baidu.com/"); // 長連線標識,不加也沒事,HTTP1.1預設都是Connection: keep-alive的 httpGet.addHeader("Connection", "keep-alive"); long startTime = System.currentTimeMillis(); try { CloseableHttpResponse response = httpClient.execute(httpGet); if (response != null) { response.close(); } } catch (Exception e) { e.printStackTrace(); } finally { addCost(System.currentTimeMillis() - startTime); if (NOW_COUNT.incrementAndGet() == REQUEST_COUNT) { System.out.println(EVERY_REQ_COST.toString()); } } } } private void initHttpClient() { PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); // 總連線池數量 connectionManager.setMaxTotal(1); // 可為每個域名設定單獨的連線池數量 connectionManager.setMaxPerRoute(new HttpRoute(new HttpHost("www.baidu.com")), 1); // setConnectTimeout表示設定建立連線的超時時間 // setConnectionRequestTimeout表示從連線池中拿連線的等待超時時間 // setSocketTimeout表示發出請求後等待對端應答的超時時間 RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(1000).setConnectionRequestTimeout(2000) .setSocketTimeout(3000).build(); // 重試處理器,StandardHttpRequestRetryHandler這個是官方提供的,看了下感覺比較挫,很多錯誤不能重試,可自己實現HttpRequestRetryHandler介面去做 HttpRequestRetryHandler retryHandler = new StandardHttpRequestRetryHandler(); httpClient = HttpClients.custom().setConnectionManager(connectionManager).setDefaultRequestConfig(requestConfig) .setRetryHandler(retryHandler).build(); // 服務端假設關閉了連線,對客戶端是不透明的,HttpClient為了緩解這一問題,在某個連線使用前會檢測這個連線是否過時,如果過時則連線失效,但是這種做法會為每個請求 // 增加一定額外開銷,因此有一個定時任務專門回收長時間不活動而被判定為失效的連線,可以某種程度上解決這個問題 Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { try { // 關閉失效連線並從連線池中移除 connectionManager.closeExpiredConnections(); // 關閉30秒鐘內不活動的連線並從連線池中移除,空閒時間從交還給連線管理器時開始 connectionManager.closeIdleConnections(20, TimeUnit.SECONDS); } catch (Throwable t) { t.printStackTrace(); } } }, 0 , 1000 * 5); } }
這個類詳細地演示了HttpClient的用法,相關注意點都寫了註釋,就不講了。
和上面一樣,看一下程式碼執行效果:
309ms 83ms 57ms 53ms 46ms
看到除開第一次呼叫的309ms以外,後續四次呼叫整體執行時間大大提升,這就是使用了連線池的好處,接著,就探究一下使用連線池提升整體效能的原因。
繞不開的長短連線
說起HTTP,必然繞不開的一個話題就是長短連線,這個話題之前的文章已經寫了好多次了,這裡再寫一次。
我們知道,從客戶端發起一個HTTP請求到服務端響應HTTP請求之間,大致有以下幾個步驟:
HTTP1.0最早在網頁中使用是1996年,那個時候只是使用一些較為簡單的網頁和網路的請求,每次請求都需要建立一個單獨的連線,上一次和下一次請求完全分離。這種做法,即使每次的請求量都很小,但是客戶端和服務端每次建立TCP連線和關閉TCP連線都是相對比較費時的過程,嚴重影響客戶端和服務端的效能。
基於以上的問題,HTTP1.1在1999年廣泛應用於現在的各大瀏覽器網路請求中,同時HTTP1.1也是當前使用最為廣泛的HTTP協議(2015年誕生了HTTP2,但是還未大規模應用),這裡不詳細對比HTTP1.1針對HTTP1.0改進了什麼,只是在連線這塊,HTTP1.1支援在一個TCP連線上傳送多個HTTP請求和響應,減少了建立和關閉連線的消耗延遲,一定程度上彌補了HTTP1.0每次請求都要建立連線的缺點,這就是長連線,HTTP1.1預設使用長連線。
那麼,長連線是如何工作的呢?首先,我們要明確一下,長短連線是通訊層(TCP)的概念,HTTP是應用層協議,它只能說告訴通訊層我打算一段時間內複用TCP通道而沒有自己去建立、釋放TCP通道的能力。那麼HTTP是如何告訴通訊層複用TCP通道的呢?看下圖:
分為以下幾個步驟:
- 客戶端傳送一個Connection: keep-alive的header,表示需要保持連線
- 客戶端可以順帶Keep-Alive: timeout=5,max=100這個header給服務端,表示tcp連線最多保持5秒,長連線接受100次請求就斷開,不過瀏覽器看了一些請求貌似沒看到帶這個引數的
- 服務端必須能識別Connection: keep-alive這個header,並且通過Response Header帶同樣的Connection: keep-alive,告訴客戶端我可以保持連線
- 客戶端和服務端之間通過保持的通道收發資料
- 最後一次請求資料,客戶端帶Connection:close這個header,表示連線關閉
至此在一個通道上交換資料的過程結束,在預設的情況下:
- 長連線的請求數量限定是最多連續傳送100個請求,超過限制即關閉這條連線
- 長連線連續兩個請求之間的超時時間是15秒(存在1~2秒誤差),超時後會關閉TCP連線,因此使用長連線應當儘量保持在13秒之內傳送一個請求
這些的限制都是在重用長連線與長連線過多之間做的一個折衷,因為長連線雖好,但是長時間的TCP連線容易導致系統資源無效佔用,浪費系統資源。
最後這個地方多說一句http的keep-alive和tcp的keep-alive的區別,一個經常講的問題,順便記錄一下:
- http的keep-alive是為了複用已有連線
- tcp的keep-alive是為了保活,即保證對端還存活,不然對端已經不在了我這邊還佔著和對端的這個連線,浪費伺服器資源,做法是隔一段時間傳送一個心跳包到對端伺服器,一旦長時間沒有接收到應答,就主動關閉連線
效能提升的原因
通過前面的分析,很顯而易見的,使用HTTP連線池提升效能最重要的原因就是省去了大量連線建立與釋放的時間,除此之外還想說一點。
TCP建立連線的時候有如下流程:
如圖所示,這裡面有兩個佇列,分別為syns queue(半連線佇列)與accept queue(全連線佇列),這裡面的流程就不細講了,之前我有文章https://www.cnblogs.com/xrq730/p/6910719.html專門寫過這個話題。
一旦不使用長連線而每次連線都重新握手的話,佇列一滿服務端將會傳送一個ECONNREFUSED錯誤資訊給到客戶端,相當於這次請求就失效了,即使不失效,後來的請求需要等待前面的請求處理,排隊也會增加響應的時間。
By the way,基於上面的分析,不僅僅是HTTP,所有應用層協議,例如資料庫有資料庫連線池、hsf提供了hsf介面連線池,使用連線池的方式對於介面效能都是有非常大的提升的,都是同一個道理。
TLS層的優化
上面講的都是針對應用層協議使用連線池提升效能的原因,但是對於HTTP請求,我們知道目前大多數網站都執行在HTTPS協議之上,即在通訊層和應用層之間多了一層TLS:
通過TLS層對報文進行了加密,保證資料安全,其實在HTTPS這個層面上,使用連線池對效能有提升,TLS層的優化也是一個非常重要的原因。
HTTPS原理不細講了,反正大致上就是一個證書交換-->服務端加密-->客戶端解密的過程,整個過程中反覆地客戶端+服務端交換資料是一個耗時的過程,且資料的加解密是一個計算密集型的操作消耗CPU資源,因此如果相同的請求能省去加解密這一套就能在HTTPS協議下對整個效能有很大提升了,實際上這種優化是有的,這裡用到了一種會話複用的技術。
TLS的握手由客戶端傳送Client Hello訊息開始,服務端返回Server Hello結束,整個流程中提供了2種不同的會話複用機制,這個地方就簡單看一下,知道有這麼一回事:
- session id會話複用----對於已建立的TLS會話,使用session id為key(來自第一次請求的Server Hello中的session id),主金鑰為value組成一對鍵值對儲存在服務端和客戶端的本地。當第二次握手時,客戶端如果想複用會話,則發起的Client Hello中帶上session id,服務端收到這個session id檢查本地是否存在,有則允許會話複用,進行後續操作
- session ticket會話複用----一個session ticket是一個加密的資料blob,其中包含需要重用的TLS連線資訊如session key等,它一般使用ticket key加密,因為ticket key服務端也知道,在初始化握手中服務端傳送一個session ticket到客戶端並儲存到客戶端本地,當會話重用時,客戶端傳送session ticket到服務端,服務端解密成功即可複用會話
session id的方式缺點是比較明顯的,主要原因是負載均衡中,多機之間不同步session,如果兩次請求不落在同一臺機器上就無法找到匹配資訊,另外服務端儲存大量的session id又需要消耗很多資源,而session ticket是比較好解決這個問題的,但是最終使用的是哪種方式還是有瀏覽器決定。關於session ticket,在網上找了一張圖,展示的是客戶端第二次發起請求,攜帶session ticket的過程:
一個session ticket超時時間預設為300s,TLS層的證書交換+非對稱加密作為效能消耗大戶,通過會話複用技術可以大大提升效能。
使用連線池的注意點
使用連線池,切記每個任務的執行時間不要太長。
因為HTTP請求也好、資料庫請求也好、hsf請求也好都是有超時時間的,比如連線池中有10個執行緒,併發來了100個請求,一旦任務執行時間非常長,連線都被先來的10個任務佔著,後面90個請求遲遲得不到連線去處理,就會導致這次的請求響應慢甚至超時。
當然每個任務的業務不一樣,但是按照我的經驗,儘量把任務的執行時間控制在50ms最多100ms之內,如果超出的,可以考慮以下三種方案:
- 優化任務執行邏輯,比如引入快取
- 適當增大連線池中的連線數量
- 任務拆分,將任務拆分為若干小任務
連線池中的連線數量如何設定
有些朋友可能會問,我知道需要使用連線池,那麼一般連線池數量設定為多少比較合適?有沒有經驗值呢?首先我們需要明確一個點,連線池中的連線數量太多不好、太少也不好:
- 比如qps=100,因為上游請求速率不可能是恆定不變的100個請求/秒,可能前1秒900個請求,後9秒100個請求,平均下來qps=100,當連線數太多的時候,可能出現的場景是高流量下建立連線--->低流量下釋放部分連線--->高流量下重新建立連線的情況,相當於雖然使用了連線池,但是因為流量不均勻反覆建立連線、釋放連結
- 執行緒數太少當然也是不好的,任務多而連線少,導致很多工一直在排隊等待前面的執行完才可以拿到連線去處理,降低了處理速度
那針對連線池中的連線數量如何設定的這個問題,答案是沒有固定的,但是可以通過估算得到一個預估值。
首先開發同學對於一個任務每天的呼叫量心中需要有數,假設一天1000W次好了,線上有10臺伺服器,那麼平均到每臺伺服器每天的呼叫量在100W,100W平均到1天的86400秒,每秒的呼叫量1000000 / 86400 ≈ 11.574次,根據介面的一個平均響應時長適當加一點餘量,差不多設定在15~30比較合適,根據線上執行的實際情況再做調整。