一提到shuffle, 我們猶如“談虎色變”。shuffle是大數據中的性能殺手,其來源于大數據中的元老級的組件Hadoop。
在Hadoop中,map被定義為數據的初次拆分獲取解析階段, reduce被定義為負責最終數據的收集匯總階段,除了業務
邏輯的功能外,其他的核心數據處理都是由shuffle來支持。
在Hadoop組件中定義的Shuffle包括了什么呢? 為什么Shuffle是資源和時間開銷比較大的階段呢?
簡單來說,Shuffle中有三次數據排序。
- map端內存中的快速排序。shuffle的map端會在內存中開辟了一個緩沖區,當K-V數據從map出來后,分批進入緩沖區,對它們按K進行排序,并且按照map的邏輯進行分區,在出緩沖區落盤的時候,完成排序。
- map端分區文件的歸并排序。一旦內存中緩沖區滿了,就會被Hadoop寫到file中,這個過程交spill, 所寫的文件被叫做spill File(這些 spill file 存在 map 所在 host 的 local disk 上,而不是 HDFS)。 當 Map 結束時,這些 spill file 會被 merge 起來,按照分區進行歸并排序合并為多個文件。
- 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。
綜上,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機制。
目前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, 同時減少中間文件的數量,提升集群的穩定性。
今天就先到這里,通過上面的介紹,我們也留下些面試題?
- Sort-based Shuffle, push- based Shuffle 和 Remote Shuffle Service 那么現在社區或業界提供這么多有趣的shuffle可供選擇,那么應用中應該如何選擇具體的Shuffle方式?他們的適用范圍是什么?
- 如果你要實現一種新的ShuffleManage應該怎么在Spark實現配置?
- 既然是Sort-based Shuffle 那么Shuffle后的數據是否是有序的?
- Remote Shuffle Service 和 Push-based Shuffle 他們的優劣分別是什么?