HttpClient 在vivo內銷瀏覽器的高併發實踐優化

語言: CN / TW / HK


作者:vivo 網際網路伺服器團隊- Zhi Guangquan


HttpClient作為Java程式設計師最常用的Http工具,其對Http連線的管理能簡化開發,並且提升連線重用效率;在正常情況下,HttpClient能幫助我們高效管理連線,但在一些併發高,報文體較大的情況下,如果再遇到網路波動,如何保證連線被高效利用,有哪些優化空間。


一、問題現象


北京時間X月X日,瀏覽器資訊流服務監控出現異常,主要表現在以下三個方面:


  1. 從某個時間點開始,雲監控顯示部分Http介面的熔斷器被開啟,而且從明細列表可以發現問題機器:




2. 從PAAS平臺Hystrix熔斷管理介面中可以進一步確認問題機器的所有Http介面呼叫均出現了熔斷:

3. 日誌中心有大量從Http連線池獲取連線的異常:

org.apache.http.impl.execchain.RequestAbortedException: Request aborted。


二、問題定位


綜合以上三個現象,大概可以推測出問題機器的TCP連線管理出了問題,可能是虛擬機器問題,也可能是物理機問題;與運維與系統側溝通後,發現虛擬機器與物理機均無明顯異常,第一時間聯絡運維重啟了問題機器,線上問題得到解決。


2.1 臨時解決方案


幾天以後,線上部分其他機器也陸續出現了上述現象,此時基本可以確認是服務本身有問題;既然問題與TCP連線相關,於是聯絡運維在問題機器上建立了一個作業檢視TCP連線的狀態分佈:

netstat -ant|awk '/^tcp/ {++S[$NF]} END {for(a in S) print (a,S[a])}'


結果如下:


如上圖,問題機器的CLOSE_WAIT狀態的連線數已經接近200左右(該服務Http連線池最大連線數設定的250),那問題直接原因基本可以確認是CLOSE_WAIT狀態的連線過多導致的;本著第一時間先解決線上問題的原則,先把連線池調整到500,然後讓運維重啟了機器,線上問題暫時得到解決。


2.2 原因分析


調整連線池大小隻是暫時解決了線上問題,但是具體原因還不確定,按照以往經驗,出現連線無法正常釋放基本都是開發者使用不當,在使用完成後沒有及時關閉連線;但很快這個想法就被否定了,原因顯而易見:當前的服務已經在線上運行了一週左右,中間沒有經歷過發版,以瀏覽器的業務量,如果是連線使用完沒有及時關。


閉,250的連線數連一分鐘都撐不到就會被打爆。那麼問題就只能是一些異常場景導致的連線沒有釋放;於是,重點排查了下近期上線的業務介面,尤其是那種資料包體較大,響應時間較長的介面,最終把目標鎖定在了某個詳情頁優化介面上;先檢視處於CLOSE_WAIT狀態的IP與埠連線對,確認對方伺服器IP地址。

netstat-tulnap|grep CLOSE_WAIT



經過與合作方確認,目標IP均來自該合作方,與我們的推測是相符的。


2.3 TCP抓包


在定位問題的同時,也讓運維同事幫忙抓取了TCP的資料包,結果表明確實是客戶端(瀏覽器服務端)沒返回ACK結束握手,導致揮手失敗,客戶端處於了CLOSE_WAIT狀態,資料包的大小也與懷疑的問題介面相符。



為了方便大家理解,我從網上找了一張圖,大家可以作為參考:



CLOSE_WAIT是一種被動關閉狀態,如果是SERVER主動斷開的連線,那麼就會在CLIENT出現CLOSE_WAIT的狀態,反之同理;


通常情況下,如果客戶端在一次http請求完成後沒有及時關閉流(tcp中的流套接字),那麼超時後服務端就會主動傳送關閉連線的FIN,客戶端沒有主動關閉,所以就停留在了CLOSE_WAIT狀態,如果是這種情況,很快連線池中的連線就會被耗盡。


所以,我們今天遇到的情況(處於CLOSE_WAIT狀態的連線數每天都在緩慢增長),更像是某一種異常場景導致的連線沒有關閉。


2.4 獨立連線池


為了不影響其他業務場景,防止出現系統性風險,我們先把問題介面連線池進行了獨立管理。


2.5 深入分析


帶著2.3的疑問我們仔細檢視一下業務呼叫程式碼:

try {        httpResponse = HttpsClientUtil.getHttpClient().execute(request);        HttpEntity httpEntity = httpResponse.getEntity();        is = httpEntity.getContent();     }catch (Exception e){        log.error("");     }finally {        IOUtils.closeQuietly(is);        IOUtils.closeQuietly(httpResponse);     }


這段程式碼存在一個明顯的問題:既關閉了資料傳輸流( IOUtils.closeQuietly(is)),也關閉了整個連線(IOUtils.closeQuietly(httpResponse)),這樣我們就沒辦法進行連線的複用了;但是卻更讓人疑惑了:既然每次都手動關閉了連線,為什麼還會有大量CLOSE_WAIT狀態的連線存在呢?


如果問題不在業務呼叫程式碼上,那麼只能是這個業務介面具有的某種特殊性導致了問題的發生;通過抓包分析發現該介面有一個明顯特徵:介面返回報文較大,平均在500KB左右。那麼問題就極有可能是報文過大導致了某種異常,造成了連線不能被複用也不能被釋放。


2.6 原始碼分析


開始分析之前,我們需要了解一個基礎知識:Http的長連線和短連線。所謂長連線就是建立起連線之後,可以複用連線多次進行資料傳輸;而短連線則是每次都需要重新建立連線再進行資料傳輸。


而通過對介面的抓包我們發現,響應頭裡有Connection:keep-live字樣,那我們就可以重點從HttpClient對長連線的管理入手來進行程式碼分析。


2.6.1 連線池初始化 


初始化方法:


進入PoolingHttpClientConnectionManager這個類,有一個過載構造方法裡包含連線存活時間引數:


順著繼續向下檢視:


manager的構造方法到此結束,我們不難發現validityDeadline會被賦值給expiry變數,那我們接下來就要看下HttpClient是在哪裡使用expiry這個引數的;


通常情況下,例項物件被構建出來的時候會初始化一些策略引數,此時我們需要檢視構建HttpClient例項的方法來尋找答案:


此方法包含一系列的初始化操作,包括構建連線池,給連線池設定最大連線數,指定重用策略和長連線策略等,這裡我們還注意到,HttpClient建立了一個非同步執行緒,去監聽清理空閒連線。


當然,前提是你打開了自動清理空閒連線的配置,預設是關閉的。



接著我們就看到了HttpClient關閉空閒連線的具體實現,裡面有我們想要看到的內容:



此時,我們可以得出第一個結論:可以在初始化連線池的時候,通過實現帶參的PoolingHttpClientConnectionManager構造方法,修改validityDeadline的值,從而影響HttpClient對長連線的管理策略。


2.6.2 執行方法入口


先找到執行入口方法:

org.apache.http.impl.execchain.MainClientExec.execute,看到了keepalive相關程式碼實現:


我們來看下預設的策略:



由於中間的呼叫邏輯比較簡單,就不在這裡一一把呼叫的鏈路貼出來了,這邊直接給結論:HttpClient對沒有指定連線有效時間的長連線,有效期設定為永久(Long.MAX_VALUE)。


綜合以上分析,我們可以得出最終結論:

HttpClient通過控制newExpiry和validityDeadline來實現對長連線的有效期的管理,而且對沒有指定連線有效時間的長連線,有效期設定為永久。


至此我們可以大膽給出一個猜測:長連線的有效期是永久,而因為某種異常導致長連線沒有被及時關閉,而永久存活了下來,不能被複用也不能被釋放。(只是根據現象的猜測,雖然最後被證實並不完全正確,但確實提高了我們解決問題的效率)。


基於此,我們也可以通過改變這兩個引數來實現對長連線的管理:


這樣簡單修改上線後,處於close_wait狀態的連線數沒有再持續增長,這個線上問題也算是得到了徹底的解決。

但此時相信大家也都存在一個疑問:作為被廣泛使用的開源框架,HttpClient難道對長連線的管理這麼粗糙嗎?一個簡單的異常呼叫就能導致整個排程機制徹底崩潰,而且不會自行恢復;


於是帶著疑問,再一次詳細查看了HttpClient的原始碼。


三、關於HttpClient


3.1 前言


開始分析之前,先簡單介紹下幾個核心類:


  • 【PoolingHttpClientConnectionManager】:連線池管理器類,主要作用是管理連線和連線池,封裝連線的建立、狀態流轉以及連線池的相關操作,是操作連線和連線池的入口方法;


  • 【CPool】:連線池的具體實現類,連線和連線池的具體實現均在CPool以及抽象類AbstractConnPool中實現,也是分析的重點;


  • 【CPoolEntry】:具體的連線封裝類,包含連線的一些基礎屬性和基礎操作,比如連線id,建立時間,有效期等;


  • 【HttpClientBuilder】:HttpClient的構造器,重點關注build方法;


  • 【MainClientExec】:客戶端請求的執行類,是執行的入口,重點關注execute方法;


  • 【ConnectionHolder】:主要封裝釋放連線的方法,是在PoolingHttpClientConnectionManager的基礎上進行了封裝。


3.2 兩個連線

  • 最大連線數(maxTotal)

  • 最大單路由連線數(maxPerRoute)

  • 最大連線數,顧名思義,就是連線池允許建立的最大連線數量;

  • 最大單路由連線數可以理解為同一個域名允許的最大連線數,且所有maxPerRoute的總和不能超過maxTotal。

    以瀏覽器為例,瀏覽器對接了頭條和一點,為了做到業務隔離,不相互影響,可以把maxTotal設定成500,而defaultMaxPerRoute設定成400,主要是因為頭條的業務介面量遠大於一點,defaultMaxPerRoute需要滿足呼叫量較大的一方。


3.3 三個超時

  • connectionRequestTimout

  • connetionTimeout

  • socketTimeout

【connectionRequestTimout】:指從連線池獲取連線的超時時間;

【connetionTimeout】:指客戶端和伺服器建立連線的超時時間,超時後會報

ConnectionTimeOutException異常;

【socketTimeout】:指客戶端和伺服器建立連線後,資料傳輸過程中資料包之間間隔的最大時間,超出後會丟擲SocketTimeOutException。

一定要注意:這裡的超時不是資料傳輸完成,而只是接收到兩個資料包的間隔時間,這也是很多線上詭異問題發生的根本原因。


3.4 四個容器

  • free

  • leased

  • pending

  • available


【free】:空閒連線的容器,連線還沒有建立,理論上freeSize=maxTotal -leasedSize

- availableSize(其實HttpClient中並沒有該容器,只是為了描述方便,特意引入的一個容器)。

【leased】:租賃連線的容器,連線建立後,會從free容器轉移到leased容器;也可以直接從available容器租賃連線,租賃成功後連線被放在leased容器中,此種場景主要是連線的複用,也是連線池的一個很重要的能力。

【pending】:等待連線的容器,其實該容器只是在等待連線釋放的時候用作阻塞執行緒,下文也不會再提到,感興趣的可以參考具體實現程式碼,其與connectionRequestTimout相關。

【available】:可複用連線的容器,通常直接從leased容器轉移過來,長連線的情況下完成通訊後,會把連線放到available列表,一些對連線的管理和釋放通常都是圍繞該容器進行的。

注:由於存在maxTotal和maxPerRoute兩個連線數限制,下文在提到這四種容器時,如果沒有帶字首,都代表是總連線數,如果是r.xxxx則代表是路由連線裡的某個容器大小。



maxTotal的組成


3.5 連線的產生與管理


  1. 迴圈從available容器中獲取連線,如果該連線未失效(根據上文提到的expiry欄位判斷),則把該連線從available容器中刪除,並新增到leased容器,並返回該連線;


  2. 如果在第一步中沒有獲取到可用連線,則判斷r.available + r.leased是否大於maxPerRoute,其實就是判斷是否還有free連線;如果不存在,則需要把多餘分配的連線釋放掉(r. available + r.leased - maxPerRoute),來保證真實的連線數受maxPerRoute控制(至於為什麼會出現r.leased+r.available>maxPerRoute的情況其實也很好理解,雖然在整個狀態流轉過程都加了鎖,但是狀態的流轉並不是原子操作,存在一些異常的場景都會導致狀態短時間不正確);所以我們可以得出結論,maxPerRoute只是一個理論上的最大數值,其實真實產生的連線數在短時間內是可能大於這個值的;


  3. 在真實的連線數(r .leased+ r .available)小於maxPerRoute且maxTotal>leased的情況下:如果free>0,則重新建立一個連線;如果free=0,則把available容器裡的最早建立的一個連線關閉掉,然後再重新建立一個連線;看起來有點繞,其實就是優先使用free容器裡的連線,獲取不到再釋放available容器裡的連線;


  4. 如果經過上述過程仍然沒有獲取到可用連線,那就只能等待一個connectionRequestTimout時間,或者有其他執行緒的訊號通知來結束整個獲取連線的過程。



3.6 連線的釋放


  1. 如果是長連線(reusable),則把該連線從leased容器中刪除,然後新增到available容器的頭部,設定有效期為expiry;

  2. 如果是短連線(non-reusable),則直接關閉該連線,並且從released容器中刪除,此時的連線被釋放,處於free容器中;

  3. 最後,喚醒“連線的產生與管理“第四部中的等待執行緒。


整個過程分析完,瞭解了httpclient如何管理連線,再回頭來看我們遇到的那個問題就比較清晰了:

正常情況下,雖然建立了長連線,但是我們會在finally程式碼塊裡去手動關閉,此場景其實是觸發了“連線的釋放”中的步驟2,連線直接被關閉;所以正常情況下是沒有問題的,長連線其實並沒有發揮真正的作用;


那問題自然就只能出現在一些異常場景,導致了長連線沒有被及時關閉,結合最初的分析,是服務端主動斷開了連線,那大概率出現在一些超時導致連線斷開的異常場景,我們再回到

org.apache.http.impl.execchain.MainClientExec這個類,發現這樣幾行程式碼:


connHolder.releaseConnection()對應“連線的釋放”中提到的步驟1,此時連線只是被放入了available容器,並且有效期是永久;


return new HttpResponseProxy(response, null)返回的ConnectionHolder是null,結合IOUtils.closeQuietly(httpResponse)的具體實現,連線並沒有及時關閉,而是永久的放在了available容器裡,並且狀態為CLOSE_WAIT,無法被複用;


根據 “連線的產生與管理”的步驟3的描述,在free容器為空的時候httpclient是能夠主動釋放available裡的連線的,即使連線永久的放在了available容器裡,理論上也不會造成連線永遠無法釋放;


然而再結合“連線的產生與管理”的步驟4,當free容器為空了以後,從連線池獲取連線時需要等待available容器裡的連線被釋放掉,整個過程是單執行緒的,效率極低,勢必會造成擁堵,最終導致大量等待獲取連線超時報錯,這也與我們線上看到的場景相吻合。


四、總結


  1. 連線池的主要功能有兩個:連線的管理和連線的複用,在使用連線池的時候一定要注意只需關閉當前資料流,而不要每次都關閉連線,除非你的目標訪問地址是完全隨機的;

  2. maxTotal和maxPerRoute的設定一定要謹慎,合理的分配引數可以做到業務隔離,但如果無法準確做出評估,可以暫時設定成一樣,或者用兩個獨立的httpclient例項;

  3. 一定記得要設定長連線的有效期,用

    PoolingHttpClientConnectionManager(60, TimeUnit.SECONDS)建構函式,尤其是呼叫量較大的情況,防止發生不可預知的問題;

  4. 可以通過設定evictIdleConnections(5, TimeUnit.SECONDS)定時清理空閒連線,尤其是http介面響應時間短,併發量大的情況下,及時清理空閒連線,避免從連線池獲取連線的時候發現連線過期再去關閉連線,能在一定程度上提高介面效能。


五、寫在最後


HttpClient作為當前使用最廣泛的基於Java語言的Http呼叫框架,在筆者看來其存在兩點明顯不足:


  1. 沒有提供監控連線狀態的入口,也沒有提供能外部介入動態影響連線生命週期的擴充套件點,一旦線上出現問題可能就是致命的;

  2. 此外,其獲取連線的方式是採用同步鎖的方式,在併發較高的情況下存在一定的效能瓶頸,而且其對長連線的管理方式存在問題,稍不注意就會導致建立大量異常長連線而無法及時釋放,造成系統性災難。


END

猜你喜歡

本文分享自微信公眾號 - vivo網際網路技術(vivoVMIC)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。