最近學習了Http鏈接池

原由html

6.1大促值班發現的一個問題,一個rpc接口在0~2點用戶下單高峯的時候表現rt高(超過1s,實際上針對性優化過的接口rt超過這個值也是有問題的,一般rpc接口裏面即便邏輯複雜,300ms應該也搞定了),能夠理解,可是在4~5點的時候接口的tps已經不高了,耗時依然在600ms~700ms之間就不能理解了。數據庫

查了一下,裏面有段調用支付寶http接口的邏輯,可是每次都new一個HttpClient出來發起調用,調用時長大概在300ms+,因此致使即便在非高峯期接口耗時依然很是高。apache

問題不難,寫篇文章系統性地對這塊進行一下總結。瀏覽器

 

用不用線程池的差異緩存

本文主要寫的是「池」對於系統性能的影響,所以開始鏈接池以前,能夠以線程池的例子做爲一個引子開始本文,簡單看下使不使用池的一個效果差異,代碼以下:安全

/**
 * 線程池測試
 * 
 * @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倍,差異是很是明顯的。網絡

究其緣由比較簡單,相信你們都知道,主要是兩點:session

  • 減小線程建立、銷燬的開銷
  • 控制線程的數量,避免來一個任務建立一個線程,最終內存的暴增甚至耗盡

固然,前面也說了,這只是一個引子引出本文,當咱們使用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(全鏈接隊列),這裏面的流程就不細講了,以前我有文章http://www.javashuo.com/article/p-altbpfqa-dy.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比較合適,根據線上運行的實際狀況再作調整。

相關文章
相關標籤/搜索