在資料時代,過多耗記憶體的大查詢都有可能壓垮整個叢集,是以其記憶體管理子產品在整個系統中扮演着非常重要的角色。
而PolarDB-X 作為一款分布式資料庫,其面對的資料可能從TB到GB位元組不等,同時又要支援TP和AP Workload,要是在計算過程中記憶體使用不當,不僅會造成TP和AP互相影響,嚴重拖慢響應時間,甚至會出現記憶體雪崩、OOM問題,導緻資料庫服務不可用。
CPU和MEMORY相對于網絡帶寬比較昂貴,是以PolarDB-X 代價模型中,一般不會将涉及到大量資料又比較耗記憶體的計算下推到存儲DN,DN層一般不會有比較耗記憶體的計算。這樣還有一個好處,當查詢性能不給力的時候,無狀态的CN節點做彈性擴容代價相對于DN也低。
鑒于此,是以本文主要對PolarDB-X計算層的記憶體管理進行分析,這有助于大家有PolarDB-X有更深入的了解。
PolarDB-X記憶體管理機制的設計,主要為了幾類問題:
- 讓使用者更容易控制每個查詢的記憶體限制;
- 預防記憶體使用不當,導緻記憶體溢出進而引發OOM;
- 避免查詢間由于記憶體争搶出現互相餓死現象;
- 避免AP Workload使用過多記憶體,嚴重拖慢TP Workload
業界解決方案
在計算層遇到的記憶體問題,業界其他産品也會遇到。這裡我們先看下業界對此類問題是如何解決的。
PostgreSQL

為了面對記憶體問題,PG的記憶體管理有兩大特點:
- 記憶體按照一定的比例,分為四大區域(Shared Buffers、Temp Buffers、Work Mem、Maintenance Work Mem);
- 在查詢過程算子主要使用Work Mem區域記憶體,且每個算子都是預先配置設定固定記憶體的,但記憶體不夠時,需要與硬碟進行swap;
是以PG在生産中要求業務方合理配置記憶體比例,配置不當的話會極大的降低系統的性能。
一般有兩種方式去指導業務方配置:估計方法與計算方法。
第一種是可以根據業務量的大小和類型,一般語句運作時間,來粗略的估計一下。第二種方式是通過對資料庫的監控,資料采集,然後計算其大小。
Flink
接下來我們看看作為大資料庫實時領域非常流行的計算引擎Flink,他的記憶體管理特點是:
- 引入了一套基于位元組流的資料結構,在JVM虛拟機上實作了類似C申請和釋放記憶體的池化管理;
- 記憶體主要分為三塊: 網絡記憶體池、系統預留的記憶體池、計算記憶體池。
- 為每個算子設定一個[min-memory, max-meory] 排程上可以保證每個算子初始化的時候預先配置設定min memory,在計算過程中不斷從計算記憶體池申請記憶體,當申請失敗或者記憶體使用超過算子預先定義max memory的時候,主動觸發資料落盤。
Flink相對于PG來說,在計算過程中引入了動态管理機制,但這種機制是有限的。主要應該有兩點考慮:
- 整個計算是動态的,其實是很難權衡哪個算子搶占權重高,如果任意放開,肯定會影響性能;
- Flink是流水線計算模型,類似PartialAgg算子一定不落盤,是以一旦PartialAgg占用過多記憶體,導緻下遊沒有記憶體可用,而PartialAgg又一定會往下遊發送資料,這樣必然會導緻Dead Lock。
Spark
說到計算層的記憶體管理,就不得不提SPARK。SPARK早在2015年就提出了Project Tungsten。
當然鎢絲計劃的提出,主要是為了不要讓硬體成為Spark性能的瓶頸,無限充分利用硬體資源。而其中記憶體管理的設計不僅僅可以減少JVM記憶體釋放的開銷和垃圾回收的壓力,結合落盤能力可以支援海量資料的查詢。
Spark 記憶體管理和Flink很像的,這裡就不展開來說。但是它支援完全動态記憶體搶占,這種動态記憶體搶占主要展現在:Excution和Storage記憶體可以互相強制;Task間可以互相搶占;Task内部可以互相搶占。Spark這裡假設了每個Task動态搶占記憶體的能力相當于,理論值都是1/N (N表示程序内部Task數量)。
Spark其實是可以這麼假設的,他是典型的MR模型,隻有Map端計算完後,才排程Reduce端,所有在一個程序内部多個Task你可以簡單認為都是Map Task。當某個Task使用的記憶體不足1/2N時,它會等到其他Task釋放記憶體。而Task間最多可以搶占的門檻值也是1/N,而Task内算子是可以完全互相動态搶占的。
Spark的計算模型決定了他可以做到不預先為每個Task和算子預先配置設定記憶體,随着N的變化,動态調整每個Task的搶占能力。個人覺得這種動态搶占記憶體的方案,相對于其他計算引擎優越不少。但是由于Spark主要是針對大資料的ETL場景,穩定性至關重要,盡量避免任務重試。是以對每個Task搶占的上界都做了限制(Task Memory < 1/N * Executor Memory),確定每個Task都有記憶體執行任務。
Presto
Presto是由 Facebook 推出的一個基于Java開發的開源分布式SQL查詢引擎。它的特點是:
- 采用的是邏輯記憶體池,來管理不同類型的記憶體需求。
- Presto整體将記憶體分為三塊: System Pool、Reserved Pool、General Pool。
- Presto記憶體申請其實是不預配置設定的,且搶占是完全随機的,理論上不設定Task搶占的上限,但為了出現某個大查詢把記憶體耗盡的現象,是以背景會有定時輪訓的機制,定時輪訓和彙總Task使用記憶體,限制Query使用的最大記憶體;同時當記憶體不夠用的時候,會對接下來送出的查詢做限流,確定系統能夠平穩運作。
考慮到Presto是基于邏輯計數方式做記憶體管理,是以是不太精準的。但邏輯計算的好處是,管理記憶體成本比較低,這又往往适合對延遲要求特别低的線上計算場景。
PolarDB-X記憶體管理
業界基本上都會将記憶體分區域管理,針對于計算層主要差別在于: 記憶體是否預配置設定和查詢過程中是否支援動态搶占。而PolarDB-X 是存儲計算分離的架構,CN節點是無狀态的,且主要針對于HTAP場景,是以對實時性要求比較高。
能否充分使用記憶體,對我們的場景比較關鍵。是以我們采取類似Presto這種無預配置設定記憶體,且支援動态搶占記憶體方案。除此之外,我們會根據不同的WorkLoad來管理記憶體,充分滿足HTAP場景。
統一的記憶體模型
PolarDB-X記憶體也是分區域管理。結合自身的計算特點,按照不同的次元會有不同的劃分和使用方式。
- 結合HTAP特性,我們将記憶體池劃分為:
- System Memory Pool:系統預留的記憶體,主要用于分片中繼資料和資料結構、臨時對象;
- Cache Memory Pool:用于管理Plan Cache和其他LRU Cache的記憶體;
- AP Memory Pool: 用于管理WorkLoad是AP的記憶體;
- TP Memory Pool: 用于管理WorkLoad是TP的記憶體;
相對于業界其他産品來說,我們主要劃分出了AP和TP的記憶體區域。這麼做是為了做TP/AP Workload在記憶體使用上的資源隔離。
- 結合我們的計算層次結構,我們按照樹形結構去管理記憶體:
一個TP Workload的查詢,我們會從TP Memory 申請出Query Memory,然後按照我們的計算層次關系,又會劃分出Task/Pipeline/Driver/Operator Memory。
這樣的好處是,我們可以動态監測不同次元下的記憶體使用情況。這裡唯一要注意的是在Query Memory下,我們額外會建立一個Planner Memory,用于管理查詢時和DN互動的資料對象的記憶體。
- 結合算子對記憶體申請和釋放的行為,我們對一個Operator Memory劃分為兩個記憶體塊: Reserve和Revoke。
Reserve Memory顧名思義就是算子一旦申請了記憶體就一定不會被動釋放,直到該算子運作結束,主動釋放記憶體,比如Scan算子,主要和DN互動資料的,一般都采樣流式實作,每次運作都從DN擷取一批資料,然後發送給下遊,該算子一般不支援資料落盤,是以Scan算子都是申請Reseve Memory。
而Revoke Memory表示算子申請記憶體後,可以通過觸發資料落盤的方式被動釋放,将申請到的Revoke Memory歸還到記憶體池,以供其他算子使用。比如HashAgg算子,都是申請Revoke Memory,但它申請的記憶體過多,導緻這個查詢其他算子無記憶體可用,就會被架構觸發HashAgg資料落盤,将其申請的記憶體統統歸還給記憶體池。
這裡需要注意PolarDB-X對于Reserve和Revoke兩塊記憶體并沒有按比例嚴格劃分,這兩者是可以完全互相搶占的。
記憶體對象
對于記憶體管理上,比較重要的實作類就是Memory Pool和MemoryAllocatorCtx。其中Memory Pool提供了申請和釋放記憶體的API。
考慮到記憶體是按樹形結構管理起來的,申請一次記憶體會涉及到多次函數調用,是以這裡封裝了MemoryAllocatorCtx對象,用于確定每次申請/釋放記憶體的最小機關是512Kb,避免對MemoryPool相關函數多次調用的開銷。且MemoryAllocatorCtx對象會記錄上一次記憶體申請失敗的大小,是架構用于判斷記憶體是否不夠用的重要标志。
interface MemoryPool {
//申請reserve 記憶體
ListenableFuture<?> allocateReserveMemory(long size);
//嘗試着申請reserve 記憶體
boolean tryAllocateReserveMemory(long size, ListenableFuture<?> allocFuture);
//申請revoke 記憶體
ListenableFuture<?> allocateRevocableMemory(long size);
//釋放reserve 記憶體
void freeReserveMemory(long size);
//釋放revoke 記憶體
void freeRevocableMemory(long size);
}
在記憶體的使用上,我們采樣的是非預配置設定模型,在計算排程上不需要額外考慮每個算子的使用記憶體。每個算子都是在執行期間按需去申請釋放記憶體的,但這并不是意味着算子就可以任意去申請記憶體。
一旦所有的算子使用記憶體之和超過查詢規定的最大記憶體,或者記憶體不夠用的時候,我們都會阻塞目前查詢,為此我們在原有的MemoryPool基礎之上派生出了BlockingMemory;同樣的為了友善AP和TP Workload基于記憶體做自适應的資源隔離,我們進一步派生出了AdaptiveMemory。
BlockingMemory:記憶體申請過中,會根據記憶體是否超過一定的門檻值,建立阻塞對象,該對象會被執行線程引用。一般來說全局記憶體或者Query記憶體超過一定的門檻值(0.8)的時候,就會觸發目前算子申請記憶體失敗,并且主動退出執行,這個和Spark算子申請不到記憶體後,阻塞目前線程的行為是不一緻的。
AdaptiveMemory:用于做TP/AP的記憶體管理,在申請過程中會根據AP和TP的記憶體占比做一些自适應調整,比如觸發限流、query自殺、大查詢落盤等操作。
動态搶占記憶體機制
在記憶體申請和釋放的行為上,我們采樣的都是非阻塞模型,這種設計可以很好的和我們的時間片執行架構結合。結合這種設計,PolarDB-X算子都是不預配置設定記憶體的,各個算子都是在運作過程中完全動态搶占記憶體。
這鐘動态搶占記憶體主要展現在: AP Memory 和TP Memory之間、Query Memory之間以及Operator Memory的Revoke和Reserve之間。
而實作這種搶占機制的基本單元依然是Driver,由下圖可知,Driver會在運作過程中會根據目前Worload的記憶體空間、Query Used Memory 和 Operator Used Memory,來判斷執行線程是否需要讓出執行線程,被記憶體阻塞。一旦發現記憶體不夠用的話,會主動退出執行隊列,加入到阻塞隊列,直到記憶體空間滿足一定的條件,才會喚醒該Driver。
Driver被記憶體阻塞主要條件是:
- 當記憶體池子(AP/TP Memory Pool)記憶體不足門檻值的0.8之時,申請記憶體的算子會在下一刻會被暫停執行,等待被觸發資料落盤,以到達釋放記憶體的目的。
- 目前查詢申請的總記憶體超過目前Max Query Memory門檻值的0.8之後,申請的算子會被暫停執行等待被被觸發資料落盤,以到達釋放記憶體的目的。
當Driver被記憶體阻塞後,Driver會退出執行線程,将線程資源拱手相讓給其他Driver。同時會回調MemoryRevokeService服務,該服務主要是基于記憶體大小和Pipeline的依賴關系挑選出耗記憶體的Driver進行标記。
當記憶體不夠時,CN會基于Used Memory Size對Task做排序,依次對占用大記憶體的Task打标記;Taks内部則基于依賴關系,對Pipeline做排序,依次對父節點的Pipeline打标;Pipeline内部首先基于Driver Used Memory Siz做排序後,再結合Operator前後依賴關系,來決定Operator的記憶體釋放順序。
這裡唯一需要注意的是MemoryRevokeService隻是對需要釋放記憶體的Operator進行打标記,然後喚醒對應的Driver,等待被排程的時候由執行線程池來觸發資料落盤。
這裡可能會有一個疑問?為什麼打标記的同時不立刻觸發資料落盤釋放記憶體呢。由于Driver被落盤标記的時候,也有可能正在執行線程池執行,如果這個時候觸發Driver執行落盤操作的時候,Driver會有并發安全的問題。是以我們将Driver的落盤動作和執行動作都交給執行線程池統一處理。
從上圖還可知,一旦Driver被MemoryRevokeService喚醒,就排程到執行線程池中,首先做自我巡檢,判斷目前Driver是否包含SPILL标記,如果是的話,就需要觸發Driver上相應算子做資料落盤的動作,這裡唯一需要注意的是算子真正做資料落盤的邏輯是完全異步的,不占用執行線程。
一旦Driver開始做異步落盤後,也會主動退出執行線程,直到異步落盤結束後,才會喚醒目前Driver。整個過程確定了執行線程的資源不會被卡住。
基于負載的記憶體隔離
PolarDB-X在HTAP場景下是希望做到AP Workload不影響TP Workload。但當系統承載的都是AP Workload時,AP可以充分利用所有資源;當系統承載的都是TP Worload時,TP可以充分利用所有的資源。這裡頭我們巧妙了利用了TP Memory的門檻值來到達目的。
- Driver 執行過程中,按需申請記憶體,如果記憶體足夠,會反複被排程執行;
- 當Driver被記憶體阻塞時,判斷其Workload;
- 如果是AP,則通過MemoryRevokeService服務觸發資料落盤;如果是TP Workload,則在觸發資料落盤的同時,會判斷目前TP Memory是否超過TP Memory Pool,若不是,則說明AP Workload使用記憶體過多,則回調AP Workload做調整;
- 回調的方式有兩種:觸發部分AP Worload自殺,且觸發Ap Worload 基于令牌桶的方式做滑動限流。
其中系統觸發AP Worload自殺的回調機制,是比較危險的,預設是關閉的。而一旦觸發AP Worload限流,則令牌桶流入的速率會減半,這樣接下來的AP workload請求可能擷取不到Token,而隻能排隊等待。
預設我們将TP Memory Pool的值設定為記憶體最大可利用資源的80%,表示在沒有AP查詢時,TP可以使用100%資源,在沒有TP查詢時,AP可以使用100%資源,當TP和AP Worload比較高時,AP最大隻能占用20%的資源。
資料測試
基于15MB的記憶體跑通1g TPCH,這裡我們截取了幾個比較耗記憶體的Query統計了落盤檔案的數量。
測試了TPCC和TPCH混跑情況下,AP 對TP的影響。這部分測試也涵蓋了CPU的資源隔離,後面我們會單獨開一篇談談PolarDB-X基于負載的資源隔離技術。在不開啟TPCH的情況下,tpmc保持在3.2w-3.4w之間; 再開啟TPCH後,tpmc基本可以維持在3w以上,且有抖動10%左右。
總結
從上表對比來看:
- PolarDB-X算子不預配置設定記憶體,而是在計算過程中按需去申請記憶體,可以充分提高記憶體使用率;
- 支援Workload/Query/Operator次元的記憶體動态搶占,比較适合HTAP場景,可以通過設定不同次元的門檻值,盡可能確定在記憶體上TP Workload不受AP的幹擾;
- 相對于PostgreSQL/Flink/Spark來,PolarDB-X釋放記憶體的時機是Lazy的。就是說當算子記憶體不夠的時候,算子并不是立即落盤釋放記憶體的,而是退出執行線程,由另外一個服務計算出更加耗記憶體的算子,這種方案可以挑選更加耗記憶體的查詢或者算子做資料落盤,避免對小查詢造成影響。但Lazy的方式存在一定的風險,可能在一瞬間記憶體未及時釋放,而運作的查詢申請記憶體過多,導緻OOM,但好在PolarDB-X是計算存儲分離的架構,計算層是無狀态的;
- PolarDB-X 相對于Presto 動态記憶體搶占的粒度更加細,将Workload也納入考慮。但Presto在實作的細節上會考慮大查詢間記憶體的互相影響。而在HTAP場景下,我們更加注重AP對TP的影響,AP内查詢間并沒有額外的機制去保證記憶體不受影響。
從業界産品來看,每個産品在記憶體管理上都有各自的特點,這和産品本身的定位是有一定關系。而PolarDB-X作為一款計算存儲分離的HTAP資料庫來說,其計算層目前采用的完全動态記憶體搶占方案,可以做到充分使用記憶體,避免AP對TP的影響。
歡迎大家對我們持續關注!