從HBase offheap到Netty的記憶體管理

小米運維發表於2019-04-30

HBase的offheap現狀

HBase作為一款流行的分散式NoSQL資料庫,被各個公司大量應用,其中有很多業務場景,例如資訊流和廣告業務,對訪問的吞吐和延遲要求都非常高。HBase2.0為了盡最大可能避免Java GC對其造成的效能影響,已經對讀寫兩條核心路徑做了offheap化,也就是物件的申請都直接向JVM offheap申請,而offheap分出來的記憶體都是不會被JVM GC的,需要使用者自己顯式地釋放。在寫路徑上,客戶端發過來的請求包都會被分配到offheap的記憶體區域,直到資料成功寫入WAL日誌和Memstore,其中維護Memstore的ConcurrentSkipListSet其實也不是直接存Cell資料,而是存Cell的引用,真實的記憶體資料被編碼在MSLAB的多個Chunk內,這樣比較便於管理offheap記憶體。類似地,在讀路徑上,先嚐試去讀BucketCache,Cache未命中時則去HFile中讀對應的Block,這其中佔用記憶體最多的BucketCache就放在offheap上,拿到Block後編碼成Cell傳送給使用者,整個過程基本上都不涉及heap內物件申請。


從HBase offheap到Netty的記憶體管理


但是在小米內部最近的效能測試結果中發現,100% Get的場景受Young GC的影響仍然比較嚴重,在HBASE-21879貼的兩幅圖中,可以非常明顯的觀察到Get操作的p999延遲跟G1 Young GC的耗時基本相同,都在100ms左右。按理說,在HBASE-11425之後,應該是所有的記憶體分配都是在offheap的,heap內應該幾乎沒有記憶體申請。但是,在仔細梳理程式碼後,發現從HFile中讀Block的過程仍然是先拷貝到堆內去的,一直到BucketCache的WriterThread非同步地把Block重新整理到Offheap,堆內的DataBlock才釋放。而磁碟型壓測試驗中,由於資料量大,Cache命中率並不高(~70%),所以會有大量的Block讀取走磁碟IO,於是Heap內產生大量的年輕代物件,最終導致Young區GC壓力上升。

消除Young GC直接的思路就是從HFile讀DataBlock的時候,直接往Offheap上讀。之前留下這個坑,主要是HDFS不支援ByteBuffer的Pread介面,當然後面開了HDFS-3246在跟進這個事情。但後面發現的一個問題就是:Rpc路徑上讀出來的DataBlock,進了BucketCache之後其實是先放到一個叫做RamCache的臨時Map中,而且Block一旦進了這個Map就可以被其他的RPC給命中,所以當前RPC退出後並不能直接就把之前讀出來的DataBlock給釋放了,必須考慮RamCache是否也釋放了。於是,就需要一種機制來跟蹤一塊記憶體是否同時不再被所有RPC路徑和RamCache引用,只有在都不引用的情況下,才能釋放記憶體。自然而言的想到用reference Count機制來跟蹤ByteBuffer,後面發現其實Netty已經較完整地實現了這個東西,於是看了一下Netty的記憶體管理機制。


Netty記憶體管理概述

Netty作為一個高效能的基礎框架,為了保證GC對效能的影響降到最低,做了大量的offheap化。而offheap的記憶體是程式設計師自己申請和釋放,忘記釋放或者提前釋放都會造成記憶體洩露問題,所以一個好的記憶體管理器很重要。首先,什麼樣的記憶體分配器,才算一個是一個“好”的記憶體分配器:

  1. 高併發且執行緒安全。一般一個程式共享一個全域性的記憶體分配器,得保證多執行緒併發申請釋放既高效又不出問題。

  2. 高效的申請和釋放記憶體,這個不用多說。

  3. 方便跟蹤分配出去記憶體的生命週期和定位記憶體洩露問題。

  4. 高效的記憶體利用率。有些記憶體分配器分配到一定程度,雖然還空閒大量記憶體碎片,但卻再也沒法分出一個稍微大一點的記憶體來。所以需要通過更精細化的管理,實現更高的記憶體利用率。

  5. 儘量保證同一個物件在實體記憶體上儲存的連續性。例如分配器當前已經無法分配出一塊完整連續的70MB記憶體來,有些分配器可能會通過多個記憶體碎片拼接出一塊70MB的記憶體,但其實合適的演算法設計,可以保證更高的連續性,從而實現更高的記憶體訪問效率。

為了優化多執行緒競爭申請記憶體帶來額外開銷,Netty的PooledByteBufAllocator預設為每個處理器初始化了一個記憶體池,多個執行緒通過Hash選擇某個特定的記憶體池。這樣即使是多處理器併發處理的情況下,每個處理器基本上能使用各自獨立的記憶體池,從而緩解競爭導致的同步等待開銷。

Netty的記憶體管理設計的比較精細。首先,將記憶體劃分成一個個16MB的Chunk,每個Chunk又由2048個8KB的Page組成。這裡需要提一下,對每一次記憶體申請,都將二進位制對齊,例如需要申請150B的記憶體,則實際待申請的記憶體其實是256B,而且一個Page在未進Cache前(後續會講到Cache)都只能被一次申請佔用,也就是說一個Page內申請了256B的記憶體後,後面的請求也將不會在這個Page中申請,而是去找其他完全空閒的Page。有人可能會疑問,那這樣豈不是記憶體利用率超低?因為一個8KB的Page被分配了256B之後,就再也分配了。其實不是,因為後面進了Cache後,還是可以分配出31個256B的ByteBuffer的。

多個Chunk又可以組成一個ChunkList,再根據Chunk記憶體佔用比例(Chunk使用記憶體/16MB * 100%)劃分成不同等級的ChunkList。例如,下圖中根據記憶體使用比例不同,分成了6個不同等級的ChunkList,其中q050內的Chunk都是佔用比例在[50,100)這個區間內。隨著記憶體的不斷分配,q050內的某個Chunk佔用比例可能等於100,則該Chunk被挪到q075這個ChunkList中。因為記憶體一直在申請和釋放,上面那個Chunk可能因某些物件釋放後,導致記憶體佔用比小於75,則又會被放回到q050這個ChunkList中;當然也有可能某次分配後,記憶體佔用比例再次到達100,則會被挪到q100內。這樣設計的一個好處在於,可以儘量讓申請請求落在比較空閒的Chunk上,從而提高了記憶體分配的效率。


從HBase offheap到Netty的記憶體管理


仍以上述為例,某物件A申請了150B記憶體,二進位制對齊後實際申請了256B的記憶體。物件A釋放後,對應申請的Page也就釋放,Netty為了提高記憶體的使用效率,會把這些Page放到對應的Cache中,物件A申請的Page是按照256B來劃分的,所以直接按上圖所示,進入了一個叫做TinySubPagesCaches的緩衝池。這個緩衝池實際上是由多個佇列組成,每個佇列內代表Page劃分的不同尺寸,例如queue->32B,表示這個佇列中,快取的都是按照32B來劃分的Page,一旦有32B的申請請求,就直接去這個佇列找未佔滿的Page。這裡,可以發現,佇列中的同一個Page可以被多次申請,只是他們申請的記憶體大小都一樣,這也就不存在之前說的記憶體佔用率低的問題,反而佔用率會比較高。

當然,Cache又按照Page內部劃分量(稱之為elemSizeOfPage,也就是一個Page內會劃分成8KB/elemSizeOfPage個相等大小的小塊)分成3個不同型別的Cache。對那些小於512B的申請請求,將嘗試去TinySubPagesCaches中申請;對那些小於8KB的申請請求,將嘗試去SmallSubPagesDirectCaches中申請;對那些小於16MB的申請請求,將嘗試去NormalDirectCaches中申請。若對應的Cache中,不存在能用的記憶體,則直接去下面的6個ChunkList中找Chunk申請,當然這些Chunk有可能都被申請滿了,那麼只能向Offheap直接申請一個Chunk來滿足需求了。


Chunk內部分配的連續性(cache coherence)

上文基本理清了Chunk之上記憶體申請的原理,總體來看,Netty的記憶體分配還是做的非常精細的,從演算法上看,無論是申請/釋放效率還是記憶體利用率都比較有保障。這裡簡單闡述一下Chunk內部如何分配記憶體。

一個問題就是:如果要在一個Chunk內申請32KB的記憶體,那麼Chunk應該怎麼分配Page才比較高效,同時使用者的記憶體訪問效率比較高?

一個簡單的思路就是,把16MB的Chunk劃分成2048個8KB的Page,然後用一個佇列來維護這些Page。如果一個Page被使用者申請,則從佇列中出隊;Page被使用者釋放,則重新入隊。這樣記憶體的分配和釋放效率都非常高,都是O(1)的複雜度。但問題是,一個32KB物件會被分散在4個不連續的Page,使用者的記憶體訪問效率會受到影響。

Netty的Chunk內分配演算法,則兼顧了申請/釋放效率使用者記憶體訪問效率。提高使用者記憶體訪問效率的一種方式就是,無論使用者申請多大的記憶體量,都讓它落在一塊連續的實體記憶體上,這種特性我們稱之為Cache coherence

來看一下Netty的演算法設計:


從HBase offheap到Netty的記憶體管理


首先,16MB的Chunk分成2048個8KB的Page,這2048個Page正好可以組成一顆完全二叉樹(類似堆資料結構),這顆完全二叉樹可以用一個int[] map來維護。例如,map[1]就表示root,map[2]就表示root的左兒子,map[3]就表示root的右兒子,依次類推,map[2048]是第一個葉子節點,map[2049]是第二個葉子節點…,map[4095]是最後一個葉子節點。這2048個葉子節點,正好依次對應2048個Page。

這棵樹的特點就是,任何一顆子樹的所有Page都是在實體記憶體上連續的。所以,申請32KB的實體記憶體連續的操作,可以轉變成找一顆正好有4個Page空閒的子樹,這樣就解決了使用者記憶體訪問效率的問題,保證了Cache Coherence特性。

但如何解決分配和釋放的效率的問題呢?

思路其實不是特別難,但是Netty中用各種二進位制優化之後,顯的不那麼容易理解。所以,我畫了一副圖。其本質就是,完全二叉樹的每個節點id都維護一個map[id]值,這個值表示以id為根的子樹上,按照層次遍歷,第一個完全空閒子樹對應根節點的深度。例如在step.3圖中,id=2,層次遍歷碰到的第一顆完全空閒子樹是id=5為根的子樹,它的深度為2,所以map[2]=2。

理解了map[id]這個概念之後,再看圖其實就沒有那麼難理解了。圖中畫的是在一個64KB的chunk(由8個page組成,對應樹最底層的8個葉子節點)上,依次分配8KB、32KB、16KB的維護流程。可以發現,無論是申請記憶體,還是釋放記憶體,操作的複雜度都是log(N),N代表節點的個數。而在Netty中,N=2048,所以申請、釋放記憶體的複雜度都可以認為是常數級別的。

通過上述演算法,Netty同時保證了Chunk內部分配/申請多個Pages的高效和使用者記憶體訪問的高效。


引用計數和記憶體洩漏檢查

上文提到,HBase的ByteBuf也嘗試採用引用計數來跟蹤一塊記憶體的生命週期,被引用一次則其refCount++,取消引用則refCount--,一旦refCount=0則認為記憶體可以回收到記憶體池。思路很簡單,只是需要考慮下執行緒安全的問題。

但事實上,即使有了引用計數,可能還是容易碰到忘記顯式refCount--的操作,Netty提供了一個叫做ResourceLeakDetector的跟蹤器。在Enable狀態下,任何分出去的ByteBuf都會進入這個跟蹤器中,回收ByteBuf時則從跟蹤器中刪除。一旦發現某個時間點跟蹤器內的ByteBuff總數太大,則認為存在記憶體洩露。開啟這個功能必然會對效能有所影響,所以生產環境下都不開這個功能,只有在懷疑有記憶體洩露問題時開啟用來定位問題用。


總結

Netty的記憶體管理其實做的很精細,對HBase的Offheap化設計有不少啟發。目前HBase的記憶體分配器至少有3種:

  1. Rpc路徑上offheap記憶體分配器。實現較為簡單,以定長64KB為單位分配Page給物件,發現Offheap池無法分出來,則直接去Heap申請。

  2. Memstore的MSLAB記憶體分配器,核心思路跟RPC記憶體分配器相差不大。應該可以合二為一。

  3. BucketCache上的BucketAllocator。

就第1點和第2點而言,我覺得今後嘗試改成用Netty的PooledByteBufAllocator應該問題不大,畢竟Netty在多核併發/記憶體利用率以及CacheCoherence上都做了不少優化。由於BucketCache既可以存記憶體,又可以存SSD磁碟,甚至HDD磁碟。所以BucketAllocator做了更高程度的抽象,維護的都是一個(offset,len)這樣的二元組,Netty現有的介面並不能滿足需求,所以估計暫時只能維持現狀。

可以預期的是,HBase2.0效能必定是朝更好方向發展的,尤其是GC對P999的影響會越來越小。

- end -

參考資料:

  1. https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf

  2. https://www.facebook.com/notes/facebook-engineering/scalable-memory-allocation-using-jemalloc/480222803919/

  3. https://netty.io/wiki/reference-counted-objects.html


相關文章