京東hotkey框架(JD-hotkey)是京東app後臺研發的一款高性能熱數據探測中間件,用來實時探測出系統的熱數據,並將熱數據毫秒內推送至系統的業務集群伺服器的JVM內存。以下統稱為&34;。
該框架主要用於對任意突發性的無法預先感知的熱key,包括並不限於熱點數據(如突發大量請求同一個商品)、熱用戶(如惡意爬蟲刷子)、熱接口(突發海量請求同一個接口)等,進行毫秒級精準探測到。然後對這些熱key,推送到所在服務端JVM內存中,以大幅減輕對後端數據存儲層的衝擊,並可以由使用者決定如何分配、使用這些熱key(譬如對熱商品做本地緩存、對熱用戶進行拒絕訪問、對熱接口進行熔斷或返回默認值)。這些熱數據在整個服務端集群內保持一致性,並且業務隔離,worker端性能強悍。
目前該框架已在京東App後臺、數據中臺、白條、金融、商家等多十餘個業務部門接入運行,目前應用最廣泛的場景是刷子(爬蟲)用戶實時探測和redis熱key實時探測。
由於框架自身核心點在於實時(準實時,可配置1-500ms內)探測系統運行中產生的熱key,而且還要面臨隨時可能突發的暴增流量(如突發搶購某商品),還要在探測出熱key後在毫秒時間內推送到該業務組的幾百、數千到上萬臺伺服器JVM內存中,所以對於它的單機性能要求非常高。
框架的計算單元worker端,它的工作流程是:啟動一個netty server,和業務伺服器(數百——上萬)建立長連接,業務伺服器批量定時(1——500ms一次)上傳自己的待測key給worker,worker對發來的key進行滑動窗口累加計算,達到用戶設置的閾值(如某個類型的key,pin_xxxx達到2秒20次),則將該key推送至整個業務伺服器集群內,從而業務伺服器可以在內存中使用這些熱key,到達設置的過期時間後會自動過期刪除。
截止目前,該框架0.4版本的性能表現如下(硬體配置為隨機機房創建的16核docker容器):
1 key探測計算:每秒可接收N臺伺服器發來的40多萬個待測key,並計算完畢其中的35萬左右。實測可每秒穩定計算30萬,極限計算37萬,超過的量會進入隊列等待。
2 熱key推送:當熱key產生後,對該業務集群所在長連接的伺服器進行key推送,每秒可穩定推送10-12萬次(毫秒內送達)。譬如1千臺伺服器,每秒該worker可以支撐產生100個熱key,即推送100*1000次。當每秒產生200個熱key,即每秒需要推送20萬次時,會出現有1s左右的延遲送達。強度壓測當每秒要推送50萬次時,出現了極其明顯的延遲,推送至client端的時間已不可控。在所有積壓的key推送完畢後,可繼續正常工作。
注意,推送是和接收30萬key並行的,也就是進和出加起來吞吐量在40多萬每秒。
以上為單機性能表現,通過橫向擴展,可以提升對應的處理量,不存在單點問題,橫向擴展中無性能瓶頸。以當前的性能表現,單機可以完成1000臺業務伺服器的熱key探測任務。系統設計之初,期望值是單機能支撐200臺業務器的日常計算,所以目前性能是高於預期的,在逐步的優化過程中,也遇到了很多問題,本文就是對這個過程中所遇到的與性能有關的問題和優化過程,做個記錄。
以下所有數據,不特殊指明的話,均默認是隨機機房的16核docker容器,屬於共享型資源,實際配置強於8核物理機,弱於16核物理機。
該版本採用的是jdk1.8.20(jdk的小版本影響很大),worker端採用的架構方式為netty默認參數+disruptor重複消費模式。
netty默認開啟cpu核數*2個線程,作為netty的工作線程,用於接收幾千臺機器發來的key,接收到待測key後,全部寫入到disruptor的生產者線程,生產者是單線程。之後disruptor同樣是cpu核數*2個消費者線程,每個消費者都重複消費每條發來的key。
這裡很明顯的問題,大家都能看到,disruptor為什麼要重複消費每個key?
因為當初的設想是將同一個key固定交給同一個線程進行累加計算,以避免多線程同時累加同一個key造成數量計算錯誤。所以每個消費者線程都全部消費所有的key,譬如8個線程,線程1隻處理key的hash值為1的,其他的return不處理。
該版本上線後,可連接3千臺業務伺服器,單機每秒處理幾千個key的情況下,cpu佔用率在20%多。猛一看,貌似還可以接受的樣子是嗎。
其實不是的,該版本隨後經歷了618大促壓測演練,首次壓測,該版本在壓測開始的一瞬間就已經生活不能自理了。
首次壓測量級只有10萬,我有4臺worker,平均每臺也就2萬多秒級key寫入,而cpu佔用率直接飆升至100%,系統卡的連10s一次的定時任務都不走了,完全殭屍狀態。
那麼問題在哪裡?僅通過猜測我們大概考慮是disruptor負載比較重,譬如是不是因為重複消費的問題?
要尋找問題在哪裡,進入容器內部,查看top的進程id,再使用top -H -p1234(1234為javaa進行pid),再使用jstack命令導出java各個線程的狀態。
在top -H這一步,我們看到有巨多的線程在佔用cpu,數量之多,令見者傷心、聞者落淚。
裡面有大量的如下
也就是說大量的disruptor消費者線程吃光了cpu,說好的百萬並發框架disruptor性能呢?
當然,這次的鍋不能給disruptor去背。這次的問題首要罪魁禍首是jdk版本問題,我們使用的1.8.0_20在docker容器內通過Runtime.getRuntime().availableProcessors()方法獲取到的數量是宿主機的核數,而不是分配的核數。
即便只分配這個容器4核8核,該方法取到的卻是宿主機的32核、64核,直接導致netty默認分配的高達128個線程,disruptor也是128個線程。這幾百個線程一開啟,平時空閒著就佔用20%多的cpu,壓測一開始,cpu直接原地起飛,忙於輪轉,瞬間癱瘓。
jdk在1.8.0_191之後,修復了容器內獲取cpu核數的問題,我們首先升級了jdk小版本,然後增加了對不同版本cpu核數的判斷邏輯,把線程數降了下來。
這其中我並沒有去修改disruptor所有線程都消費key的邏輯,保持了原樣。也就是說,16核機器,目前是32個netty IO線程,32個消費者業務線程。再次上線,cpu日常佔用率降低到7%-10%左右。
二次壓測開始,從下圖每10秒列印一次的日誌來看,單機秒級計算完畢的key在8萬多,同時秒級推送量在10萬左右。
此時cpu已經開始飄高了,當單機秒級達到14萬時,CPU接近跑滿狀態,此時已達線上平穩值上限10萬。其中佔用cpu較多的依舊是disruptor大量的線程。
考慮到線程數對性能影響還是很大,該應用作為純內存計算框架,是典型的cpu密集型,根本不需要那麼多的線程來做計算。
我大幅調低了netty的IO線程數和disruptor線程數,並隨後進行了多次實驗。首先netty的IO線程分別為4和8反覆測試,業務線程也調為4和8進行反覆測試。
最終得出結論,netty的IO線程在低於4時,秒級能接收到的key數量上限較低,4和8時區別不太大。而業務線程在高於8時,cpu佔用偏高。尤其是key發來的量比較少時,線程越多,cpu越高。注意,是發來的key更少,cpu更高,當完全沒有key發來時,cpu會比有key時更高。
原因在於disruptor它的策略就是會反覆輪詢隊列是否有可消費的數據,沒有數據時,它這個輪詢空耗cpu,即便等待策略是BlockingWaitStrategy。
所以,最終定下了IO線程和業務線程分別為8,即核數的一半。一半用來接收數據,一半用來做計算。
從圖上可以看到,秒級可以處理完畢16萬個key,cpu佔用率在40%。
加大壓力源後,實際處理量會繼續提升,但此時我對disruptor的實際表現並不滿意,總是在每百萬key左右就出現幾個發來的key在被消費時就已經超時(key發送時比key被處理時超過5秒)的情況。追查原因也無果,就是個別key好像是被遺忘在角落一樣,遲遲未被處理。
由於對disruptor不滿意,所以這一次直接丟棄了它,改為jdk自帶的LinkedBlockQueue,這是一個讀寫分別加鎖的隊列,比ArrayBlockQueue性能稍好,理論上不如disruptor性能好。
原因大家應該都清楚,首先ArrayBlockQueue讀寫同用一把鎖,LinkedBlockQueue是讀一把鎖、寫一把鎖,ArrayBlockQueue肯定是性能不如LinkedBlockQueue。
disruptor採用ringbuffer環形隊列,如果生產消費速率相當情況下,理論上讀寫均無鎖,是生產消費模型裡理論上性能最優的。然而,一切都要靠場景和最終成績來說話,網上拋開了這些單獨談框架性能其實沒有什麼意義。
同樣是8線程讀取key然後寫入到隊列,8線程是循環消費隊列。此時已經不會重複消費了,我採用了別的方式來避免多線程同時計算同一個key的數量累加問題。
再次上線後,首先非常明顯的變化就是再也沒有key被處理時發生超時的情況了,之前每百萬必出幾個,而這個Queue幾億也沒一個超時的,每個寫入都會迅速被消費。另一個非常直觀的感受就是cpu佔用率明顯下降。
在平時的日常生產環境,disruptor版在秒級幾千key,3千個長連接情況下,cpu佔用在7%-10%之間,不低於5%。而BlockQueue版上線後,日常同等狀態下,cpu佔用率0.5%-1%之間,即便是後來我加入了秒級監控這個單線程挺耗資源的邏輯(該邏輯會統計累加所有client每秒的key訪問數據,單線程cpu佔用單核50%以上),cpu佔用率也才在1.5%,不超過2%。
此時壓測可以看到,秒級處理量能達到25-30萬,cpu佔用率在70%。cpu整體處於比較穩定的狀態,該壓測持續N個小時,未見任何異常。
並且該版也進行過並發寫入同時對推送的壓測,穩定推送每秒10-12萬次可保持極其穩定毫秒級送達的狀態。在20萬次時開始出現延遲送達,極限每秒壓至48萬次推送,出現大量延遲,部分送達至業務client時延遲已超過5秒,此時我們認為熱key毫秒級推送功能在如此延遲下已不能達到既定目的。讀寫並行情況下,吞吐量在40萬左右。
這一版也是618大促期間線上運行的版本(秒級監控是618後加入的功能)。
之前所有版本,都是通過fastjson進行的序列化和反序列化,兩端通信時交互的對象都是fastjson序列化的字符串,採用netty的StringDecoder進行交互。
這種序列化和反序列化方式在平時使用中,性能處於足夠的狀態,幾千幾萬個小對象序列化耗時很少。大家都知道一些其他的序列化方式,如protobuf、msgPack等。
本地測試很容易,搞個只有幾個屬性的對象,做30萬次序列化、反序列化,對比json和protobuf的耗時區別。30萬次,大概相差個300-500ms的樣子,在兩小時幾乎沒什麼區別,但在30萬QPS時,差的就是這幾百ms。
網上相關評測序列化方式的文章也很多,可以自行找一些看看對比。
在更換序列化方式,修改netty編解碼器後,壓測如圖:
秒級16萬時,cpu大概25%。
秒級36.5萬時,cpu在50%的樣子。
此時,每秒壓力機發來的key在42萬以上,但處理量維持在36萬左右,不能繼續提升。因為多線程消費LinkedBlockQueue,已達到該組件性能上限。netty的IO線程尚未達到接收上限。
其實從上面的生產消費圖大家都能看出來,所有netty收到的消息都發到了BlockQueue裡,這是唯一的隊列,生產消費者都是多線程,那麼必然是存在鎖競爭的。
很多人都會考慮為何不採用8個隊列,每個線程只往一個隊列發,消費者也只消費這一個線程。或者乾脆去掉隊列,直接消費netty收到的消息。這樣不就沒有所競爭了嗎,性能不就能再次起飛?
這個事情自然我也是多次測試過了,首先通過上面的壓測,我們知道瓶頸是在BlockQueue那裡,它一秒被8線程消費了37萬次,已經不能再高了,那麼8個隊列只要能超過這個數值,就代表這樣的優化是有效的。
然而實際測試結果並不讓人滿意,分發到8個隊列後,實際秒級處理量驟降至25萬浮動。至於直接在netty的IO線程做業務邏輯,這更不可取,如果不小心可能導致較為嚴重的積壓,甚至導致客戶端阻塞。
雖然網上很多文章都專門講鎖競爭導致性能下降,避免鎖來提升性能,但在這種30萬以上的場景下,實踐的重要性遠大於理論。至於為什麼會性能變差,就留給大家思考一下吧。
可能大家覺得哇塞你這個調優好簡單,我也學會了,就是減少線程數,那麼實際是這樣嗎?
我們再來看看,線程少時導致吞吐量大幅下降的場景。
之前我們的測試都是說每秒收到了多少萬個key,處理了多少萬個key,大部分負載都是處理key上。現在我們來測試一下純推送量,只接收很少的key,然後讓所有的key都是熱key,開啟很多個客戶端,每秒推送很多次。
測試是這樣的,由一個單獨的client每秒發送1萬個key到單個worker,設置變熱規則為1,那麼這1萬個就全是熱key,然後我分別採用40、60、80、100個client端機器,這樣就意味著每秒單個worker要推送40、60、80、100萬次,通過觀察client端每秒是否接收到了1萬個熱key推送來判斷worker的推送極限。
首先還是使用上面的配置,即8個IO線程,8個消費者線程。
可以看到8線程在每秒推送40萬次時比較穩定,在client端打日誌也是基本1秒1萬個全部收到,沒有超時的情況。在60萬次時,cpu大幅抖動,大部分能及時推送到達,但開始出現部分超時送達的情況。上到60萬以上時,系統已不可用,大量的超時,所有信息都超時,開始出現推送積壓,堆外內存持續增長,逐漸內存溢出。
調大IO線程至16,推送量每秒60萬:
持續運行較長時間,cpu非常穩定,未出現8線程時那種偶爾大幅抖動的情況,穩定性比之前的8線程明顯好很多。
隨後我加大到80個客戶端,即每秒推送80萬次。
總體還是比較平穩,cpu來了60%附近,full gc開始變得頻繁起來,幾乎每20秒一次,但並未出現大量的超時情況,只有full gc那一刻,會有個別key在到達client時超過1秒。系統開始出現不穩定的情況。
我加大到100個client,即每秒要推送100萬次。
此時系統已經明顯不堪重負,full gc非常密集,cpu開始大幅抖動,大量的1秒超時,整體已經不可控了,持續運行後,就會開始出異常,內存溢出等問題。
通過對純推送的壓測,發現更多的IO線程,幾乎可以到達每秒70萬推送穩定,比8線程的時候40多萬穩定,強了不是一點點。同樣一臺機器,僅改了改線程數量而已。
那麼問題又來了,我們應該怎麼去配置這個線程數量呢?
那麼就有實際場景了,你是到底每秒有很多key要探測、但是熱的不多,還是探測量一般般、但是閾值調的低、熱key產生多、需要每秒推送很多次。歸根到底,是要把計算資源讓給IO線程多一些,還是消費者線程多一些的問題。
在開發過程中,遇到了諸多問題,遠不止上面調個線程數那麼簡單,對很多數據結構、包傳輸、並發、和客戶端的連接處理很多地方都出過問題,也對很多地方選型做過權衡,線上以求穩為主,穩中有性能提升是最好的。
主要的結論就是一切靠實踐,任何理論、包括書上寫的、博客寫的,很多是靠想像,平時本地運行多久都不出問題,拿到線上,百萬流量一衝擊,各種奇奇怪怪的問題。有些在被衝擊後,過一會能恢復服務,而有些技術就不行了,就直接癱瘓只能重啟,甚至於連個異常信息都沒有,就那麼安安靜靜的停止響應了。
線上真實流量+極端暴力壓測是我們在每一個微小改動後都會去做的事情,力求服務極端流量不宕機、不誤判。
目前該框架已在開源中國發布開源,https://gitee.com/jd-platform-opensource/hotkey,有相關熱key痛點需求的可以了解一下,有定製化需求,或不滿足自己場景的也可以反饋,我們也在積極採納內外部意見建議,共建優質框架。
本文轉自gitee,如果對您有幫助的話,關注我一起向大佬學習。