Spark系列 - (6) Spark 內存管理

6. Spark內存管理

在執行Spark的應用程序時,Spark集羣會啓動Driver和Executor兩種JVM線程,前者爲主控進程,負責創建Spark上下文,提交Spark作業(Job),並將作業轉化爲計算任務(Task),在各個Executor進程間協調任務的調度,後者負責在工作節點上執行具體的計算任務,並將結果返回給Driver,同時爲需要持久化的RDD提供存儲功能。由於Driver的內存管理相對來說較爲簡單,本文主要對Executor的內存的管理進行分析,上下文中的Spark內存均特指Executor的內存。

6.1 堆內和堆外內存規劃

作爲一個JVM進程,Executor的內存管理建立在JVM的內存管理之上,Spark對JVM的堆內(On-heap)空間進行了更爲詳細的分配,以充分利用內存。同時,Spark引入對外(Off-heap),使之可以直接在工作節點的系統內存中開闢空間,進一步優化內存的使用。

堆內內存受到JVM統一管理,堆外內存是直接向操作系統進行內存的申請和釋放。

1. 堆內內存

堆內內存的大小,由Spark應用程序啓動時spark.executor.memory參數配置。Executor內存的併發任務共享JVM堆內內存,這些任務在緩存RDD數據和廣播(Broadcast)數據時佔用的內存被規劃爲存儲(Storage)內存,而這些任務在執行Shuffle時佔用的內存被規劃爲執行(Execution)內存,剩餘的部分不做特殊規劃,那些Spark內部的對象實例,或者用戶定義的Spark應用程序中的對象實例,均佔用剩餘的空間,不同的管理模式下,這三部分佔用的空間大小各不同。

Spark對堆內存的管理是一種邏輯上的規劃式的管理,因爲對象實例佔用內存的申請和釋放都是由JVM完成的,Spark只能在申請和釋放前記錄這些內存。

申請內存:

  1. Spark在代碼中new一個對象實例
  2. JVM從堆內內存分配空間,創建對象並返回對象引用
  3. Spark保存該對象的引用,記錄該對象佔用的內存

釋放內存:

  1. Spark記錄該對象釋放的內存,刪除該對象的引用
  2. 等待JVM的垃圾回收機制釋放該對象佔用的堆內內存

JVM的對象可以以序列化的方式存儲,序列化的過程是將對象轉換爲二進制字節流,本質上可以理解爲將非連續空間的鏈式存儲轉化爲連續空間或塊存儲,在訪問時則需要進行反序列化。對於Spark中序列化的對象是字節流形式的,其佔用的內存大小可直接計算,而對於非序列化的對象,其佔用的內存是通過週期性的採樣近似估算而得。

被Spark標記爲釋放的對象實例,很有可能在實際上並沒有被JVM回收。導致實際可用的內存小於Spark記錄的可用內存,從而無法完全避免內存溢出(OOM)的異常。

2. 堆外內存

爲了進一步優化內存的使用以及提高Shuffle時排序的效率,Spark引入了堆外(Off-heap)內存,使之可以直接在工作節點的系統內存中開闢空間,存儲經過序列化的二進制數據。

堆外內存意味着把內存對象分配在Java虛擬機的堆以外的內存,這些內存直接受操作系統管理(而不是虛擬機)。這樣做的結果就是能保持一個較小的堆,以減少垃圾收集對應用的影響。

利用 JDK Unsafe API(從 Spark 2.0開始,在管理堆外的存儲內存時不再基於Tachyon,而是與堆外的執行內存一樣, 基於JDK Unsafe API實現),Spark可以直接操作系統堆外內存,減少了不必要的內存開銷,以及頻繁的GC掃描和回收,提升了處理性能。堆外內存可以精確的申請和釋放(堆外內存之所以能夠被精確的申請和釋放,是由於內存的申請和釋放不再通過 JVM 機制,而是直接向操作系統申請,JVM對於內存的清理是無法準確指定時間點的,因此無法實現精確的釋放),而且序列化的數據佔用空間可以被精確計算,所以相比與堆內內存來說降低了管理的難度,也降低了誤差。

在默認情況下堆外內存並不啓用,可以通過配置spark.memory.offHeap.enabled參數啓用,並由spark.memory.offHeap.size參數設定堆外空間的大小。除了沒有other空間,堆外內存和堆內內存的劃分方式相同,所有運行中的併發任務共享存儲內存和執行內存。

6.2 內存空間管理

1. 靜態內存管理

在Spark最初採用的靜態內存管理機制下,存儲內存、執行內存和其他內存的大小在Spark引用程序運行期間均爲固定的,但用戶可以引用程序啓動前進行配置。

可用的存儲內存 = systemMaxMemory * spark.storge.memoryFraction * spark.storage.safetyFraction
可用的執行內存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction

其中這個預留的保險區域僅僅是一種邏輯上的規劃,在具體使用時Spark並沒有區別對待,和其他內存一樣交給了JVM去管理。

堆外的內存分配較爲簡單,只有存儲內存和執行內存,由參數spark.memory.storageFraction決定,由於堆外內存佔用空間可以被精確計算,所以無需再設定保險區域。

2. 統一內存管理

Spark1.6之後引入的統一內存管理機制,與靜態內存管理的區別在於存儲內存和執行內存共享同一塊空間,可以動態佔用對象的空間區域。

統一內存管理的堆外內存結構如下圖所示:

其中重要的優化在於動態佔用機制,其規則如下:

  • 設定基本的存儲內存和執行內存區域(spark.storage.storageFraction參數),該設定確定了雙方各自擁有的空間的範圍。
  • 雙方的空間都不足時,則存儲到硬盤;若己方空間不足而對方空餘時,可借用對方的空間。
  • 執行內存的空間被對方佔用,可以讓對方佔用部分轉存到硬盤,然後歸還借用空間。
  • 存儲內存的空間被對方佔用後,無法讓對方歸還,需要考慮shuffle過程的很多因素實現起來較爲複雜。

憑藉統一內存管理機制,Spark在一定程度上提高了堆內和堆外內存資源的利用率,降低了開發者維護Spark內存的難度,但並不意味着開發者可以高枕無憂,所以如果存儲內存的空間太大或者說緩存的數據過多,反而會導致頻繁的全量垃圾回收,降低任務執行時的性能,因爲緩存的RDD數據通常都是長期駐留內存的 。

6.3 存儲內存管理

1. RDD的持久化機制

RDD作爲Spark最根本的數據抽象,是隻讀的分區記錄(Partition)的集合,只能基於在穩定物理存儲中的數據集上創建,或者由其他已有的RDD上執行轉換操作產生一個新的RDD。轉化後的RDD與已有的RDD之間產生依賴關係,構成了血統(Lineage)。憑藉血統Spark保證了每一個RDD都可以被重新恢復。

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

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

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

Spark中7種存儲級別如下:

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

  1. 存儲位置:磁盤/堆內內存/堆外內存,如MEMORY_AND_DISK是同時在磁盤和堆內內存上存儲,實現了冗餘備份。OFF_HEAP則是隻在堆外內存存儲,目前選擇堆外內存時不能同時存儲到其他位置。

  2. 存儲形式:Block緩存到存儲內存後,是否爲非序列化的形式。如MEMORY_ONLY是非序列化方式存儲,OFF_HEAP是序列化方式存儲。

  3. 副本數量:大於1時需要遠程冗餘備份到其他節點。如DISK_ONLY_2需要遠程備份1個副本。

2. RDD緩存的過程

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

RDD在緩存到存儲內存之後,Partition被轉換成Block,Record在堆內或堆外存儲內存中佔用一塊連續的空間。將Parititon由不連續的存儲空間轉換爲連續存儲空間的過程,Spark稱之爲展開(Unroll)。

Block有序列化和非序列化兩種存儲格式,具體以哪中方式取決與該RDD的存儲級別。每個Executor的Storage模塊用一個鏈式Map結構(LinkedHashMap)來管理堆內和堆外存儲內存中的所有Block對象的實例,對於這個LinkedHashMap新增和刪除簡介記錄了內存的申請和釋放。

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

如果最終Unroll成功,當前Partition所佔用的Unroll空間被轉換爲正常緩存RDD的存儲空間。

3. 淘汰和落盤

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

存儲內存的淘汰規則爲:

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

6.4 執行內存管理

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

Shuffle Write

  1. 若在map端選擇普通的排序方式,會常用ExternalSorter進行排序,在內存中存儲數據時主要佔用對內執行空間。
  2. 若在map端選擇Tungsten的排序方式,則採用ShuffleExternalSorter直接以序列化形式存儲的數據排序,在內存中存儲數據時可以佔用堆外或堆內執行空間,取決於用戶是否開啓了堆外內存以及堆外執行內存是否足夠。

Shuffle Read

  1. 在對reduce端的數據進行聚合時,要將數據交給Aggregator處理,在內存中存儲數據時佔用堆內執行空間。
  2. 如果需要進行最終結果排序,則要再次將數據交給ExternalSorter處理,佔用堆內執行空間。

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


參考:

搜索『後端精進之路』並關注,立刻獲取最新文章和麪試資料。

Aqbsav

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章