Spark 內存管理詳解(下):內存管理

本文轉自:Spark內存管理詳解(下)——內存管理

本文最初由IBM developerWorks中國網站發表,其鏈接爲Apache Spark內存管理詳解
在這裏,正文內容分爲上下兩篇來闡述,這是下篇,上一篇請移步博客列表的上一篇文章。

Spark內存管理詳解(上)——內存分配
  1. 堆內和堆外內存
  2. 內存空間分配
Spark內存管理詳解(下)——內存管理
  3. 存儲內存管理
  4. 執行內存管理

3. 存儲內存管理

3.1 RDD的持久化機制

彈性分佈式數據集(RDD)作爲Spark最根本的數據抽象,是隻讀的分區記錄(Partition)的集合,只能基於在穩定物理存儲中的數據集上創建,或者在其他已有的RDD上執行轉換(Transformation)操作產生一個新的RDD。轉換後的RDD與原始的RDD之間產生的依賴關係,構成了血統(Lineage)。憑藉血統,Spark保證了每一個RDD都可以被重新恢復。但RDD的所有轉換都是惰性的,即只有當一個返回結果給Driver的行動(Action)發生時,Spark纔會創建任務讀取RDD,然後真正觸發轉換的執行。

Task在啓動之初讀取一個分區時,會先判斷這個分區是否已經被持久化,如果沒有則需要檢查Checkpoint或按照血統重新計算。所以如果一個RDD上要執行多次行動,可以在第一次行動中使用persist或cache方法,在內存或磁盤中持久化或緩存這個RDD,從而在後面的行動時提升計算速度。事實上,cache方法是使用默認的MEMORY_ONLY的存儲級別將RDD持久化到內存,故緩存是一種特殊的持久化。堆內和堆外存儲內存的設計,便可以對緩存RDD時使用的內存做統一的規劃和管理(存儲內存的其他應用場景,如緩存broadcast數據,暫時不在本文的討論範圍之內)。

RDD的持久化由Spark的Storage模塊[1]負責,實現了RDD與物理存儲的解耦合。Storage模塊負責管理Spark在計算過程中產生的數據,將那些在內存或磁盤、在本地或遠程存取數據的功能封裝了起來。在具體實現時Driver端和Executor端的Storage模塊構成了主從式的架構,即Driver端的BlockManager爲Master,Executor端的BlockManager爲Slave。Storage模塊在邏輯上以Block爲基本存儲單位,RDD的每個Partition經過處理後唯一對應一個Block(BlockId的格式爲rdd_RDD-ID_PARTITION-ID)。Master負責整個Spark應用程序的Block的元數據信息的管理和維護,而Slave需要將Block的更新等狀態上報到Master,同時接收Master的命令,例如新增或刪除一個RDD。

圖1 Storage模塊示意圖

在對RDD持久化時,Spark規定了MEMORY_ONLY、MEMORY_AND_DISK等7種不同的存儲級別,而存儲級別是以下5個變量的組合:

class StorageLevel private(
    private var _useDisk: Boolean, //磁盤
    private var _useMemory: Boolean, //這裏其實是指堆內內存
    private var _useOffHeap: Boolean, //堆外內存
    private var _deserialized: Boolean, //是否爲非序列化
    private var _replication: Int = 1 //副本個數
)

通過對數據結構的分析,可以看出存儲級別從三個維度定義了RDD的Partition(同時也就是Block)的存儲方式:

  • 存儲位置:磁盤/堆內內存/堆外內存。如MEMORY_AND_DISK是同時在磁盤和堆內內存上存儲,實現了冗餘備份。OFF_HEAP則是隻在堆外內存存儲,目前選擇堆外內存時不能同時存儲到其他位置。
  • 存儲形式:Block緩存到存儲內存後,是否爲非序列化的形式。如MEMORY_ONLY是非序列化方式存儲,OFF_HEAP是序列化方式存儲。
  • 副本數量:大於1時需要遠程冗餘備份到其他節點。如DISK_ONLY_2需要遠程備份1個副本。
3.2 RDD緩存的過程

RDD在緩存到存儲內存之前,Partition中的數據一般以迭代器(Iterator)的數據結構來訪問,這是Scala語言中一種遍歷數據集合的方法。通過Iterator可以獲取分區中每一條序列化或者非序列化的數據項(Record),這些Record的對象實例在邏輯上佔用了JVM堆內內存的other部分的空間,同一Partition的不同Record的空間並不連續。

RDD在緩存到存儲內存之後,Partition被轉換成Block,Record在堆內或堆外存儲內存中佔用一塊連續的空間。將Partition由不連續的存儲空間轉換爲連續存儲空間的過程,Spark稱之爲“展開”(Unroll)。Block有序列化和非序列化兩種存儲格式,具體以哪種方式取決於該RDD的存儲級別。非序列化的Block以一種DeserializedMemoryEntry的數據結構定義,用一個數組存儲所有的Java對象,序列化的Block則以SerializedMemoryEntry的數據結構定義,用字節緩衝區(ByteBuffer)來存儲二進制數據。每個Executor的Storage模塊用一個鏈式Map結構(LinkedHashMap)來管理堆內和堆外存儲內存中所有的Block對象的實例[6],對這個LinkedHashMap新增和刪除間接記錄了內存的申請和釋放。

因爲不能保證存儲空間可以一次容納Iterator中的所有數據,當前的計算任務在Unroll時要向MemoryManager申請足夠的Unroll空間來臨時佔位,空間不足則Unroll失敗,空間足夠時可以繼續進行。對於序列化的Partition,其所需的Unroll空間可以直接累加計算,一次申請。而非序列化的Partition則要在遍歷Record的過程中依次申請,即每讀取一條Record,採樣估算其所需的Unroll空間並進行申請,空間不足時可以中斷,釋放已佔用的Unroll空間。如果最終Unroll成功,當前Partition所佔用的Unroll空間被轉換爲正常的緩存RDD的存儲空間,如下圖2所示。

圖2 Spark Unroll示意圖

在上一篇的圖3和圖5中可以看到,在靜態內存管理時,Spark在存儲內存中專門劃分了一塊Unroll空間,其大小是固定的,統一內存管理時則沒有對Unroll空間進行特別區分,當存儲空間不足是會根據動態佔用機制進行處理。

3.3 淘汰和落盤

由於同一個Executor的所有的計算任務共享有限的存儲內存空間,當有新的Block需要緩存但是剩餘空間不足且無法動態佔用時,就要對LinkedHashMap中的舊Block進行淘汰(Eviction),而被淘汰的Block如果其存儲級別中同時包含存儲到磁盤的要求,則要對其進行落盤(Drop),否則直接刪除該Block。
存儲內存的淘汰規則爲:

  • 被淘汰的舊Block要與新Block的MemoryMode相同,即同屬於堆外或堆內內存
  • 新舊Block不能屬於同一個RDD,避免循環淘汰
  • 舊Block所屬RDD不能處於被讀狀態,避免引發一致性問題
  • 遍歷LinkedHashMap中Block,按照最近最少使用(LRU)的順序淘汰,直到滿足新Block所需的空間。其中LRU是LinkedHashMap的特性。

落盤的流程則比較簡單,如果其存儲級別符合_useDisk爲true的條件,再根據其_deserialized判斷是否是非序列化的形式,若是則對其進行序列化,最後將數據存儲到磁盤,在Storage模塊中更新其信息。

4. 執行內存管理

4.1 多任務間的分配

Executor內運行的任務同樣共享執行內存,Spark用一個HashMap結構保存了任務到內存耗費的映射。每個任務可佔用的執行內存大小的範圍爲1/2N ~ 1/N,其中N爲當前Executor內正在運行的任務的個數。每個任務在啓動之時,要向MemoryManager請求申請最少爲1/2N的執行內存,如果不能被滿足要求則該任務被阻塞,直到有其他任務釋放了足夠的執行內存,該任務纔可以被喚醒。

4.2 Shuffle的內存佔用

執行內存主要用來存儲任務在執行Shuffle時佔用的內存,Shuffle是按照一定規則對RDD數據重新分區的過程,我們來看Shuffle的Write和Read兩階段對執行內存的使用:

  • Shuffle Write
    • 若在map端選擇普通的排序方式,會採用ExternalSorter進行外排,在內存中存儲數據時主要佔用堆內執行空間。
    • 若在map端選擇Tungsten的排序方式,則採用ShuffleExternalSorter直接對以序列化形式存儲的數據排序,在內存中存儲數據時可以佔用堆外或堆內執行空間,取決於用戶是否開啓了堆外內存以及堆外執行內存是否足夠。
  • Shuffle Read
    • 在對reduce端的數據進行聚合時,要將數據交給Aggregator處理,在內存中存儲數據時佔用堆內執行空間。
    • 如果需要進行最終結果排序,則要將再次將數據交給ExternalSorter處理,佔用堆內執行空間。

在ExternalSorter和Aggregator中,Spark會使用一種叫AppendOnlyMap的哈希表在堆內執行內存中存儲數據,但在Shuffle過程中所有數據並不能都保存到該哈希表中,當這個哈希表佔用的內存會進行週期性地採樣估算,當其大到一定程度,無法再從MemoryManager申請到新的執行內存時,Spark就會將其全部內容存儲到磁盤文件中,這個過程被稱爲溢存(Spill),溢存到磁盤的文件最後會被歸併(Merge)。

Shuffle Write階段中用到的Tungsten是Databricks公司提出的對Spark優化內存和CPU使用的計劃[4],解決了一些JVM在性能上的限制和弊端。Spark會根據Shuffle的情況來自動選擇是否採用Tungsten排序。Tungsten採用的頁式內存管理機制建立在MemoryManager之上,即Tungsten對執行內存的使用進行了一步的抽象,這樣在Shuffle過程中無需關心數據具體存儲在堆內還是堆外。每個內存頁用一個MemoryBlock來定義,並用Object objlong offset這兩個變量統一標識一個內存頁在系統內存中的地址。堆內的MemoryBlock是以long型數組的形式分配的內存,其obj的值爲是這個數組的對象引用,offset是long型數組的在JVM中的初始偏移地址,兩者配合使用可以定位這個數組在堆內的絕對地址;堆外的MemoryBlock是直接申請到的內存塊,其obj爲null,offset是這個內存塊在系統內存中的64位絕對地址。Spark用MemoryBlock巧妙地將堆內和堆外內存頁統一抽象封裝,並用頁表(pageTable)管理每個Task申請到的內存頁。

Tungsten頁式管理下的所有內存用64位的邏輯地址表示,由頁號和頁內偏移量組成:

1. 頁號:佔13位,唯一標識一個內存頁,Spark在申請內存頁之前要先申請空閒頁號。
2. 頁內偏移量:佔51位,是在使用內存頁存儲數據時,數據在頁內的偏移地址。

有了統一的尋址方式,Spark可以用64位邏輯地址的指針定位到堆內或堆外的內存,整個Shuffle Write排序的過程只需要對指針進行排序,並且無需反序列化,整個過程非常高效,對於內存訪問效率和CPU使用效率帶來了明顯的提升[5]。

小結

Spark的存儲內存和執行內存有着截然不同的管理方式:對於存儲內存來說,Spark用一個LinkedHashMap來集中管理所有的Block,Block由需要緩存的RDD的Partition轉化而成;而對於執行內存,Spark用AppendOnlyMap來存儲Shuffle過程中的數據,在Tungsten排序中甚至抽象成爲頁式內存管理,開闢了全新的JVM內存管理機制。

結束語

Spark的內存管理是一套複雜的機制,且Spark的版本更新比較快,筆者水平有限,難免有敘述不清、錯誤的地方,若讀者有好的建議和更深的理解,還望不吝賜教。

參考文獻

  1. 《Spark技術內幕:深入解析Spark內核架構於實現原理》——第8章 Storage模塊詳解
  2. Spark存儲級別的源碼
  3. Spark Sort Based Shuffle內存分析
  4. Project Tungsten: Bringing Apache Spark Closer to Bare Metal
  5. Spark Tungsten-sort Based Shuffle 分析
  6. 探索Spark Tungsten的祕密
  7. Spark Task 內存管理(on-heap&off-heap)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章