【大數據計算引擎-Spark】 Spark 內核解析-下

Spark內核泛指Spark的核心運行機制,包括Spark核心組件的運行機制、Spark任務調度機制、Spark內存管理機制、Spark核心功能的運行原理等,熟練掌握Spark內核原理,能夠幫助我們更好地完成Spark代碼設計,並能夠幫助我們準確鎖定項目運行過程中出現的問題的癥結所在。

Spark Shuffle 解析

Shuffle 的核心要點

ShuffleMapStage與ResultStage

ShuffleMapStage與ResultStage
在劃分stage時,最後一個stage稱爲finalStage,它本質上是一個ResultStage對象,前面的所有stage被稱爲ShuffleMapStage。
ShuffleMapStage的結束伴隨着shuffle文件的寫磁盤。
ResultStage基本上對應代碼中的action算子,即將一個函數應用在RDD的各個partition的數據集上,意味着一個job的運行結束。

Shuffle中的任務個數

我們知道,Spark Shuffle分爲map階段和reduce階段,或者稱之爲ShuffleRead階段和ShuffleWrite階段,那麼對於一次Shuffle,map過程和reduce過程都會由若干個task來執行,那麼map task和reduce task的數量是如何確定的呢?

假設Spark任務從HDFS中讀取數據,那麼初始RDD分區個數由該文件的split個數決定,也就是一個split對應生成的RDD的一個partition,我們假設初始partition個數爲N。

初始RDD經過一系列算子計算後(假設沒有執行repartition和coalesce算子進行重分區,則分區個數不變,仍爲N,如果經過重分區算子,那麼分區個數變爲M),我們假設分區個數不變,當執行到Shuffle操作時,map端的task個數和partition個數一致,即map task爲N個。

reduce端的stage默認取spark.default.parallelism這個配置項的值作爲分區數,如果沒有配置,則以map端的最後一個RDD的分區數作爲其分區數(也就是N),那麼分區數就決定了reduce端的task的個數。

reduce端數據的讀取

根據stage的劃分我們知道,map端task和reduce端task不在相同的stage中,map task位於ShuffleMapStage,reduce task位於ResultStage,map task會先執行,那麼後執行的reduce task如何知道從哪裏去拉取map task落盤後的數據呢?
reduce端的數據拉取過程如下:

  1. map task 執行完畢後會將計算狀態以及磁盤小文件位置等信息封裝到MapStatus對象中,然後由本進程中的MapOutPutTrackerWorker對象將mapStatus對象發送給Driver進程的MapOutPutTrackerMaster對象;
  2. 在reduce task開始執行之前會先讓本進程中的MapOutputTrackerWorker向Driver進程中的MapoutPutTrakcerMaster發動請求,請求磁盤小文件位置信息;
  3. 當所有的Map task執行完畢後,Driver進程中的MapOutPutTrackerMaster就掌握了所有的磁盤小文件的位置信息。此時MapOutPutTrackerMaster會告訴MapOutPutTrackerWorker磁盤小文件的位置信息;
  4. 完成之前的操作之後,由BlockTransforService去Executor0所在的節點拉數據,默認會啓動五個子線程。每次拉取的數據量不能超過48M(reduce task每次最多拉取48M數據,將拉來的數據存儲到Executor內存的20%內存中)。

HashShuffle解析

以下的討論都假設每個Executor有1個CPU core。

未經優化的HashShuffleManager

shuffle write階段,主要就是在一個stage結束計算之後,爲了下一個stage可以執行shuffle類的算子(比如reduceByKey),而將每個task處理的數據按key進行“劃分”。所謂“劃分”,就是對相同的key執行hash算法,從而將相同key都寫入同一個磁盤文件中,而每一個磁盤文件都只屬於下游stage的一個task。在將數據寫入磁盤之前,會先將數據寫入內存緩衝中,當內存緩衝填滿之後,纔會溢寫到磁盤文件中去。

下一個stage的task有多少個,當前stage的每個task就要創建多少份磁盤文件。比如下一個stage總共有100個task,那麼當前stage的每個task都要創建100份磁盤文件。如果當前stage有50個task,總共有10個Executor,每個Executor執行5個task,那麼每個Executor上總共就要創建500個磁盤文件,所有Executor上會創建5000個磁盤文件。由此可見,未經優化的shuffle write操作所產生的磁盤文件的數量是極其驚人的。

shuffle read階段,通常就是一個stage剛開始時要做的事情。此時該stage的每一個task就需要將上一個stage的計算結果中的所有相同key,從各個節點上通過網絡都拉取到自己所在的節點上,然後進行key的聚合或連接等操作。由於shuffle write的過程中,map task給下游stage的每個reduce task都創建了一個磁盤文件,因此shuffle read的過程中,每個reduce task只要從上游stage的所有map task所在節點上,拉取屬於自己的那一個磁盤文件即可。

shuffle read的拉取過程是一邊拉取一邊進行聚合的。每個shuffle read task都會有一個自己的buffer緩衝,每次都只能拉取與buffer緩衝相同大小的數據,然後通過內存中的一個Map進行聚合等操作。聚合完一批數據後,再拉取下一批數據,並放到buffer緩衝中進行聚合操作。以此類推,直到最後將所有數據到拉取完,並得到最終的結果。

未優化的HashShuffleManager工作原理如圖所示:
未優化的HashShuffleManager工作原理

優化後的HashShuffleManager

爲了優化HashShuffleManager我們可以設置一個參數,spark.shuffle. consolidateFiles,該參數默認值爲false,將其設置爲true即可開啓優化機制,通常來說,如果我們使用HashShuffleManager,那麼都建議開啓這個選項。

開啓consolidate機制之後,在shuffle write過程中,task就不是爲下游stage的每個task創建一個磁盤文件了,此時會出現shuffleFileGroup的概念,每個shuffleFileGroup會對應一批磁盤文件,磁盤文件的數量與下游stage的task數量是相同的。一個Executor上有多少個CPU core,就可以並行執行多少個task。而第一批並行執行的每個task都會創建一個shuffleFileGroup,並將數據寫入對應的磁盤文件內。

當Executor的CPU core執行完一批task,接着執行下一批task時,下一批task就會複用之前已有的shuffleFileGroup,包括其中的磁盤文件,也就是說,此時task會將數據寫入已有的磁盤文件中,而不會寫入新的磁盤文件中。因此,consolidate機制允許不同的task複用同一批磁盤文件,這樣就可以有效將多個task的磁盤文件進行一定程度上的合併,從而大幅度減少磁盤文件的數量,進而提升shuffle write的性能。

假設第二個stage有100個task,第一個stage有50個task,總共還是有10個Executor(Executor CPU個數爲1),每個Executor執行5個task。那麼原本使用未經優化的HashShuffleManager時,每個Executor會產生500個磁盤文件,所有Executor會產生5000個磁盤文件的。但是此時經過優化之後,每個Executor創建的磁盤文件的數量的計算公式爲:CPU core的數量 * 下一個stage的task數量,也就是說,每個Executor此時只會創建100個磁盤文件,所有Executor只會創建1000個磁盤文件。

優化後的HashShuffleManager工作原理如圖所示:
優化後的HashShuffleManager工作原理

SortShuffle解析

SortShuffleManager的運行機制主要分成兩種,一種是普通運行機制,另一種是bypass運行機制。當shuffle read task的數量小於等於spark.shuffle.sort. bypassMergeThreshold參數的值時(默認爲200),就會啓用bypass機制。

普通運行機制
在該模式下,數據會先寫入一個內存數據結構中,此時根據不同的shuffle算子,可能選用不同的數據結構。如果是reduceByKey這種聚合類的shuffle算子,那麼會選用Map數據結構,一邊通過Map進行聚合,一邊寫入內存;如果是join這種普通的shuffle算子,那麼會選用Array數據結構,直接寫入內存。接着,每寫一條數據進入內存數據結構之後,就會判斷一下,是否達到了某個臨界閾值。如果達到臨界閾值的話,那麼就會嘗試將內存數據結構中的數據溢寫到磁盤,然後清空內存數據結構。

在溢寫到磁盤文件之前,會先根據key對內存數據結構中已有的數據進行排序。排序過後,會分批將數據寫入磁盤文件。默認的batch數量是10000條,也就是說,排序好的數據,會以每批1萬條數據的形式分批寫入磁盤文件。寫入磁盤文件是通過Java的BufferedOutputStream實現的。BufferedOutputStream是Java的緩衝輸出流,首先會將數據緩衝在內存中,當內存緩衝滿溢之後再一次寫入磁盤文件中,這樣可以減少磁盤IO次數,提升性能。

一個task將所有數據寫入內存數據結構的過程中,會發生多次磁盤溢寫操作,也就會產生多個臨時文件。最後會將之前所有的臨時磁盤文件都進行合併,這就是merge過程,此時會將之前所有臨時磁盤文件中的數據讀取出來,然後依次寫入最終的磁盤文件之中。此外,由於一個task就只對應一個磁盤文件,也就意味着該task爲下游stage的task準備的數據都在這一個文件中,因此還會單獨寫一份索引文件,其中標識了下游各個task的數據在文件中的start offset與end offset。

SortShuffleManager由於有一個磁盤文件merge的過程,因此大大減少了文件數量。比如第一個stage有50個task,總共有10個Executor,每個Executor執行5個task,而第二個stage有100個task。由於每個task最終只有一個磁盤文件,因此此時每個Executor上只有5個磁盤文件,所有Executor只有50個磁盤文件。

普通運行機制的SortShuffleManager工作原理如圖所示:
普通運行機制的SortShuffleManager工作原理
bypass運行機制
bypass運行機制的觸發條件如下:
 shuffle map task數量小於spark.shuffle.sort.bypassMergeThreshold參數的值。
 不是聚合類的shuffle算子。

此時,每個task會爲每個下游task都創建一個臨時磁盤文件,並將數據按key進行hash然後根據key的hash值,將key寫入對應的磁盤文件之中。當然,寫入磁盤文件時也是先寫入內存緩衝,緩衝寫滿之後再溢寫到磁盤文件的。最後,同樣會將所有臨時磁盤文件都合併成一個磁盤文件,並創建一個單獨的索引文件。

該過程的磁盤寫機制其實跟未經優化的HashShuffleManager是一模一樣的,因爲都要創建數量驚人的磁盤文件,只是在最後會做一個磁盤文件的合併而已。因此少量的最終磁盤文件,也讓該機制相對未經優化的HashShuffleManager來說,shuffle read的性能會更好。

而該機制與普通SortShuffleManager運行機制的不同在於:第一,磁盤寫機制不同;第二,不會進行排序。也就是說,啓用該機制的最大好處在於,shuffle write過程中,不需要進行數據的排序操作,也就節省掉了這部分的性能開銷。

bypass運行機制的SortShuffleManager工作原理如圖所示:
bypass運行機制的SortShuffleManager工作原理

Spark 內存管理

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

堆內和堆外內存規劃

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

堆內內存

堆內內存的大小,由 Spark 應用程序啓動時的 –executor-memory 或 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 記錄的可用內存。所以 Spark 並不能準確記錄實際可用的堆內內存,從而也就無法完全避免內存溢出(OOM, Out of Memory)的異常。

雖然不能精準控制堆內內存的申請和釋放,但 Spark 通過對存儲內存和執行內存各自獨立的規劃管理,可以決定是否要在存儲內存裏緩存新的 RDD,以及是否爲新的任務分配執行內存,在一定程度上可以提升內存的利用率,減少異常的出現。

堆外內存

爲了進一步優化內存的使用以及提高 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 空間,堆外內存與堆內內存的劃分方式相同,所有運行中的併發任務共享存儲內存和執行內存。

(該部分內存主要用於程序的共享庫、Perm Space、線程Stack和一些Memory mapping等, 或者類C方式allocate object)

內存空間分配

靜態內存管理

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

# 堆內內存計算公式
可用的存儲內存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safety Fraction
可用的執行內存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safety Fraction

其中 systemMaxMemory 取決於當前 JVM 堆內內存的大小,最後可用的執行內存或者存儲內存要在此基礎上與各自的 memoryFraction 參數和 safetyFraction 參數相乘得出。上述計算公式中的兩個 safetyFraction 參數,其意義在於在邏輯上預留出 1-safetyFraction 這麼一塊保險區域,降低因實際內存超出當前預設範圍而導致 OOM 的風險(上文提到,對於非序列化對象的內存採樣估算會產生誤差)。值得注意的是,這個預留的保險區域僅僅是一種邏輯上的規劃,在具體使用時 Spark 並沒有區別對待,和”其它內存”一樣交給了 JVM 去管理。

Storage內存和Execution內存都有預留空間,目的是防止OOM,因爲Spark堆內內存大小的記錄是不準確的,需要留出保險區域。

堆外的空間分配較爲簡單,只有存儲內存和執行內存,如圖1-3所示。可用的執行內存和存儲內存佔用的空間大小直接由參數spark.memory.storageFraction 決定,由於堆外內存佔用的空間可以被精確計算,所以無需再設定保險區域。
靜態內存管理
靜態內存管理機制實現起來較爲簡單,但如果用戶不熟悉 Spark 的存儲機制,或沒有根據具體的數據規模和計算任務或做相應的配置,很容易造成”一半海水,一半火焰”的局面,即存儲內存和執行內存中的一方剩餘大量的空間,而另一方卻早早被佔滿,不得不淘汰或移出舊的內容以存儲新的內容。由於新的內存管理機制的出現,這種方式目前已經很少有開發者使用,出於兼容舊版本的應用程序的目的,Spark 仍然保留了它的實現。

統一內存管理

Spark 1.6 之後引入的統一內存管理機制,與靜態內存管理的區別在於存儲內存和執行內存共享同一塊空間,可以動態佔用對方的空閒區域,統一內存管理的堆內內存結構如圖所示:

統一內存管理——堆內內存

統一內存管理——堆外內存

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

  1. 設定基本的存儲內存和執行內存區域(spark.storage.storageFraction 參數),該設定確定了雙方各自擁有的空間的範圍;
  2. 雙方的空間都不足時,則存儲到硬盤;若己方空間不足而對方空餘時,可借用對方的空間;(存儲空間不足是指不足以放下一個完整的 Block)
  3. 執行內存的空間被對方佔用後,可讓對方將佔用的部分轉存到硬盤,然後”歸還”借用的空間;
  4. 存儲內存的空間被對方佔用後,無法讓對方”歸還”,因爲需要考慮 Shuffle 過程中的很多因素,實現起來較爲複雜。
    統一內存管理的動態佔用機制如圖所示:

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

存儲內內管理

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 時使用的內存做統一的規劃和管理。

RDD 的持久化由 Spark 的 Storage 模塊負責,實現了 RDD 與物理存儲的解耦合。Storage 模塊負責管理 Spark 在計算過程中產生的數據,將那些在內存或磁盤、在本地或遠程存取數據的功能封裝了起來。在具體實現時 Driver 端和 Executor 端的 Storage 模塊構成了主從式的架構,即 Driver 端的 BlockManager 爲 Master,Executor 端的 BlockManager 爲 Slave。

Storage 模塊在邏輯上以 Block 爲基本存儲單位,RDD 的每個 Partition 經過處理後唯一對應一個 Block(BlockId 的格式爲 rdd_RDD-ID_PARTITION-ID )。Driver端的Master 負責整個 Spark 應用程序的 Block 的元數據信息的管理和維護,而Executor端的 Slave 需要將 Block 的更新等狀態上報到 Master,同時接收 Master 的命令,例如新增或刪除一個 RDD。

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

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

Spark中7種存儲級別如下:

持久化級別 含義
MEMORY_ONLY 以非序列化的Java對象的方式持久化在JVM內存中。如果內存無法完全存儲RDD所有的partition,那麼那些沒有持久化的partition就會在下一次需要使用它們的時候,重新被計算
MEMORY_AND_DISK 同上,但是當某些partition無法存儲在內存中時,會持久化到磁盤中。下次需要使用這些partition時,需要從磁盤上讀取
MEMORY_ONLY_SER 同MEMORY_ONLY,但是會使用Java序列化方式,將Java對象序列化後進行持久化。可以減少內存開銷,但是需要進行反序列化,因此會加大CPU開銷
MEMORY_AND_DISK_SER 同MEMORY_AND_DISK,但是使用序列化方式持久化Java對象
DISK_ONLY 使用非序列化Java對象的方式持久化,完全存儲到磁盤上
MEMORY_ONLY_2 MEMORY_AND_DISK_2 等等 如果是尾部加了2的持久化級別,表示將持久化數據複用一份,保存到其他節點,從而在數據丟失時,不需要再次計算,只需要使用備份數據即可

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

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

RDD緩存過程

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

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

Block 有序列化和非序列化兩種存儲格式,具體以哪種方式取決於該 RDD 的存儲級別。非序列化的 Block 以一種 DeserializedMemoryEntry 的數據結構定義,用一個數組存儲所有的對象實例,序列化的 Block 則以 SerializedMemoryEntry的數據結構定義,用字節緩衝區(ByteBuffer)來存儲二進制數據。每個 Executor 的 Storage 模塊用一個鏈式 Map 結構(LinkedHashMap)來管理堆內和堆外存儲內存中所有的 Block 對象的實例,對這個 LinkedHashMap 新增和刪除間接記錄了內存的申請和釋放。

因爲不能保證存儲空間可以一次容納 Iterator 中的所有數據,當前的計算任務在 Unroll 時要向 MemoryManager 申請足夠的 Unroll 空間來臨時佔位,空間不足則 Unroll 失敗,空間足夠時可以繼續進行。
對於序列化的 Partition,其所需的 Unroll 空間可以直接累加計算,一次申請。

對於非序列化的 Partition 則要在遍歷 Record 的過程中依次申請,即每讀取一條 Record,採樣估算其所需的 Unroll 空間並進行申請,空間不足時可以中斷,釋放已佔用的 Unroll 空間。

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

淘汰與落盤

由於同一個 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 模塊中更新其信息。

執行內存管理

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

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

 Shuffle Read

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

在 ExternalSorter 和 Aggregator 中,Spark 會使用一種叫 AppendOnlyMap 的哈希表在堆內執行內存中存儲數據,但在 Shuffle 過程中所有數據並不能都保存到該哈希表中,當這個哈希表佔用的內存會進行週期性地採樣估算,當其大到一定程度,無法再從 MemoryManager 申請到新的執行內存時,Spark 就會將其全部內容存儲到磁盤文件中,這個過程被稱爲溢存(Spill),溢存到磁盤的文件最後會被歸併(Merge)。
Shuffle Write 階段中用到的 Tungsten 是 Databricks 公司提出的對 Spark 優化內存和 CPU 使用的計劃(鎢絲計劃),解決了一些 JVM 在性能上的限制和弊端。Spark 會根據 Shuffle 的情況來自動選擇是否採用 Tungsten 排序。

Tungsten 採用的頁式內存管理機制建立在 MemoryManager 之上,即 Tungsten 對執行內存的使用進行了一步的抽象,這樣在 Shuffle 過程中無需關心數據具體存儲在堆內還是堆外。
每個內存頁用一個 MemoryBlock 來定義,並用 Object obj 和 long offset 這兩個變量統一標識一個內存頁在系統內存中的地址。

堆內的 MemoryBlock 是以 long 型數組的形式分配的內存,其 obj 的值爲是這個數組的對象引用,offset 是 long 型數組的在 JVM 中的初始偏移地址,兩者配合使用可以定位這個數組在堆內的絕對地址;堆外的 MemoryBlock 是直接申請到的內存塊,其 obj 爲 null,offset 是這個內存塊在系統內存中的 64 位絕對地址。Spark 用 MemoryBlock 巧妙地將堆內和堆外內存頁統一抽象封裝,並用頁表(pageTable)管理每個 Task 申請到的內存頁。
Tungsten 頁式管理下的所有內存用 64 位的邏輯地址表示,由頁號和頁內偏移量組成:

 頁號:佔 13 位,唯一標識一個內存頁,Spark 在申請內存頁之前要先申請空閒頁號。
 頁內偏移量:佔 51 位,是在使用內存頁存儲數據時,數據在頁內的偏移地址。
有了統一的尋址方式,Spark 可以用 64 位邏輯地址的指針定位到堆內或堆外的內存,整個 Shuffle Write 排序的過程只需要對指針進行排序,並且無需反序列化,整個過程非常高效,對於內存訪問效率和 CPU 使用效率帶來了明顯的提升。

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

Spark 核心組件解析

BlockManager是整個Spark底層負責數據存儲與管理的一個組件,Driver和Executor的所有數據都由對應的BlockManager進行管理。

Driver上有BlockManagerMaster,負責對各個節點上的BlockManager內部管理的數據的元數據進行維護,比如block的增刪改等操作,都會在這裏維護好元數據的變更。

每個節點都有一個BlockManager,每個BlockManager創建之後,第一件事即使去向BlockManagerMaster進行註冊,此時BlockManagerMaster會爲其長難句對應的BlockManagerInfo。
BlockManager運行原理如下圖所示:
BlockManager原理
BlockManagerMaster與BlockManager的關係非常像NameNode與DataNode的關係,BlockManagerMaster中保存中BlockManager內部管理數據的元數據,進行維護,當BlockManager進行Block增刪改等操作時,都會在BlockManagerMaster中進行元數據的變更,這與NameNode維護DataNode的元數據信息,DataNode中數據發生變化時NameNode中的元數據信息也會相應變化是一致的。\

每個節點上都有一個BlockManager,BlockManager中有3個非常重要的組件:
· DiskStore:負責對磁盤數據進行讀寫;
· MemoryStore:負責對內存數據進行讀寫;
· BlockTransferService:負責建立BlockManager到遠程其他節點的BlockManager的連接,負責對遠程其他節點的BlockManager的數據進行讀寫;

每個BlockManager創建之後,做的第一件事就是想BlockManagerMaster進行註冊,此時BlockManagerMaster會爲其創建對應的BlockManagerInfo。

使用BlockManager進行寫操作時,比如說,RDD運行過程中的一些中間數據,或者我們手動指定了persist(),會優先將數據寫入內存中,如果內存大小不夠,會使用自己的算法,將內存中的部分數據寫入磁盤;此外,如果persist()指定了要replica,那麼會使用BlockTransferService將數據replicate一份到其他節點的BlockManager上去。

使用BlockManager進行讀操作時,比如說,shuffleRead操作,如果能從本地讀取,就利用DiskStore或者MemoryStore從本地讀取數據,但是本地沒有數據的話,那麼會用BlockTransferService與有數據的BlockManager建立連接,然後用BlockTransferService從遠程BlockManager讀取數據;例如,shuffle Read操作中,很有可能要拉取的數據在本地沒有,那麼此時就會到遠程有數據的節點上,找那個節點的BlockManager來拉取需要的數據。

只要使用BlockManager執行了數據增刪改的操作,那麼必須將Block的BlockStatus上報到BlockManagerMaster,在BlockManagerMaster上會對指定BlockManager的BlockManagerInfo內部的BlockStatus進行增刪改操作,從而達到元數據的維護功能。

Spark共享變量底層實現

Spark一個非常重要的特性就是共享變量。

默認情況下,如果在一個算子的函數中使用到了某個外部的變量,那麼這個變量的值會被拷貝到每個task中,此時每個task只能操作自己的那份變量副本。如果多個task想要共享某個變量,那麼這種方式是做不到的。

Spark爲此提供了兩種共享變量,一種是Broadcast Variable(廣播變量),另一種是Accumulator(累加變量)。Broadcast Variable會將用到的變量,僅僅爲每個節點拷貝一份,即每個Executor拷貝一份,更大的用途是優化性能,減少網絡傳輸以及內存損耗。Accumulator則可以讓多個task共同操作一份變量,主要可以進行累加操作。Broadcast Variable是共享讀變量,task不能去修改它,而Accumulator可以讓多個task操作一個變量。

廣播變量

廣播變量允許編程者在每個Executor上保留外部數據的只讀變量,而不是給每個任務發送一個副本。

每個task都會保存一份它所使用的外部變量的副本,當一個Executor上的多個task都使用一個大型外部變量時,對於Executor內存的消耗是非常大的,因此,我們可以將大型外部變量封裝爲廣播變量,此時一個Executor保存一個變量副本,此Executor上的所有task共用此變量,不再是一個task單獨保存一個副本,這在一定程度上降低了Spark任務的內存佔用。
使用廣播變量

Spark還嘗試使用高效的廣播算法分發廣播變量,以降低通信成本。

Spark提供的Broadcast Variable是隻讀的,並且在每個Executor上只會有一個副本,而不會爲每個task都拷貝一份副本,因此,它的最大作用,就是減少變量到各個節點的網絡傳輸消耗,以及在各個節點上的內存消耗。此外,Spark內部也使用了高效的廣播算法來減少網絡消耗。

可以通過調用SparkContext的broadcast()方法來針對每個變量創建廣播變量。然後在算子的函數內,使用到廣播變量時,每個Executor只會拷貝一份副本了,每個task可以使用廣播變量的value()方法獲取值。
在任務運行時,Executor並不獲取廣播變量,當task執行到 使用廣播變量的代碼時,會向Executor的內存中請求廣播變量,如下圖所示:
task向Executor請求廣播變量
之後Executor會通過BlockManager向Driver拉取廣播變量,然後提供給task進行使用,如下圖所示:
 Executor從Driver拉取廣播變量
廣播大變量是Spark中常用的基礎優化方法,通過減少內存佔用實現任務執行性能的提升。

累加器

累加器(accumulator):Accumulator是僅僅被相關操作累加的變量,因此可以在並行中被有效地支持。它們可用於實現計數器(如MapReduce)或總和計數。

Accumulator是存在於Driver端的,集羣上運行的task進行Accumulator的累加,隨後把值發到Driver端,在Driver端彙總(Spark UI在SparkContext創建時被創建,即在Driver端被創建,因此它可以讀取Accumulator的數值),由於Accumulator存在於Driver端,從節點讀取不到Accumulator的數值。

Spark提供的Accumulator主要用於多個節點對一個變量進行共享性的操作。Accumulator只提供了累加的功能,但是卻給我們提供了多個task對於同一個變量並行操作的功能,但是task只能對Accumulator進行累加操作,不能讀取它的值,只有Driver程序可以讀取Accumulator的值。

Accumulator的底層原理如下圖所示:
累加器原理

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