解決golang 的記憶體碎片問題

charlieroro發表於2023-03-06

解決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小時之後,記憶體使用一直在增加:

image

第1種除錯方式 -- Go pprof

一開始懷疑是記憶體洩露問題,因此透過每小時採集heap profile來對比記憶體使用差異,但此時並沒有發現任何異常。一開始懷疑可能是chunks沒有完全釋放,如果長期持有未使用的物件,可能會導致該問題,但透過pprof並沒有找到相關線索。

為什麼使用的記憶體在增加,但總的堆使用卻保持不變?

第2種除錯方式 -- Go memstats指標

透過如下go memstats指標發現可能出現了記憶體碎片:

go_memstats_heap_inuse_bytes{…} - go_memstats_heap_alloc_bytes{…}
image

指標結果顯示,堆申請的位元組數要少於使用的位元組數。這意味著有很多申請的空間沒有被有效地利用。通常在chunks過期前的4小時內,該值會增加,但之後會逐步降低。然而在出問題的節點上,該值並沒有降低。

我懷疑它可以為非重啟節點使用過期的空間來處理新攝取的資料,但是由於記憶體碎片而不能為重啟過的節點使用過期的空間(即使用恢復邏輯讀取快照)。

之後我將懷疑點轉向了快照的恢復邏輯。快照實際上由chunks的位元組構成,並放在檔案中。在處理過程中會並行寫chunk,因此chunk的順序是隨機的,這樣可以提高寫效能,而讀操作則是從檔案頭按順序讀取的。因此可以想象,每4個小時,當某些零散chunk過期時,就會導致大量記憶體碎片。

image

下面是嘗試的解決方式,即在將chunk寫入檔案之前會按照chunk的時間戳進行排序,這樣就可以按照時間順序來申請位元組(恢復期間會從頭部讀取位元組並分配記憶體),下面是修復後的申請方式:

image

經驗證發現,問題並沒有解決,且寫操作效能嚴重降級。

第3種除錯方式--理解Go 堆管理方式

至此需要理解Go是如何進行堆管理的。參考golang-memory-allocation

image

簡單地說,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是固定的:

image

而目前恢復使用的chunk的為:

make([]byte, 0, actual chunk byte size)

這意味著攝取時採用的chunk size classes與恢復是採用的chunk size classes完全不同!恢復時使用未對齊mspan的實際chunk大小來儲存資料,導致過期記憶體重複利用率不高,也導致mspan中出現了大量記憶體碎片:

image-20230306095308333

最後作者,透過如下方式解決了該問題:

  1. 將容量申請設定為128位元組,讓記憶體申請模式保持一致(即讓系統自動對其mspan),這樣就可以儘可能地複用記憶體
  2. 按照時間順序來寫入快照檔案,防止因為資料亂序導致出現chunk層面的記憶體碎片

透過如上兩種方式解決了該問題:

image

這裡解釋一下文中涉及的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和時間區域性性

相關文章