[SPARK][CORE] 面試問題 之 Spark Shuffle概述

一提到shuffle, 我們猶如“談虎色變”。shuffle是大數據中的性能殺手,其來源于大數據中的元老級的組件Hadoop。

    在Hadoop中,map被定義為數據的初次拆分獲取解析階段, reduce被定義為負責最終數據的收集匯總階段,除了業務
邏輯的功能外,其他的核心數據處理都是由shuffle來支持。

在Hadoop組件中定義的Shuffle包括了什么呢? 為什么Shuffle是資源和時間開銷比較大的階段呢?

簡單來說,Shuffle中有三次數據排序。

  1. map端內存中的快速排序。shuffle的map端會在內存中開辟了一個緩沖區,當K-V數據從map出來后,分批進入緩沖區,對它們按K進行排序,并且按照map的邏輯進行分區,在出緩沖區落盤的時候,完成排序。
  2. map端分區文件的歸并排序。一旦內存中緩沖區滿了,就會被Hadoop寫到file中,這個過程交spill, 所寫的文件被叫做spill File(這些 spill file 存在 map 所在 host 的 local disk 上,而不是 HDFS)。 當 Map 結束時,這些 spill file 會被 merge 起來,按照分區進行歸并排序合并為多個文件。
  3. reduce端文件歸并排序。Reducer 內部有一個 thread 負責定期詢問 map output 的位置。另一個 thread 會把拷貝過來的 map output file merge 成更大的 file。當一個 reduce task 所有的 map output 都被拷貝到一個它的 host上時,reduce 就要開始對他們排序了。

Spark中的Shuffle

Spark 中的shuffle, 經歷了Hash、Sort 和 Tungsten-Sort 3個重要階段。

在1.1之前Spark采用Hash Shuffle, 1.1 之后引入Sort Shuffle, 1.6 時引入Tungsten-Sort Shuffle, 2.0 版本所有的Shuffle被統一到了Sort Shuffle中,3.2 時引入push-based shuffle; 當然還有未被合入社區,但在各大廠被開源使用Remote Shuffle Service;除此以外還有將向量計算引入shuffle計算的實現。

由上面的介紹可知, Shuffle 就是將map階段的拆分數據,通過設置的聚合方式按照分區進行聚合后,由reduce進行處理的過程。下面我們來總的認識下Spark中的shuffle方式:

1. Hash Shuffle

Hash Shuffle, 顧名思義,就是采取Hash的方式在Map的任務中為每個reduce端的任務生成一個文件。因此如果有M個map任務, R個reduce任務就會產生M x R個文件。巨量磁盤小文件而產生大量性能低下的Io操作,從而性能較低,因為其巨量的磁盤小文件還可能導致OOM,HashShuffle的合并機制通過重復利用buffer從而將磁盤小文件的數量降低到Core R個,但是當Reducer 端的并行任務或者是數據分片過多的時候,依然會產生大量的磁盤小文件。

    開啟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的性能。

2. Sort Shuffle

Sort Shuffle 的引入是如何解決上述問題的呢?

首先,在Shuffle的map階段會將所有數據進行排序,并將分區的數據寫入同一個文件中,在創建數據文件的同時會產生索引文件,來記錄分區的大小和偏移量。所以這里產生文件的數量和reduce分區就沒有關系了,只會產生2 * M個臨時文件。

下面我們先通過簡單分析來對Spark Shuffle有個簡單的了解,后面再詳細介紹:

Spark 在啟動時就會在SparkEnv中創建ShuffleManager來管理shuffle過程。

// Let the user specify short names for shuffle managers
val shortShuffleMgrNames =Map(
  "sort" ->classOf[org.apache.spark.shuffle.sort.SortShuffleManager].getName,
  "tungsten-sort" ->classOf[org.apache.spark.shuffle.sort.SortShuffleManager].getName)
val shuffleMgrName = conf.get("spark.shuffle.manager", "sort")
val shuffleMgrClass =
  shortShuffleMgrNames.getOrElse(shuffleMgrName.toLowerCase(Locale.ROOT), shuffleMgrName)
val shuffleManager = instantiateClass[ShuffleManager](shuffleMgrClass)

從上面的代碼可以看出,Spark目前只有唯一一種ShuffleManager的實現方式,就是SortShuffleManager。

下面我們可以看下ShuffleManager這個接口類:

private[spark] trait ShuffleManager {

/**
   * Register a shuffle with the manager and obtain a handle for it to pass to tasks.
   *向shuffleManager注冊shuffle,并返回handle
   */
def registerShuffle[K, V, C](
      shuffleId: Int,
      numMaps: Int,
      dependency: ShuffleDependency[K, V, C]): ShuffleHandle

/** Get a writer for a given partition. Called on executors by map tasks. */
def getWriter[K, V](handle: ShuffleHandle, mapId: Int, context: TaskContext): ShuffleWriter[K, V]

/**
   * Get a reader for a range of reduce partitions (startPartition to endPartition-1, inclusive).
   * Called on executors by reduce tasks.
   */
def getReader[K, C](
      handle: ShuffleHandle,
      startPartition: Int,
      endPartition: Int,
      context: TaskContext): ShuffleReader[K, C]
...
}

  • registerShuffle()方法用于注冊一種shuffle機制,并返回對應的ShuffleHandle(類似于句柄),handle內會存儲shuffle依賴信息。根據該handle可以進一步確定采用ShuffleWriter/ShuffleReader的種類。
  • getWriter()方法用于獲取ShuffleWriter。它是executor執行map任務時調用的。
  • getReader()方法用于獲取ShuffleReader。它是executor執行reduce任務時調用的。

我們都知道在Spark的DAG中,頂點是一個個 RDD,邊則是 RDD 之間通過 dependencies 屬性構成的父子關系。dependencies 又分為寬依賴和窄依賴,分別對應ShuffleDependency和NarrowDependency。當RDD間的依賴關系為ShuffleDependency時,RDD會通過其SparkEnv向ShuffleManager注冊一個shuffle, 并返回當前處理當前shuffle所需要的句柄。

Spark有2個類型的Task: ShuffleMapTask和ResultTask,在Spark中stage是以Pipeline運行的, 除了最后一個Stage對應的是ResultStage,其余的Stage對應的都是ShuffleMapStage。

ShuffleMapStage中的每個Task,叫做ShuffleMapTask,getWriter()方法的調用主要是在ShuffleMapTask中進行。調用getWriter方法會返回一個ShuffleWriter的trait。

除了需要從外部存儲讀取數據和RDD已經做過cache或者checkpoint的Task,一般Task的開始都是從ShuffledRDD的調用getReader()。調用getReader()會返回一個ShuffleReader的trait。


微信截圖_20220519215721.png

綜上,Spark的Shuffle模塊主要有ShuffleManager、ShuffleWriter和ShuffleReader。ShuffleManager目前在社區版中只有SortShuffleManager一種實現,ShuffleReader也只有BlockStoreShuffleReader一種實現,但是ShuffleWriter目前有BypassMergerSortShuffleWriter, SortShuffleWriter和UnsafeShuffleWriter三種實現。

3. Push-Based Shuffle

push-based shuffle方案,會在mapper執行后并且會被自動合并數據,然后將數據移動到下游的reducer。目前只支持Yarn方式的實現。

在中大規模的Spark shuffle中,Shuffle依然是很多的性能問題:

  • 第一個挑戰是可靠性問題。由于計算節點數據量大和 shuffle 工作負載的規模,可能會導致 shuffle fetch 失敗,從而導致昂貴的 stage 重試。
  • 第二個挑戰是效率問題。由于 reducer 的 shuffle fetch 請求是隨機到達的,因此 shuffle 服務也會隨機訪問 shuffle 文件中的數據。如果單個 shuffle 塊大小較小,則 shuffle 服務產生的小隨機讀取會嚴重影響磁盤吞吐量,從而延長 shuffle fetch 等待時間。
  • 第三個挑戰是擴展問題。由于 external shuffle service 是我們基礎架構中的共享服務,因此一些對 shuffle services 錯誤調優的作業也會影響其他作業。當一個作業錯誤地配置導致產生許多小的 shuffle blocks 將會給 shuffle 服務帶來壓力時,它不僅會給自身帶來性能下降,還會使共享相同 shuffle 服務的所有相鄰作業的性能下降。這可能會導致原本正常運行的作業出現不可預測的運行時延遲,尤其是在集群高峰時段。

push-based shuffle 利用共享的ESS服務,在map階段時將溢寫的數據文件,通過推送的方式推送到reduce對應的ESS節點,并在其中對小文件進行合并。其中push-based shuffle 實現了一個magnet shuffle服務,是一個增強的Spark ESS,它可以接受遠程推送的shuffle block,它會將block合并到每一個唯一shuffle分區文件。

push merge shuffle采用的push-merge shuffle機制。Mapper生成的shuffle數據被推送到遠程的magnet shuffle服務,并按照每個shuffle合并。Magnet在此期間可以將小的shuffle塊的隨機讀取轉換為MB大小的順序讀取。這個push操作與map任務完全解耦,所以無需添加到執行map任務的運行時中,一旦推送失敗就會導致maptask失敗。

盡可能地執行push,Magnet無需所有的shuffle都是完美的push成功。通過push-merge shuffle,Magnet復制shuffle數據,Reducer可以獲取合并后的、或者是沒有合并的shuffle數據作為任務輸入。也就是,即使沒有合并也可以讀取。

那么Spark是如何選擇Sort-based ShuffleWriter的具體實現方式呢?

ShuffleWriter方式的選擇

override def registerShuffle[K, V, C](
    shuffleId: Int,
    numMaps: Int,
    dependency: ShuffleDependency[K, V, C]): ShuffleHandle = {
  if (SortShuffleWriter.shouldBypassMergeSort(conf, dependency)) {
    // If there are fewer than spark.shuffle.sort.bypassMergeThreshold partitions and we don't
    // need map-side aggregation, then write numPartitions files directly and just concatenate
    // them at the end. This avoids doing serialization and deserialization twice to merge
    // together the spilled files, which would happen with the normal code path. The downside is
    // having multiple files open at a time and thus more memory allocated to buffers.
    new BypassMergeSortShuffleHandle[K, V](
      shuffleId, numMaps, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
  } else if (SortShuffleManager.canUseSerializedShuffle(dependency)) {
    // Otherwise, try to buffer map outputs in a serialized form, since this is more efficient:
    new SerializedShuffleHandle[K, V](
      shuffleId, numMaps, dependency.asInstanceOf[ShuffleDependency[K, V, V]])
  } else {
    // Otherwise, buffer map outputs in a deserialized form:
    new BaseShuffleHandle(shuffleId, numMaps, dependency)
  }
}

可以看出,根據條件的不同,會返回3種不同的handle,對應3種shuffle機制。從上到下來分析一下:

1. 檢查是否符合SortShuffleWriter.shouldBypassMergeSort()方法的條件:

def shouldBypassMergeSort(conf: SparkConf, dep: ShuffleDependency[_, _, _]): Boolean = {
  // We cannot bypass sorting if we need to do map-side aggregation.
  if (dep.mapSideCombine) {
    false
  } else {
    val bypassMergeThreshold: Int = conf.getInt("spark.shuffle.sort.bypassMergeThreshold", 200)
    dep.partitioner.numPartitions <= bypassMergeThreshold
  }
}

判斷是否符合bypassMergeSort的條件主要有以下兩個:

  • 該shuffle依賴中沒有map端聚合操作(如groupByKey()算子)
  • 分區數不大于參數spark.shuffle.sort.bypassMergeThreshold規定的值(默認200)

那么會返回BypassMergeSortShuffleHandle,啟用bypass merge-sort shuffle機制。

2. 如果不啟用上述bypass機制,那么繼續檢查是否符合canUseSerializedShuffle()方法的條件:

def canUseSerializedShuffle(dependency: ShuffleDependency[_, _, _]): Boolean = {
    val shufId = dependency.shuffleId
    val numPartitions = dependency.partitioner.numPartitions
    if (!dependency.serializer.supportsRelocationOfSerializedObjects) {
      log.debug(/*...*/)
      false
    } else if (dependency.aggregator.isDefined) {
      log.debug(/*...*/)
      false
    } else if (numPartitions > MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE) {
      log.debug(/*...*/)
      false
    } else {
      log.debug(/*...*/)
      true
    }
  }
}

也就是說,如果同時滿足以下三個條件:

  • 使用的序列化器支持序列化對象的重定位(如KryoSerializer)
  • shuffle依賴中完全沒有聚合操作
  • 分區數不大于常量MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE的值(最大分區ID號+1,即2^24=16777216)

那么會返回SerializedShuffleHandle,啟用序列化sort shuffle機制(也就是tungsten-sort)。

3. 如果既不用bypass也不用tungsten-sort,那么就返回默認的BaseShuffleHandle,采用基本的sort shuffle機制。

Untitled.png

目前spark中只有一種Shuffle的實現方式,即sort-Shuffle, 但是它包括了使用Tungsten實現的unsafeSortShuffle, 不需要排序的BypassMergeSortShuffle和baseSortShuffle, 并通過實現ShuffleWriter的方式根據不通過的情況進行選擇,可以更好的處理不同情況的數據任務。

總結,Spark為不同情況實現不同有趣的Shuffle機制。Spark的shuffle是通過將中間文件物化到spark.local.dir的本地臨時文件中,來增強spark的容錯性,但是也造成了shuffle時的性能壓力。在傳統的Hash shuffle中,可以直接將map端的數據shuffle歸類為對應的reduce 分區的數據,但是也造成了產生MxN(M是map Task, N 是Reduce Task)數量級的中間文件, 即使通過重用buffer, 將不同批次的Task的寫入文件進行重用,減少了一定量的數據文件,但是并不能從根本上減少文件的數量級。采用Sort-based Shuffle 主要是使用在數據量比較大的情況下,通過將map端的數據進行排序,并生成文件索引,那么就可以通過讀取文件的偏移量來區別不同的reduce應該拉取那部分的數據,產生的中間文件數據也變成了2 * M 個, 大大的減少了處理的文件數量。但是隨著數據服務壓力增加,大量的中間小文件會造成隨機io, io的壓力也會導致fetchfail的發生幾率的上升,push-based shuffle 主要是將map端的數據push到共享ESS進行合并,進一步的減少小文件的數量,將隨機io變為順序io, 同時減少中間文件的數量,提升集群的穩定性。

今天就先到這里,通過上面的介紹,我們也留下些面試題?

  1. Sort-based Shuffle, push- based Shuffle 和 Remote Shuffle Service 那么現在社區或業界提供這么多有趣的shuffle可供選擇,那么應用中應該如何選擇具體的Shuffle方式?他們的適用范圍是什么?
  2. 如果你要實現一種新的ShuffleManage應該怎么在Spark實現配置?
  3. 既然是Sort-based Shuffle 那么Shuffle后的數據是否是有序的?
  4. Remote Shuffle Service 和 Push-based Shuffle 他們的優劣分別是什么?
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容