提升內存管理效率,攜程酒店查詢服務輕量化探索和實踐
作者簡介
NekoMatryoshka,攜程酒店資深后端開發工程師,主要工作是緩存類組件的開發維護,并對業務應用的排障和優化有所關注。
一、背景和目標
在容器化部署成為主流的現在,降低集群中單個容器的資源需求的意義已經不只限于更少的硬件成本,同時也意味著整個集群更加輕量化,這通常會帶來一系列其他優勢:例如更短的恢復時間,更精確的資源控制和調度,和更快速的伸縮和部署等。但在另一方面,一味的追求壓縮容器配置必然會嚴重影響應用在穩定性、響應耗時和吞吐量等方面的表現,所以輕量化的措施需要在多個性能維度上進行仔細的權衡取舍,以達到一個總體更優的結果。
作為攜程計算量最大的接口之一,酒店查詢服務一直承擔著沉重的硬件成本壓力,僅僅詳情頁集群就包含了千余臺服務器實例和數十TB的Redis資源,因此對應用進行全面的輕量化有著很高的必要性和預期收益。在內存方向上,我們的主要目標是將單個容器的內存從32GB壓縮到16GB,并在以下兩個基本方向上進行了探索:
- 減少內存增長速度:壓縮本地緩存,減少浮動內存的產生,并對線程,類庫,參數和代碼邏輯進行針對性的優化和調整。
- 提升內存管理效率:加強JVM等服務依賴的基礎組件其本身的性能。
由于第一個方向需要根據應用的具體代碼實現來分析和排查,普適性相對較差,所以本文將主要分享查詢服務在輕量化中對于內存管理方向上的探索過程和實踐經驗。
二、堆內內存管理
我們的應用原本運行在JDK8的CMS收集器之上,但是在JDK11以后,CMS已經被完全淘汰。于是,要提高堆內的內存管理效率,我們首先嘗試的便是對GC進行升級和調優。因此我們對G1、ZGC和ShenandoahGC等更現代的收集器進行了性能上的測試和對比,來嘗試找出最合適的技術選型。
2.1 垃圾收集器的選型
首先,在JDK17上,ZGC第一次以生產環境可用的狀態登陸了LTS版本,所以我們這次選型起初的目標也是嘗試將應用遷移到ZGC之上。相對于大家熟悉的G1,ZGC最主要的優勢在于其通過著色指針和讀屏障兩個特性,使得用戶線程幾乎可以全程與標記-復制算法并行,基本解決了YGC的STW問題。簡單來說,ZGC在標記過程中會向64位指針的高位4bit中記錄三色標記、重分配標記和可達性標記;當應用線程訪問對象時,讀屏障機制會依據指針狀態和復制表信息去更新對象的地址和狀態。
這樣,即使GC線程正在后臺轉移、復制或清理對象,也可以保證前臺線程能始終訪問到正確的地址,這使得ZGC幾乎可以做到無停頓回收。除此之外,ZGC還向用戶承諾了可擴展性:由于ZGC的停頓時間基本只和初始掃描中GC Roots的數量相關,堆的大小和活躍對象的數量并不會導致停頓時間的增長。
其次,ShenandoahGC與ZGC同為新一代的零停頓收集器,總體來看,其內存布局非常類似于G1,而并發設計則與ZGC如出一轍,所以我們也將其作為一個可能的備選方案。
ShenandoahGC與ZGC的主要區別在于其使用的是Brook指針而非染色指針:即在對象頭中額
外記錄一個指向復制后正確地址的指針。但是由于額外信息記錄在對象頭中,Brook指針的讀屏障無法在第一次訪問后直接更新正確地址來自我恢復。另一方面,ShenandoahGC的區塊布局和回收階段則與G1非常相似,甚至部分代碼都是直接復用的。其不同主要在于ShenandoahGC利用了一個被稱為連接矩陣的二維數組來取代G1中開銷巨大的記憶集,來解決跨區引用問題:例如區塊N引用了區塊M,則在數組的`[N][M]`坐標打上標記。
最后,作為現在最主流的收集器,同時也是CMS的取代者,G1理所應當的也被我們作為最成熟和穩妥的一個選擇。G1本身的內存布局使得其對可控的停頓耗時和吞吐量的平衡上有較好的兼顧,在理論上使它更適合查詢接口這種會短時間內突然生成大量臨時對象的計算密集型應用。
綜上所述,我們以原本在輕量化前的生產配置(16C32G+JDK1.8+CMS)作為基準,選取了以下幾個組合作為測試方案:
其他各相關參數都為默認配置。
2.2 G1調優實踐
在橫向比較不同收集器的性能之前,我們首先需要按應用的需求對每個收集器做一些簡單的適配和調整,以發揮這些收集器的全部性能。由于G1是為開箱即用而準備的默認收集器,使用起來相對簡單,基本上只需要簡單設置下堆大小和線程數等參數即可。然而在實際使用中,我們仍然遇到了一些小問題,需要對關鍵某些參數進行控制。
(1)現象1:某個高壓力場景的計算集群的YGC頻率相對較高,GC吞吐量不足,最終甚至會引發FGC。
由于輕量化配置的資源本身就比較緊張,很難通過增加`ConcGCThreads`線程數來提升吞吐量,于是我們嘗試放寬了`MaxGCPauseMillis`來減少G1的回收壓力。作為G1最核心的參數,當`MaxGCPauseMillis`過小時,G1會自動調整其他GC參數來盡可能滿足該目標,進而導致YGC非常頻繁并影響吞吐量。由于各個應用的情況不同,需要開發人員手動進行基準測試來找到最合適的數值:當我們把數值從200調整到300后,平均響應有非常明顯的下降,而繼續上調的邊際效應則很不明顯。
(2)現象2:應用在長時間穩定運行后,老年代突然大量上漲后卻不及時回收,在數小時內老年代都維持高位,擠占年輕代的占比并影響到了YGC的停頓耗時。
由于查詢服務主要是無狀態的計算邏輯,除了一部分本地緩存外大部分的對象都是相對短命的,但是當GC壓力上升的情況下仍然會有大量對象進入老年代。這里我們通過縮小`InitiatingHeapOccupancyPercent`來降低MGC的閾值,讓G1可以及時回收掉進入老年代的短命對象。
除此之外,我們也對JDK8、11和17上的G1進行過縱向對比,發現實際反映到機器性能和響應耗時維度上的區別非常小,在排除掉宿主機本身的硬件區別后幾乎可以忽略不計。最終,由于依賴類庫和監控平臺等各種原因,最終選擇了相對成熟的JDK11作為G1的平臺。
2.3 ZGC調優實踐
與G1的普適明顯不同,對ZGC上的適配工作明顯困難的多,作為最現代的收集器之一,ZGC并不是萬用的銀彈,因此也并沒有成為JDK17的默認收集器。相比G1,ZGC并不能簡單的適配于所有場景,我們在試運行過程中遇到了一系列難以解決的問題,經過大量的參數調整和性能測試后,才能發揮其全部的實力。
(1)現象1:在訪問量突然上漲時,會觀察到非常顯著的分鐘級響應尖刺。
由于ZGC會使用之前數次的GC指標來預測下一次GC的回收策略,使得其相比CMS等傳統收集器更容易受到流量波動的沖擊,在高流量壓力下的魯棒性很差。解決響應尖刺的辦法主要有兩個:首先是通過提高`ZAllocationSpikeTolerance`的數值來減少觸發GC的閾值,其次是打開周期性的主動回收參數`ZProactive`,并通過減少`ZCollectionInterval`來縮短兩次主動GC的間隔。兩個參數的具體數值需要開發人員手動調試確定,但是在高壓力下無論怎么調整參數,提升都相對有限。
(2)現象2:GC日志出現大量Allocation Stall/Rellocation Stall,同時監控上發現秒級的STW出現,某些時候甚至伴有OOM報錯。
雖然ZGC的停頓時間很短,但整個回收階段很長,期間用戶線程一直處于并發運行的狀態。這使得回收過程中會產生大量的浮動垃圾,只能等到下次GC時再回收。此時如果浮動垃圾占滿了整個堆使得回收無法繼續,ZGC就會直接暫停對應的用戶線程,來優先執行回收任務,同時在日志上記錄對應線程的Allocation/Relocation Stall。簡而言之,Allocation Stall是一種當GC吞吐量不夠時觸發的用戶線程暫停,大量秒級的Allocation Stall甚至比FGC的影響更大。
這種情況一般都是GC回收速度跟不上內存申請速度導致的,如果GC資源相對充足的話,可以通過上面兩個主動GC參數來增加GC頻率,而如果GC資源本身就很匱乏,則只能通過增加GC線程數`ConcGCThreads`和`ParallelGCThreads`來根本性的解決問題。
(3)現象3:ZGC堆使用的RSS持續上升,其大小不會隨著內存使用情況智能伸縮,最終導致了堆外溢出。
低版本的ZGC并不會主動將長期未使用的堆內存返還給系統,JDK13后ZGC提供了`ZUncommitDelay`參數來設置將空閑內存返還給OS的期限,可以通過縮短這個值來使得RSS空間被更加靈活的使用。為了保證生產環境服務的穩定性,我們直接通過讓堆大小的上下限相同來防止堆的伸縮。
2.4 基準測試的結果
在實際試驗中,我們首先觀察到ZGC確實無愧于其零停頓收集器的名號,可以做到在全程任何情況下都達到百微秒級每次的停頓時間,每分鐘累計不超過1ms,同時CMS中令人困擾的FGC現象也不再成為問題。而與之原理相近的ShenandoahGC的性能表現也非常好,平均每分鐘的停頓時間也不超過10ms。G1與這些新一代的收集器相比雖然遜色許多,但是仍然能在僅僅使用生產配置一半的內存下,達到比CMS更好的GC性能:其YGC停頓約下降了50%,每分鐘停頓約為200ms左右,并且MGC也基本可以滿足老年代的回收需要,數天時間內沒有觀察到FGC。
但是隨著流量壓力的上升,我們很快發現作為首選的ZGC和ShenandoahGC等零停頓收集器實際上并不適合查詢接口這樣的運算密集型應用:他們的運行顯著地依賴于資源開銷,最終嚴重擠占了業務邏輯的計算資源,使得響應耗時飆升,服務趨于崩潰。
為了穩定服務,我們不得不重新分配了更多計算資源并降低了流量,并得出了結論:即使使用了兩倍的線程資源,在CPU利用率三倍于CMS的情況下,ZGC和ShenandoahGC仍然只能達到相當于生產環境約50%-60%的極限吞吐量。與之相對,G1在這方面的表現則好得多,在輕量化配置下比起生產配置的整體吞吐量僅稍有下降,在相同QPS下的CPU利用率變高了約5-7%,幾乎可以忽略不計。經過后續排查,我們發現主要的原因是ZGC的四條ZWORKER線程幾乎每個都會100%的占用一個核心(如下圖),大量的吞吃了CPU資源并影響到了核心處理流程。
在內存方面,ZGC的實際表現也并不盡如人意。ZGC不僅比傳統收集器記錄了更多的額外信息,且很多優化和特性(例如字符串去重、分代、指針壓縮等)也暫時無法使用,這使得ZGC無論是堆內還是堆外的內存開銷都要明顯高于G1和CMS。測試中我們使用NMT和JMX簡單對比了各收集器在未接入流量一段時間內的平均內存開銷,發現ZGC的堆內開銷大約比CMS高三分之一,堆外則高達CMS的10倍左右。
綜合來看,ZGC等零停頓收集器雖然可以達到10ms以下的每分鐘停頓時間,但是其占用的大量GC資源會嚴重影響核心計算邏輯。在資源緊張的輕量化場景下,切換至ZGC導致了服務在極限壓測中損失了相當于100%生產流量的吞吐量,同時接口的響應耗時上升了70%。與之相對,G1在響應耗時上的表現則幾乎與原來相同,各細節指標上也僅僅只有CPU利用率略差。
2.5 遷移到JDK11
由于ZGC和ShenandoahGC在測試中表現不佳,且各種問題在資源緊張的限制下幾乎無法解決,我們最終將目光轉向了更為成熟穩定的G1+JDK11的組合。從JDK8遷移到11是兩個連續的LTS版本之間的遷移,比起直接遷移到17來說簡單很多。我們在實際遷移中主要遇到了三個類型的問題:官方類庫缺失,權限控制,以及第三方類庫報錯。
- 官方類庫缺失:JDK11中移除了一系列官方類庫,其中部分類庫只是從rt.jar中被拆分,可以簡單的通過maven補回,例如javax.*,但是其他一些包含危險操作的類庫則被直接刪除,例如jdk.nashorn,sun.misc等,一般也可以通過重寫來繞過這些代碼。
- ?權限控制:JDK11中對各種權限做了更精細的控制。例如自代理需要使用參數?
?-Djdk.attach.allowAttachSelf?
??控制,而跨類庫的反射權限則需要用??--add-exports=?
??和??--add-exports?
?來打開。如果未能注意到這些參數則會造成大量權限控制報錯。? - 第三方類庫報錯:此類報錯一般都是兼容性問題導致,Lombok和AspectJ等類庫需要根據JDK版本來選擇對應的類庫版本。主要排查難點在于報錯的形式五花八門,報錯信息對定位幾乎沒有幫助,有時候很難確定是哪個類庫導致的。
三、堆外內存管理
一般來說,對于運行在容器中的單個Java應用,大部分堆外相關的細節都會被虛擬機給屏蔽掉,導致開發人員往往很少會深入到相關問題。然而,由于這次輕量化后剩余的內存資源非常緊張,我們被迫給堆外留下了非常有限的空間,導致了后續測試過程中出現的一系列問題。
3.1 問題表象與排查
具體來說,在生產測試中,我們經常觀察到應用經常在半夜多次無故宕機后被拉起,最終反復點火失敗導致應用崩潰無法繼續服務。經過大量試驗后,我們發現這一現象與JDK版本和GC無關,可以在多個輕量化配置上復現,現象為RSS在較長的一段時間內持續上升,最終導致了應用多次崩潰重啟。
由于是RSS持續上升,我們排查時首先懷疑是堆內溢出。但是卻并沒有在應用日志上發現OOM報錯,且使用JFR檢查堆內存增長情況后也并沒有找到明顯的溢出跡象。所以我們進一步檢查了機器的dmesg日志,發現反復崩潰的原因是malloc申請不到內存,導致內核的oom_killer線程直接kill掉了DMESGTomcact進程,然后又被重啟腳本重新拉起。
由于堆內存是基本穩定的,我們使用NMT baseline對JVM的堆外內存使用情況進行了檢查。雖然現代GC本身的native占用相對較高,但增長并不顯著,總體內存使用很穩定,也沒有泄露的傾向。
進一步向下排查,我們用 `gdb --batch --pid 36563 --ex 'call malloc_trim()'` 強行回收內存后,發現RSS有明顯下降,至此基本判斷是glibc這一塊造成了泄露問題。
3.2 內存分配器
要解釋溢出的原因,首先需要了解一下內存管理的機制。對于Java應用來說,內存管理一般分為四層:內核負責管理和映射虛擬頁,glibc進行通用的內存算法管理,JVM負責屏蔽內存申請和回收的細節,而最上層才是Java應用。
由于mmap和brk是系統調用,如果應用每次申請內存時都直接訪問內核函數的話,性能會非常差,代碼實現也更加困難。所以,linux會使用通用的內存分配算法來緩沖和規劃內存的使用,其主要關注點在三個指標上:
- 減少申請和釋放操作的時間和開銷?
- 減少小對象分配帶來的內存碎片?
- 減少分配器本身數據結構的額外內存開
?
3.3 默認分配器PTMALLOC的優缺點
在默認情況下,glibc使用的是其原生的ptmalloc2內存分配器,由以下三層數據結構組成:
- arena(分配區)是ptmalloc中的內存緩沖區,在一個環形鏈表上被管理。也是ptmalloc中最小的鎖顆粒度。?
- bin(空閑鏈表)是arena中用于管理可用內存塊的鏈表。不同的bins根據其管理的內存塊大小而被分為fast、unsorted、small和large四種。
- chunk(內存塊)則是用戶申請和釋放內存的最小單位。bins的頭部永遠是一個被稱為top chunk的空閑塊,當沒有合適的chunk時,會擴容并返回top chunk來處理請求。
ptmalloc作為標準實現,主要使用了以下幾個方法來優化以上三個重要指標:
內存池(減少頻繁的系統調用和內存碎片):用戶free掉的內存,不會被直接被歸還給系統,而是暫存到bins中,供下次申請時直接分配。
多分配區(減少鎖競爭):所有內存操作都需要加鎖,如果沒有找到未上鎖的arena,則會新增一個副arena并上鎖,直到arena的數量上限。?
ptmalloc雖然滿足了內存分配器的基本需求,但是本身實現有很多缺陷,導致了內在的OOM傾向:
- 額外內存開銷大:每個chunk都需要額外消耗8b的內存,而chunk是內存操作的最小單位,這會導致整體上浪費了非常多內存。
- 內存利用率不穩定:由于多分配區的機制,激烈的鎖競爭會導致副arena數量快速增多。并且,新增的副arena永遠不會被銷毀,且保留會其初始的chunk。這意味著在一臺16核的標準機器上最多會有128個arena,并占用高達8G的堆外緩沖區。
- 多線程性能差:所有的內存操作都需要進行悲觀鎖的加鎖解鎖操作,導致其性能較差。同時,即使有多分配區機制,在動輒500個以上線程的生產環境中這個并發量完全不夠。?
- 回收機制簡陋:由于bins是鏈表結構,ptmalloc的內存收縮必須從上向下收縮,這意味著只要后申請的內存沒有被釋放,之前申請的所有chunk都無法被收縮,這導致了在管理長周期內存時,有內存泄漏的可能性。
具體到這次詳情頁的溢出,則主要有三個原因:
- 前置條件:由于輕量化的需要,應用目前僅僅能給堆外大約2.5G的空間。并且G1本身使用的堆外空間是CMS的4-6倍之多。而原來的32G的堆外空間充足,所以之前沒有發現類似問題。?
- 由于大量的緩存、快照和報文的處理,應用本身有非常頻繁和重量級的NIO和序列化/壓縮操作(尤其是點火的時候,線程數非常多),這導致了應用會高頻的申請和釋放堆外內存作為IO緩沖區。因此ptmalloc在這種情況下新增了大量的arena來避免頻繁的鎖競爭(下圖中有大量64M大小的內存塊)。
- ptmalloc本身的釋放機制就導致申請的內存被歸還的特別慢,甚至有內存溢出的傾向,這些因素綜合在一起引起了OOM的發生。
3.4 解決方案JEMALLOC
考慮到ptmalloc的性能相對較差,我們將目光轉向了第三方的內存管理器。無論是谷歌的tcmalloc還是臉書的jemalloc都完全是默認分配器的上位替代,各項性能遠超ptmalloc,并且遷移起來非常方便。雖然tcmalloc和jemalloc兩者之間優劣差別不大,但是由于jemalloc相對優秀的工具鏈,我們最終優先對它進行了測試。
jemalloc是專精于多核多線程場景的內存分配器,可以說在并發量越大的情況下,jemalloc的優勢越明顯。對比pemalloc,jemalloc有以下的優勢:
(1)內存碎片率:jemalloc承諾至多20%的內存碎片。
通過將內存塊根據大小進一步細分為232個小類,同一個bins中的內存塊大小一致,向上取整申請,來提高每個內存塊在分配時的利用率和性能。(同樣處理10kb內存的申請,返回10kb的 chunk和返回20kb的chunk之間肯定有區別)
采用了低地址優先的分配策略,進一步降低了內存碎片率。(使用紅黑樹記錄了地址排序,總是從低地址開始分配,使高地址的內存更整塊)
(2)鎖的顆粒度:jemalloc在大部分場景下幾乎是無鎖的。
每個線程都擁有動態伸縮的緩存tcache,在小內存操作時是無鎖的。
大部分的線程都會被綁定到專屬的arena上,使其操作無鎖化(類似于JVM的偏向鎖)。即使多個 線程共享一個arena,也會在arena內部細化為局部鎖,而不是直接使用全局鎖。
(3)內存回收:除了類似于ptmalloc的回收機制外,jemalloc還有兩種機制。
當發現某個chunk全部都是臟頁后,會直接釋放整個chunk。
當臟頁數量超過某個閾值的時候,進行主動的purge操作。
(4)額外開銷:僅僅占用約2%的額外內存,用于存儲一些meta信息。
(5)工具鏈:jemalloc有完善的內存分析工具,可以更好的定位溢出和泄露問題。
3.5 遷移和收益
對于簡單的性能測試,手動安裝jemalloc非常容易,甚至不需要重新編譯代碼,直接在一臺正常運行的機器上安裝好jemalloc后,修改tomcat的sh文件中將LD_PRELOAD變量指定為對應的so文件覆蓋glibc動態庫并重啟tomcat即可。而后續容器部署也只需要在dockerfile中自定義數行代碼模擬上述操作,然后構建并上傳自定義鏡像便能完成。
目前查詢服務已經在jemalloc上生產運行了數個月,至今還沒有觀察到再次出現堆外溢出的問題;同時RSS的波動非常穩定,即使遇到流量高峰也不會出現內存尖刺,可以保持良好的響應時間和穩定。
從實際的情況來看,jemalloc與ptmalloc相比主要有以下收益:
- 從運維方面來看,集群為了方便調度,一般會限制幾個預設的容器配置以供選擇。在資源相對緊張的情況下,jemalloc可以使得應用整體的部署更加靈活,而使用默認的ptmalloc則會被迫將容器配置向上升級,否則就需要額外對特殊配置進行審批和調度,這樣不但會造成不必要的資源浪費,同時在流量尖峰時也難以對集群進行調度和擴容。
- 在成本方面,從測試結果出發,僅僅使用jemalloc本身就能比ptmalloc在每臺機器上節省1-1.5G的堆外內存,雖然在單機上可能不夠顯著,但是推廣到整個云的范圍時收益應該是非??捎^的。
- 性能上,jemalloc的內存回收和多線程機制更加高效和智能化,對低配置機器更加友好,能大大加強內存資源緊張的機器上服務的魯班性,同時對IO、GC、類加載等多線程native操作有較大的優化。
- 從遷移角度看,遷移到jemalloc幾乎是無成本的操作,僅僅需要簡單的鏡像自定義和一定的灰度測試,就可以完成優化。
故綜合來看,jemalloc的收益相比于成本大得多的,有一定的分享和推廣的意義。
四、結語
本文相對完整的記述了酒店查詢服務在輕量化中的一次優化過程,希望其中的經驗和過程能對讀者有所幫助。然而,對于應用的優化過程是一個從猜想到驗證的循環。在有了可能的猜測和方向之后,比起反復的調研,更重要的則是不斷向著落地驗證去推進。雖然這些經驗有一些普適性,但是由于應用之間各有不同,仍然需要讀者根據實際情況親手試驗后,才能最終確定是否有借鑒意義。