京東618大促壓測時自研中間件暴露出的問題總結,壓測級別數十萬/秒

前天618大促演練進行了全鏈路壓測,在此之前剛好我的熱key探測框架(點擊可跳轉到開源地址)也已經上線灰度一週了,小範圍上線了幾千臺服務器,每秒大概接收幾千個key探測,每天大概幾億左右,因爲量很小,所以框架表現穩定。藉着這次壓測,剛好可以檢驗一下熱key框架在大流量時的表現。畢竟作爲一個新的中間件,裏面很多東西還是第一次用,免不得會出一些問題。

壓測期,我沒有去擴容熱key的worker集羣,還是平時用的3個16C+1個4C8G的組合,3個16核是是主力,4核的是看上限能到什麼樣。

由於之前那一週的平穩表現,導致我有點大意了,沒再去好好檢查代碼。導致實際壓測期間表現有點慘淡。

框架的架構如下:

cmd-markdown-logo

大概0點多壓測開始,初始量比較小,從10w/s開始壓,當然都是壓的APP的後臺,我的框架只是被動的接收後臺發來的熱key探測請求而已。我主要檢測的就是worker集羣,也就是那4臺機器的情況。

從壓測開始的一瞬間,那臺4核8G的機器就cpu100%,16核的cpu在90%以上,4核的100%即便在壓測暫停的間隙也沒有恢復,一直都是100%,無論是10w/s,還是後期到大幾十萬/s。16核的在20w/s以上時也開始cpu100%,整體卡到不行了已經,連10秒一次的定時任務都卡的不走了,導致定時註冊自己到etcd的任務都停了,再導致etcd裏把自己註冊信息過期刪除,大量和client斷連。

然後dashboard控制檯監聽etcd熱key信息的監聽器也出了大問題,熱key產生非常密集,導致dashboard將熱key入庫卡頓,甚至於入庫時,都已經過期1分鐘多了,導致插入數據庫的時間全部是錯的。

雖然worker問題蠻多,也蠻嚴重,但好在etcd集羣穩如老狗,除了1分鐘一次的熱key密集過期導致cpu有個小尖峯,別的都非常穩定,接收、推送都很穩,client端表現也可以,沒有什麼異常表現。

其中etcd真的很不錯,比想象中的更好,有圖爲證:

worker呢就是這樣子

後來經過一系列操作,我還樂觀的修改上線了一版,然後沒什麼用,在100%上穩的一匹。

後來經過我一天的研究分析,發現當時沒找到關鍵點,改的效果不明顯。當然後來我自我感覺找到問題點了,又修改了一些,有待下次壓測檢驗。

這一篇就是針對各個發現的問題進行總結,包括壓測期間的和之前灰度期間發現的一些。總的來說,無論書上寫的、博客寫的,各路這個說的那個說的雖然在本地跑的時候各種正常,但真正在大流量面前,未必能對。還有一些知名框架,參數配不好,效果未必達到預期。

平時發現的問題列表

先說壓測前小流量時的問題

在worker端,會密集收到client發來的請求。其中有代碼邏輯爲先後取系統時間戳,居然有後取的時間戳小於前面的時間戳的情況(罕見、不能復現),猜測爲docker時間對齊問題。造成時間戳相減爲負值,代碼數組越界,cpu瞬間達到100%。注意,這可是單線程的!

  解決:問題雖然很奇葩,但很好解決,爲負時,按0處理。

使用網上找的的netty自定義協議(我前幾天還轉過那篇問題),在本地測試以及線上灰度測試時,表現穩健。但在全量上線後,2千臺client情況下,出現過單worker關閉一段時間並重啓後,瞬間收到高達數GB的流量包,直接打爆內存,導致worker停機,後續無法啓動的情況。

  解決:書上及網上均未找到相關解決方案,類似場景別人極難遇到。後通過使用netty官方自帶協議StringDecoder加分隔符後,未復現突傳大包的情況,目前線上表現穩定。

Netty client是可以反覆連接同一個server的,造成單個client對單個server產生多個長連接的情況,使得server的tcp連接數遠遠大於client的總數量。此前書上、網絡教程等各個地方均未提及該情況。使得誤認爲,client對server僅會保持一個長連接。

  解決:對client的連接進行排重、加鎖,避免client反覆連接同一個server。

Netty server在推送信息到大量client時,會造成cpu瞬間飆升至60-100%,推送完畢後cpu下降至正常值

  解決:在推送時,避免做循環體內json序列化操作,應在循環體外進行

在複用netty創建出來的ByteBuf對象時,反覆的使用它,會出現大量的報錯。原因是對ByteBuf對象瞭解不深,該對象和普通的Java對象不一樣,Java對象是可以傳遞下去反覆使用的,不存在使用後銷燬的情況,而ByteBuf在被netty傳出去後,就銷燬了,裏面攜帶的字節組就沒了

  解決:每次都創建新的ByteBuf對象,不要複用它。

2千臺client在監聽到worker發生變化後,會同時瞬間去連接它,和平時上線時,每次幾百臺緩慢連接server的場景不同,突發瞬間數千連接時,可能發生server丟失一部分連接,導致部分client連接失敗。

  解決:不再採用監聽的方式,而採用定時輪訓的方式,錯開連接的時機,對連不上的worker進行本地保存,後加一個定時任務,定時去連接那些沒連上的server。

7 worker機器佔用的內存持續增長,超過給docker分配的內存後,被系統殺死進程

  解決:worker全部是部署在docker裏的,剛開始我是沒有給它配JVM參數的,譬如那個4核8G的,我只是將它部署上去,就沒有管它了。隨後,它的內存在持續穩定上漲,從未下降。直到內存爆滿。後來經進入到容器內部,執行查看內存命令,發現雖然docker是4核8G的,但是宿主機是250G的。JVM採用默認的內存分配策略,初始分配1/64的內存,最大分配1/4的內存。但是是按250G進行分配的,導致jvm不斷擴容再擴容,直到1/4 * 250G,在到達docker分配的8G時就被殺死了。後來給容器配置了JVM參數後,內存平穩。這塊帶來的經驗教訓就是,一定要給自己的程序配JVM,不然JVM按默認的執行,後果就不可控了。

壓測發現的問題列表

前面發現的多是代碼邏輯和配置問題,壓測期間主要是cpu100%的問題,也列一下。

1 netty線程數巨多、disruptor線程數也巨多,導致cpu100%

  問題描述:worker部署的jdk版本是1.8.20,注意,這個版本是在1.8裏算比較老的版本。worker裏面作爲netty server啓動,我是沒有給它配線程池的(如圖,之前boss和worker我都沒有指定線程數量),所以它走的就是默認Runtime.getRuntime().availableProcessors() * 2。這個是系統獲取核數的代碼,在jdk1.8.31之前,docker容器內的這段代碼獲取到的是宿主機的核數,而非給容器分配的核數!!!譬如我的程序取到的就是32核,而非分配的4核。再乘以2後,變成了64個線程。導致netty boss和worker線程數高達64,另外我還用了disruptor,disruptor的consumer數量也是64!導致壓測一開始,瞬間cpu切換及其繁忙,大量的空轉。大家都知道,cpu密集型的應用,線程數最好比較小,等於核數是比較合適的,而我的程序線程數高達180,cpu全部用於輪轉了。

  之後我增加了判斷jdk版本的邏輯,jdk1.8.31後的獲取到的availableProcessors就是對的了,並且我限制了bossGroup的線程數爲1.再次上線後,cpu明顯有下降!

  帶來的經驗教訓是,用docker時,需要注意jdk版本,尤其是有獲取系統核數的代碼作爲邏輯時。cpu密集型的,切勿搞很多線程。

2 cpu持續100%,導致定時任務都不執行了

  和第一個問題是連鎖的,因爲worker接收到的請求非常密集,每秒達10萬以上,而cpu已經全部用於N個線程的輪轉了,真正工作的都沒了,我的一個很輕的定時任務5s上傳一次worker自己的ip信息到配置中心etcd,連這個定時任務都工作不ok了,通過jstack查看,一直處於wait狀態。之後導致etcd裏該worker信息過期被刪除,再導致2千多個client從etcd沒取到該worker註冊信息,就把它給刪掉了,發生了大量client沒有和worker進行連接。

  可見,cpu滿時,什麼都不靠譜了,核心功能都會阻塞。

3 caffeine密集擴容,耗費cpu大

  因爲worker裏是用caffeine來存儲各client發來的key信息的,之後讀取caffeine進行存取。caffeine底層是用ConcurrentHashMap來進行的數據存儲,大家都知道HashMap擴容的事,擴容2倍,就要進行一次copy,裏面動輒幾十萬個key,擴容resize時,cpu會佔用比較大。尤其是cpu本身負荷很重時,這一步也會卡住。

  我的worker給caffeine分配的最大500萬容量,雖然不是很大,但卡頓時,resize這一步執行很慢。不過這個不是什麼大問題,也沒有什麼好修復的,就保持這樣就行。

4 caffeine在密集失效時,老版本jdk下,caffeine默認的forkJoinPool有bug

  caffeine我是設置的寫入後一分鐘過期,因爲是密集寫入,自然也會密集失效。caffeine採用線程池進行過期刪除,不指定線程池時採用默認的forkJoinPool。問題是什麼呢,大家自己也能試出來。搞個死循環往caffeine裏寫值,然後看它的失效。在jdk1.8.20之前,這個forkJoinPool存在不提交任務的bug,導致key過期後未被刪除。進而caffeine容量爆滿超過閾值,發生內存溢出。架構師針對該問題給caffeine官方提了issue,對方回覆,請勿過於密集寫入caffeine,寫入過快時,刪除跟不上。還需要升級jdk,至少要超過1.8.20.不然forkJoinPool也有問題。

5 disruptor消費慢

  大名鼎鼎的disruptor實際表現並不如名氣那麼好,很多文章都是在講disruptor怎麼怎麼牛x,一秒幾百萬。在worker裏的用法是這樣的,netty的worker線程池接收到請求後,統一全部發到disruptor裏,然後我搞cpu核數個線程來消費這些請求,做計算熱key數量的操作。而壓測期間,cpu100%時,幾乎所有的線程都卡在了disruptor生產上。即N個線程在這個生產者獲取next序列號時卡住,原因很簡單,就是沒消費完,生產者阻塞。我設置的disruptor的隊列長度爲100萬,實際應該寫不滿這個隊列,但不知道爲什麼還是大量卡在了這個地方。該問題有待下次壓測時檢驗。

6 有個定時任務裏面有耗大量cpu的方法

  之前爲了統計caffeine的容量和佔用的內存,我搞了個定時任務10秒一次上傳caffeine的內存佔用。就是被註釋掉的那行,上線後坑到我了,那一句特別耗cpu。趕緊刪掉,避免這種測試性質的代碼誤上線,佔用大量資源。

7 數據庫寫入速度跟不上熱key產生的速度

  我是有個地方在監聽etcd裏熱key的,每當有新key產生時,就會往數據庫裏插值。結果由於key瞬間來了好幾千個,數據庫處理不過來,導致大量的阻塞,等輪到這條key信息插入時,早就已經過期了,造成數據庫裏的數據全是錯的。

  這個問題比較好解決,可以做批量入庫,加個隊列緩衝就好了。

初步總結

其實裏面有很多本地永遠無法出現的問題,譬如時間戳的那個,還有一些問題是jdk版本的,還有是docker的。但最終都可以歸納爲,代碼不夠嚴謹,沒有充分考慮到這些可能會帶來問題的地方,譬如不配JVM參數。

但是不上線又怎麼都測試不出來這些問題,或者上線了量級不夠時也發現不了。這就說明一個穩定健壯的中間件,是需要打磨的,不是說書上抄了一段netty的代碼,上線了它就能正常運行了。

當然進步的過程其實就是踩坑的過程,有了相應的場景,實實在在的併發量,踩過足夠的坑,才能打磨好一個框架。

也希望有相關應用場景的同學,關注京東熱key探測框架。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章