最近學習了Http連接池


起因

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比較合適,根據線上運行的實際情況再做調整。

 


免責聲明!

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



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