解決golang 的內存碎片問題

解決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和時間局部性
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章