Spark系列 - (5) Spark Shuffle

目前已經更新完《Java併發編程》,《JVM性能優化》,《Spring核心知識》《Docker教程》和《Spark基礎知識》,都是多年面試總結。歡迎關注【後端精進之路】,輕鬆閱讀全部文章。

Java併發編程:

Docker教程:

JVM性能優化:

Spring MVC系列:

Spark系列:


5. Spark Shuffle

5.1 Shuffle概念

有些運算需要將各節點上的同一類數據彙集到某一節點進行計算,把這些分佈在不同節點的數據按照一定的規則彙集到一起的過程稱爲Shuffle。

下圖是一個簡單的Spark Job的運行圖,根據寬依賴將任務劃分爲不同的Stage,

在劃分stage時,最後一個stage稱爲 FinalStage,它本質上是一個ResultStage對象,前面的所有stage被稱爲ShuffleMapStage。

  • ShuffleMapStage的結束伴隨着shuffle文件的寫磁盤。

  • ResultStage基本上對應代碼中的action算子,即將一個函數應用在RDD的各個partition的數據集上,意味着一個job 的運行結束。

觸發Shuffle的操作大概分爲如下幾類:

5.2 核心思想

Shuffle的核心思想可以用上圖來表示,前一個Stage的 ShuffleMapTask 進行 Shuffle Write, 把數據存儲在 BlockManager上面,並且把數據位置元信息上報到Driver 的MapOutTrack組件中,下一個Stage根據數據位置元信息,進行Shuffle Read,拉取上個Stage的輸出數據。

Shuffle中的任務個數

1. Map端task個數的確定

Shuffle過程中的task個數由RDD分區數決定,而RDD的分區個數與參數spark.default.parallelism有關.

在Yarn Cluster模式下,如果沒有手動設置,則:
spark.default.parallelism = max(所有executor使用的core總數,2)

參與決定分區數的參數defaultMinPartitions也是由該參數確定的,
defaultMinPartitions=min(spark.default.parallelism, 2)

由於spark對於一個partition中的最大文件大小有限制(spark.files.maxPartitionBytes = 128 M (默認)),爲128M,因此自定義分區時,不能選的過小。

常見的幾種情況如下:

2. reduce端的task個數的確定

Reduce端進行數據的聚合,一部分聚合算子可以手動指定並行度,如果沒有指定,則以map端的最後一個RDD分區作爲其分區數,分區數也就決定了reduce端的task個數。

5.3 HashShuffle

1. 未優化的HashShuffleManager

相對於傳統的 MapReduce,Spark 假定大多數情況下 Shuffle 的數據不需要排序,例如 Word Count,強制排序反而會降低性能。因此不在 Shuffle Read 時做 Merge Sort,如果需要合併的操作的話,則會使用聚合(agggregator),即用了一個 HashMap (實際上是一個 AppendOnlyMap)來將數據進行合併。

在 Map Task 過程按照 Hash 的方式重組 Partition 的數據,不進行排序。每個 Map Task 爲每個 Reduce Task 生成一個文件,通常會產生大量的文件(即對應爲 M*R 箇中間文件,其中 M 表示 Map Task 個數,R 表示 Reduce Task 個數),伴隨大量的隨機磁盤 I/O 操作與大量的內存開銷。

總結下這裏的兩個嚴重問題:

  • 生成大量文件,佔用文件描述符,同時引入 DiskObjectWriter 帶來的 Writer Handler 的緩存也非常消耗內存;
  • 如果在 Reduce Task 時需要合併操作的話,會把數據放在一個 HashMap 中進行合併,如果數據量較大,很容易引發 OOM。

2. 優化後的HashShuffleManager

針對上面的第一個問題,Spark做了改進,引入了File Consolidation機制。

一個Executor上所有的Map Task生成的分區文件只有一份,即將所有的Map Task相同的分區文件合併,這樣每個 Executor上最多隻生成N個分區文件。

這樣就減少了文件數,但是假如下游 Stage 的分區數 N 很大,還是會在每個Executor上生成 N 個文件,同樣,如果一個 Executor 上有 K 個 Core,還是會開 K*N 個 Writer Handler,所以這裏仍然容易導致OOM。

5.4 SortShuffle

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

1. 普通運行機制

下圖說明了普通的SortShuffleManager的原理。在該模式下,數據會先寫入一個內存數據結構中,此時根據不同的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個磁盤文件。

2. bypass運行機制

下圖說明了bypass SortShuffleManager的原理。bypass運行機制的觸發條件如下:

  • shuffle map task數量小於spark.shuffle.sort.bypassMergeThreshold參數的值。
  • 不是聚合類的shuffle算子(比如reduceByKey)。

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

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

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

擴展:Tungsten-Sort Based Shuffle / Unsafe Shuffle

從 Spark 1.5.0 開始,Spark 開始了鎢絲計劃(Tungsten),目的是優化內存和CPU的使用,進一步提升spark的性能。由於使用了堆外內存,而它基於 JDK Sun Unsafe API,故 Tungsten-Sort Based Shuffle 也被稱爲 Unsafe Shuffle。

它的做法是將數據記錄用二進制的方式存儲,直接在序列化的二進制數據上 Sort 而不是在 Java 對象上,這樣一方面可以減少內存的使用和 GC 的開銷,另一方面避免 Shuffle 過程中頻繁的序列化以及反序列化。在排序過程中,它提供 cache-efficient sorter,使用一個 8 bytes 的指針,把排序轉化成了一個指針數組的排序,極大的優化了排序性能。

但是使用 Tungsten-Sort Based Shuffle 有幾個限制,Shuffle 階段不能有 aggregate 操作,分區數不能超過一定大小(2^24-1,這是可編碼的最大 Parition Id),所以像 reduceByKey 這類有 aggregate 操作的算子是不能使用 Tungsten-Sort Based Shuffle,它會退化採用 Sort Shuffle。

從 Spark-1.6.0 開始,把 Sort Shuffle 和 Tungsten-Sort Based Shuffle 全部統一到 Sort Shuffle 中,如果檢測到滿足 Tungsten-Sort Based Shuffle 條件會自動採用 Tungsten-Sort Based Shuffle,否則採用 Sort Shuffle。從Spark-2.0.0開始,Spark 把 Hash Shuffle 移除,可以說目前 Spark-2.0 中只有一種 Shuffle,即爲 Sort Shuffle。

5.5 Shuffle Read

1. 何時開始fetch上一個stage的數據

當 parent stage 的所有 ShuffleMapTasks 結束後再 fetch。

理論上講一個 ShuffleMapTask 結束後就可以 fetch,但是爲了迎合 stage 的概念(即一個 stage 如果其 parent stages 沒有執行完,自己是不能被提交執行的),還是選擇全部 ShuffleMapTasks 執行完再去 fetch。

2. 邊 fetch 邊處理還是一次性 fetch 完再處理?

邊fetch邊處理。

本質上,MapReduce shuffle 階段就是邊 fetch 邊使用 combine() 進行處理,只是 combine() 處理的是部分數據。MapReduce 爲了讓進入 reduce() 的 records 有序,必須等到全部數據都 shuffle-sort 後再開始 reduce()。

Spark 不要求 shuffle 後的數據全局有序,因此沒必要等到全部數據 shuffle 完成後再處理。

那麼如何實現邊 shuffle 邊處理,而且流入的 records 是無序的?

使用可以 aggregate 的數據結構,比如 HashMap。
每 shuffle 得到(從緩衝的 FileSegment 中 deserialize 出來)一個 record,直接將其放進 HashMap 裏面。如果該 HashMap 已經存在相應的 Key,那麼直接進行 aggregate 也就是 func(hashMap.get(Key), Value),並將 func 的結果重新 put(key) 到 HashMap 中去。

3. fetch 來的數據存放到哪裏?

剛 fetch 來的 FileSegment 存放在 softBuffer 緩衝區,經過處理後的數據放在內存 + 磁盤上。也可以靈活設置這些數據是“只用內存”還是“內存+磁盤”。如果spark.shuffle.spill = false就只用內存。

內存使用的是AppendOnlyMap,類似Java的HashMap,內存+磁盤使用的是ExternalAppendOnlyMap,如果內存空間不足時,ExternalAppendOnlyMap可以將records進行sort後spill到磁盤上,等到需要它們的時候再進行歸併。

4. 怎麼獲得要 fetch 的數據的存放位置?

一個 ShuffleMapStage形成後,會將該 stage 最後一個 final RDD 註冊到 MapOutputTrackerMaster,reducer 在 shuffle 的時候去 driver 裏面的 MapOutputTrackerMaster 詢問 ShuffleMapTask 輸出的數據位置。

每個 ShuffleMapTask 完成時會將 FileSegment 的存儲位置信息彙報給 MapOutputTrackerMaster。
MapOutputTrackerMaster.registerShuffle(shuffleId, rdd.partitions.size)


參考:


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

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