解決golang 的記憶體碎片問題
本文譯自Why I encountered Go memory fragmentation? How did I resolve it?,作者透過分析golang的堆管理方式,解決了記憶體碎片的問題。
背景
我們的團隊正在搭建執行一個相容Prometheus的記憶體時序資料庫,該資料庫有一個資料結構,稱為"chunk"。每個chunk對應一個唯一鍵值標籤對的4個小時的資料點,如:
{host="host1", env="production"}
可以將一個資料點認為是一個時間戳加數值的組合,一個chunk包含了4個小時的資料點。資料庫同一時間只會儲存每個(唯一標籤對的)指標的8個chunk,且每4小時會對老的chunk進行清除。由於它是一個記憶體資料庫,因此使用快照恢復邏輯來防止資料丟失。
遇到的問題
透過觀察記憶體使用發現,在資料庫啟動32~36小時之後,記憶體使用一直在增加:
第1種除錯方式 -- Go pprof
一開始懷疑是記憶體洩露問題,因此透過每小時採集heap profile來對比記憶體使用差異,但此時並沒有發現任何異常。一開始懷疑可能是chunks沒有完全釋放,如果長期持有未使用的物件,可能會導致該問題,但透過pprof並沒有找到相關線索。
為什麼使用的記憶體在增加,但總的堆使用卻保持不變?
第2種除錯方式 -- Go memstats指標
透過如下go memstats指標發現可能出現了記憶體碎片:
go_memstats_heap_inuse_bytes{…} - go_memstats_heap_alloc_bytes{…}
指標結果顯示,堆申請的位元組數要少於使用的位元組數。這意味著有很多申請的空間沒有被有效地利用。通常在chunks過期前的4小時內,該值會增加,但之後會逐步降低。然而在出問題的節點上,該值並沒有降低。
我懷疑它可以為非重啟節點使用過期的空間來處理新攝取的資料,但是由於記憶體碎片而不能為重啟過的節點使用過期的空間(即使用恢復邏輯讀取快照)。
之後我將懷疑點轉向了快照的恢復邏輯。快照實際上由chunks的位元組構成,並放在檔案中。在處理過程中會並行寫chunk,因此chunk的順序是隨機的,這樣可以提高寫效能,而讀操作則是從檔案頭按順序讀取的。因此可以想象,每4個小時,當某些零散chunk過期時,就會導致大量記憶體碎片。
下面是嘗試的解決方式,即在將chunk寫入檔案之前會按照chunk的時間戳進行排序,這樣就可以按照時間順序來申請位元組(恢復期間會從頭部讀取位元組並分配記憶體),下面是修復後的申請方式:
經驗證發現,問題並沒有解決,且寫操作效能嚴重降級。
第3種除錯方式--理解Go 堆管理方式
至此需要理解Go是如何進行堆管理的。參考golang-memory-allocation。
簡單地說,Go執行時管理著大量mspans
,每個mspans
包含特定數目的連續8KB記憶體頁,不同msapns
有著不同的size class(大小),size class決定了mspan中的物件的大小,用於適應不同大小的物件,降低記憶體浪費。
假設要申請100位元組的物件,則需要選擇112位元組的size class(參見列表)。
通常每個chunk都有一個用於內部資料的位元組陣列,其建立方式為:
make([]byte, 0, 128)
Go中slice的大小並不是固定不變的,當slice的容量小於1024時會以2的倍數增加,當容量大於1024時,新slice的容量會變為原來的1.25倍。(本文對這部分描述有誤,此處糾正),在本場景中,大部分size-classes是固定的:
而目前恢復使用的chunk的為:
make([]byte, 0, actual chunk byte size)
這意味著攝取時採用的chunk size classes與恢復是採用的chunk size classes完全不同!恢復時使用未對齊mspan的實際chunk大小來儲存資料,導致過期記憶體重複利用率不高,也導致mspan中出現了大量記憶體碎片:
最後作者,透過如下方式解決了該問題:
- 將容量申請設定為128位元組,讓記憶體申請模式保持一致(即讓系統自動對其mspan),這樣就可以儘可能地複用記憶體
- 按照時間順序來寫入快照檔案,防止因為資料亂序導致出現chunk層面的記憶體碎片
透過如上兩種方式解決了該問題:
這裡解釋一下文中涉及的mstat的2個指標,更多參見Exploring Prometheus Go client metrics:
- go_memstats_heap_alloc_bytes:為物件申請的堆記憶體,單位位元組。該指標計算了所有GC沒有釋放的所有堆物件(可達的物件和不可達的物件)
- go_memstats_heap_inuse_bytes: in-use span中的位元組數。go_memstats_heap_inuse_bytes-go_memstats_heap_alloc_bytes表示那些已申請但沒有使用的堆記憶體。
總結
- Go將堆分為mspans
- 一個mspan由特定數目的連續8KB頁組成
- 每個mspan對應特定的size class,用來決定申請建立的物件大小
- 為麼避免在Go 執行時中出現記憶體碎片,需要同時考慮size classes和時間區域性性