導讀:GC一直是Java應用中討論的一個熱門話題,尤其在像HBase這樣的大型在線存儲系統中,大堆下(百GB)的GC停頓延遲產生的在線實時影響,成為內核和應用開發者的一大痛點。
過去的一年里,我們準備在Ali-HBase上突破這個被普遍認知的痛點,為此進行了深度分析及全面創新的工作,獲得了一些比較好的效果。以螞蟻風控場景為例,HBase的線上young GC時間從120ms減少到15ms,結合阿里巴巴JDK團隊提供的利器——AliGC,進一步在實驗室壓測環境做到了5ms。本文主要介紹我們過去在這方面的一些工作和技術思想。
背景
JVM的GC機制對開發者屏蔽了內存管理的細節,提高了開發效率。說起GC,很多人的第一反應可能是JVM長時間停頓或者FGC導致進程卡死不可服務的情況。但就HBase這樣的大數據存儲服務而言,JVM帶來的GC挑戰相當復雜和艱難。原因有三:
1、內存規模巨大。線上HBase進程多數為96G大堆,今年新機型已經上線部分160G以上的堆配置
2、對象狀態復雜。HBase服務器內部會維護大量的讀寫cache,達到數十GB的規模。HBase以表格的形式提供有序的服務數據,數據以一定的結構組織起來,這些數據結構產生了過億級別的對象和引用
3、young?GC頻率高。訪問壓力越大,young區的內存消耗越快,部分繁忙的集群可以達到每秒1~2次youngGC, 大的young區可以減少GC頻率,但是會帶來更大的young?GC停頓,損害業務的實時性需求。
思路
1.??HBase作為一個存儲系統,使用了大量的內存作為寫buffer和讀cache,比如96G的大堆(4G young + 92G old)下,寫buffer+讀cache會占用70%以上的內存(約70G),本身堆內的內存水位會控制在85%,而剩余的占用內存就只有在10G以內了。所以,如果我們能在應用層面自管理好這70G+的內存,那么對于JVM而言,百G大堆的GC壓力就會等價于10G小堆的GC壓力,并且未來面對更大的堆也不會惡化膨脹。 在這個解決思路下,我們線上的young?GC時間獲得了從120ms到15ms的優化效果。
2.??在一個高吞吐的數據密集型服務系統中,大量的臨時對象被頻繁創建與回收,如何能夠針對性管理這些臨時對象的分配與回收,AliJDK團隊研發了一種新的基于租戶的GC算法—AliGC。集團HBase基于這個新的AliGC算法進行改造,我們在實驗室中壓測的young?GC時間從15ms減少到5ms,這是一個未曾期望的極致效果。
下面將逐一介紹Ali-HBase版本GC優化所使用的關鍵技術。
消滅一億個對象:更快更省的CCSMap
目前HBase使用的存儲模型是LSMTree模型,寫入的數據會在內存中暫存到一定規模后再dump到磁盤上形成文件。
下面我們將其簡稱為寫緩存。寫緩存是可查詢的,這就要求數據在內存中有序。為了提高并發讀寫效率,并達成數據有序且支持seek&scan的基本要求,SkipList是使用得比較廣泛的數據結構。
我們以JDK自帶的ConcurrentSkipListMap為例子進行分析,它有下面三個問題:
1.??內部對象繁多。每存儲一個元素,平均需要4個對象(index+node+key+value,平均層高為1)
2.??新插入的對象在young區,老對象在old區。當不斷插入元素時,內部的引用關系會頻繁發生變化,無論是ParNew算法的CardTable標記,還是G1算法的RSet標記,都有可能觸發old區掃描。
3.??業務寫入的KeyValue元素并不是規整長度的,當它晉升到old區時,可能產生大量的內存碎片。
問題1使得young區GC的對象掃描成本很高,young?GC時晉升對象更多。問題2使得young?GC時需要掃描的old區域會擴大。問題3使得內存碎片化導致的FGC概率升高。當寫入的元素較小時,問題會變得更加嚴重。我們曾對線上的RegionServer進程進行統計,活躍Objects有1億2千萬之多!
分析完當前young?GC的最大敵人后,一個大膽的想法就產生了,既然寫緩存的分配,訪問,銷毀,回收都是由我們來管理的,如果讓JVM“看不到”寫緩存,我們自己來管理寫緩存的生命周期,GC問題自然也就迎刃而解了。
說起讓JVM“看不到”,可能很多人想到的是off-heap的解決方案,但是這對寫緩存來說沒那么簡單,因為即使把KeyValue放到offheap,也無法避免問題1和問題2。而1和2也是young?GC的最大困擾。
問題現在被轉化成了:如何不使用JVM對象來構建一個有序的支持并發訪問的Map。
當然我們也不能接受性能損失,因為寫入Map的速度和HBase的寫吞吐息息相關。
需求再次強化:如何不使用對象來構建一個有序的支持并發訪問的Map,且不能有性能損失。
為了達成這個目標,我們設計了這樣一個數據結構:
·??????它使用連續的內存(堆內or堆外),我們通過代碼控制內部結構而不是依賴于JVM的對象機制
·??????在邏輯上也是一個SkipList,支持無鎖的并發寫入和查詢
·??????控制指針和數據都存放在連續內存中
上圖所展示的即是CCSMap(CompactedConcurrentSkipListMap)的內存結構。 我們以大塊的內存段(Chunk)的方式申請寫緩存內存。每個Chunk包含多個Node,每個Node對應一個元素。新插入的元素永遠放在已使用內存的末尾。Node內部復雜的結構,存放了Index/Next/Key/Value等維護信息和數據。新插入的元素需要拷貝到Node結構中。當HBase發生寫緩存dump時,整個CCSMap的所有Chunk都會被回收。當元素被刪除時,我們只是邏輯上把元素從鏈表里"踢走",不會把元素實際從內存中收回(當然做實際回收也是有方法,就HBase而言沒有那個必要)。
插入KeyValue數據時雖然多了一遍拷貝,但是就絕大多數情況而言,拷貝反而會更快。因為從CCSMap的結構來看,一個Map中的元素的控制節點和KeyValue在內存上是鄰近的,利用CPU緩存的效率更高,seek會更快。對于SkipList來說,寫速度其實是bound在seek速度上的,實際拷貝產生的overhead遠不如seek的開銷。根據我們的測試,CCSMap和JDK自帶的ConcurrentSkipListMap相比,50Byte長度KV的測試中,讀寫吞吐提升了20~30%。
由于沒有了JVM對象,每個JVM對象至少占用16Byte空間也可以被節省掉(8byte為標記預留,8byte為類型指針)。還是以50Byte長度KeyValue為例,CCSMap和JDK自帶的ConcurrentSkipListMap相比,內存占用減少了40%。
CCSMap在生產中上線后,實際優化效果: young?GC從120ms+減少到了30ms
使用了CCSMap后,原來的1億2千萬個存活對象被縮減到了千萬級別以內,大大減輕了GC壓力。由于緊致的內存排布,寫入吞吐能力也得到了30%的提升。
永不晉升的Cache:BucketCache
HBase以Block的方式組織磁盤上的數據。一個典型的HBase Block大小在16K~64K之間。HBase內部會維護BlockCache來減少磁盤的I/O。BlockCache和寫緩存一樣,不符合GC算法理論里的分代假說,天生就是對GC算法不友好的 —— 既不稍縱即逝,也不永久存活。
一段Block數據從磁盤被load到JVM內存中,生命周期從分鐘到月不等,絕大部分Block都會進入old區,只有Major GC時才會讓它被JVM回收。它的麻煩主要體現在:
1.??HBase Block的大小不是固定的,且相對較大,內存容易碎片化
2.??在ParNew算法上,晉升麻煩。麻煩不是體現在拷貝代價上,而是因為尺寸較大,尋找合適的空間存放HBase Block的代價較高。
讀緩存優化的思路則是,向JVM申請一塊永不歸還的內存作為BlockCache,我們自己對內存進行固定大小的分段,當Block加載到內存中時,我們將Block拷貝到分好段的區間內,并標記為已使用。當這個Block不被需要時,我們會標記該區間為可用,可以重新存放新的Block,這就是BucketCache。關于BucketCache中的內存空間分配與回收(這一塊的設計與研發在多年前已完成),詳細可以參考?:?http://zjushch.iteye.com/blog/1751387
很多基于堆外內存的RPC框架,也會自己管理堆外內存的分配和回收,一般通過顯式釋放的方式進行內存回收。但是對HBase來說,卻有一些困難。我們將Block對象視為需要自管理的內存片段。Block可能被多個任務引用,要解決Block的回收問題,最簡單的方式是將Block對每個任務copy到棧上(copy的block一般不會晉升到old區),轉交給JVM管理就可以。
實際上,我們之前一直使用的是這種方法,實現簡單,JVM背書,安全可靠。但這是有損耗的內存管理方式,為了解決GC問題,引入了每次請求的拷貝代價。由于拷貝到棧上需要支付額外的cpu拷貝成本和young區內存分配成本,在cpu和總線越來越珍貴的今天,這個代價顯得高昂。
于是我們轉而考慮使用引用計數的方式管理內存,HBase上遇到的主要難點是:
1.??HBase內部會有多個任務引用同一個Block
2.??同一個任務內可能有多個變量引用同一個Block。引用者可能是棧上臨時變量,也可能是堆上對象域。
3.??Block上的處理邏輯相對復雜,Block會在多個函數和對象之間以參數、返回值、域賦值的方式傳遞。
4.??Block可能是受我們管理的,也可能是不受我們管理的(某些Block需要手動釋放,某些不需要)。
5.??Block可能被轉換為Block的子類型。
這幾點綜合起來,對如何寫出正確的代碼是一個挑戰。但在C++ 上,使用智能指針來管理對象生命周期是很自然的事情,為什么到了Java里會有困難呢?
Java中變量的賦值,在用戶代碼的層面上,只會產生引用賦值的行為,而C++ 中的變量賦值可以利用對象的構造器和析構器來干很多事情,智能指針即基于此實現(當然C++的構造器和析構器使用不當也會引發很多問題,各有優劣,這里不討論)
于是我們參考了C++的智能指針,設計了一個Block引用管理和回收的框架ShrableHolder來抹平coding中各種if else的困難。它有以下的范式:
1.??ShrableHolder可以管理有引用計數的對象,也可以管理非引用計數的對象
2.??ShrableHolder在被重新賦值時,釋放之前的對象。如果是受管理的對象,引用計數減1,如果不是,則無變化。
3.??ShrableHolder在任務結束或者代碼段結束時,必須被調用reset
4.??ShrableHolder不可直接賦值。必須調用ShrableHolder提供的方法進行內容的傳遞
5.??因為ShrableHolder不可直接賦值,需要傳遞包含生命周期語義的Block到函數中時,ShrableHolder不能作為函數的參數。
根據這個范式寫出來的代碼,原來的代碼邏輯改動很少,不會引入if else。雖然看上去仍然有一些復雜度,所幸的是,受此影響的區間還是局限于非常局部的下層,對HBase而言還是可以接受的。為了保險起見,避免內存泄漏,我們在這套框架里加入了探測機制,探測長時間不活動的引用,發現之后會強制標記為刪除。
將BucketCache應用之后,減少了BlockCache的晉升開銷,減少了young?GC時間:
追求極致:AliGC
經過以上兩個大的優化之后,螞蟻風控生產環境的young?GC時間已經縮減到15ms。由于ParNew+CMS算法在這個尺度上再做優化已經很困難了,我們轉而投向AliGC的懷抱。AliGC在G1算法的基礎上做了深度改進,內存自管理的大堆HBase和AliGC產生了很好的化學反應。
AliGC是阿里巴巴JVM團隊基于G1算法, 面向大堆 (LargeHeap) 應用場景,優化的GC算法的統稱。這里主要介紹下多租戶GC。
多租戶GC包含的三層核心邏輯:1)?在JavaHeap上,對象的分配按照租戶隔離,不同的租戶使用不同的Heap區域;2)允許GC以更小的代價發生在租戶粒度,而不僅僅是應用的全局;3)允許上層應用根據業務需求對租戶靈活映射。
AliGC將內存Region劃分為了多個租戶,每個租戶內獨立觸發GC。在個基礎上,我們將內存分為普通租戶和中等生命周期租戶。中等生命周期對象指的是,既不稍縱即逝,也不永久存在的對象。由于經過以上兩個大幅優化,現在堆中等生命周期對象數量和內存占用已經很少了。但是中等生命周期對象在生成時會被old區對象引用,每次young?GC都需要掃描RSet,現在仍然是young?GC的耗時大頭。
借助于AJDK團隊的ObjectTrace功能,我們找出中等生命周期對象中最"大頭"的部分,將這些對象在生成時直接分配到中等生命周期租戶的old區,避免RSet標記。而普通租戶則以正常的方式進行內存分配。
普通租戶GC頻率很高,但是由于晉升的對象少,跨代引用少,Young區的GC時間得到了很好的控制。在實驗室場景仿真環境中,我們將young?GC優化到了5ms。
云端使用
阿里HBase目前已經在阿里云提供商業化服務,任何有需求的用戶都可以在阿里云端使用深入改進的、一站式的HBase服務。云HBase版本與自建HBase相比在運維、可靠性、性能、穩定性、安全、成本等方面均有很多的改進,更多內容歡迎大家關注?https://www.aliyun.com/product/hbase