我們在之前scheduler模塊的分析中了解到,DAGScheduler劃分stage的依據就是Shuffle Dependency,那么Shuffle是一個怎么樣的過程呢?Shuffle為何成為性能調優的重點呢?接下來的shuffle模塊將從源碼的角度來嘗試給出答案。
為什么存在shuffle
Spark分布式的架構、分布式的計算、分布式的存儲導致的,當運行某些特殊的算子(aggregate),匯聚具有相同特征的數據到同一個節點來進行計算時,就會發生Shuffle(洗牌)操作,這個數據重新打亂然后匯聚到不同的節點的過程就是Shuffle。
shuffle帶來的問題
1.數據量會很大,數據量級為TB、PB甚至EB的數據集分布在成百上千甚至上萬臺機器上
2.在匯聚過程中,數據大小大于本地內存,導致多次發生溢寫磁盤
3.數據需要通過網絡傳輸,因此數據的序列化和反序列化變得相對復雜
4.為了節省帶寬,數據可能需要壓縮,如何在壓縮率和壓縮解壓時間中間做一個權衡
Shuffle策略的進化史:
在Spark 1.0版本之前,Spark只支持Hash Based Shuffle,因為很多應用場景并不需要排序,而Hadoop中MapReduce的shuffle過程:partition->spill to disk->sort->combiner->merge中,就必須要排序,多余的排序只能使性能變差。Spark為了跳過不需要的排序,最早實現的是Hash Based Shuffle,原理很簡單:每個Task根據key的哈希值計算出每個key將要寫入的partition,然后將數據寫入一個文件供下游的Task來拉取。如果應用需要實現排序的功能,就需要用戶調用相關算子(sortByKey)去實現。
但是這種Hash Based Shuffle模式有其缺點:當并行度很高時,會產生很多中間落地的文件,比如說map的并行度為500,reduce的并行度為500,那么就會有500*500=250000個中間文件生成,同時打開這么多個文件并進行隨機讀對系統的內存和磁盤IO會造成很大壓力。
為了解決這個問題,在Spark 0.8.1中加入了Shuffle Consolidate File機制,在1.6版本之前,需要通過設置spark.shuffle.consolidateFiles設置為true(默認為false)來使用這個功能,1.6版本之后成為默認項。其實現原理為:對于同一個core的不同Task在寫中間文件的時候可以共享追加同一個文件,這樣就顯著的減小了文件的數量。可以通過下圖加深理解:
Shuffle Consolidate File機制雖然緩解了Shuffle過程產生文件過多的問題,但是并沒有徹底解決內存和IO的問題,所以在Spark 1.1中實現了Sort Based Shuffle,通過spark.shuffle.manager選項可以設置,默認為Hash,而在Spark 1.2中Sort Based Shuffle取代Hash Based Shuffle成為默認選項,在Spark 2.0版本之后,Hash Based Shuffle已經不見蹤影,Sort Based Shuffle成為唯一選項。
Sort Based Shuffle的實現有點復雜:首先,每個ShuffleMapTask不會為每個Reducer生成單獨的一個文件,它會將所有的結果寫到一個文件中,同時生成一個Index文件,Reducer可以通過這個Index文件取得它需要處理的數據,這樣就避免了產生大量的中間文件,也就節省同時打開大量文件使用的內存和隨機寫帶來的IO。過程是這樣的:
- 每個Map Task會為下游的每一個Reducer,或者說每一個partition生成一個Array,將key-value數據寫入到這個Array中,每一個partition中的數據并不會排序(避免不必要的排序)
- 每個Array中的數據如果超過某個閾值將會寫到外部存儲,這個文件會記錄相應的partitionId以及保存了多少了數據條目等信息
- 最后用歸并排序將不同partition的文件歸并到一個新的文件中,每個partition數據在新的文件中相當于一個桶,并且需要同時生成Index索引文件來記錄桶的位置信息
可通過下圖加深理解:
shuffle write
接下來,通過源碼閱讀來了解Hash、Sort兩種模式的shuffle過程,首先在shuffle過程中,必定有中間落地數據,這是因為Shuffle從各個節點中找到特征相同的數據并把它們匯聚到相應的節點是一件耗時耗力的工程,為了容錯,需要把中間數據持久化,其次也是因為數據量較大,內存可能會放不下。
這樣,整個Shuffle過程就被分成了兩個部分,shuffle write和shuffle read,在executor模塊中,我們分析到了task的執行,實際上是調用Task類的runTask()方法來計算,而shuffle write過程則是存在于Task的實現類ShuffleMapTask的runTask()方法中:
override def runTask(context: TaskContext): MapStatus = {
...省略部分代碼,作用為反序列化Task信息得到rdd和dependence
var writer: ShuffleWriter[Any, Any] = null //ShuffleWrite實例
try {
val manager = SparkEnv.get.shuffleManager
writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)
writer.write(rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]])
writer.stop(success = true).get
} catch {
case e: Exception =>
try {
if (writer != null) {
writer.stop(success = false)
}
} catch {
case e: Exception =>
log.debug("Could not stop writer", e)
}
throw e
}
}
這段代碼中首先反序列化收到的Task信息,然后調用了ShuffleWrite實例的write方法,我們首先來看writer的實例化代碼writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)
其中,manager是ShuffleManager,在1.6.3版本中,它有兩個實現類:HashShuffleManager和SortShuffleManager,而在2.3.0版本中,HashShuffleManager已經不見蹤影,只剩下SortShuffleManager實現類,Hash Based Shuffle已經被優化掉,但我們依然會分析這倆種Shuffle過程來做一個對比。
Hash Shuffle Write
HashShuffleManager中getWriter方法創建了HashShuffleWriter實例
override def getWriter[K, V](handle: ShuffleHandle, mapId: Int, context: TaskContext)
: ShuffleWriter[K, V] = {
new HashShuffleWriter(
shuffleBlockResolver, handle.asInstanceOf[BaseShuffleHandle[K, V, _]], mapId, context)
}
接著調用了這個實例的write方法,在方法中首先判斷是否存在aggregator聚合操作,并進一步判斷是否為map端的聚合mapSideCombine,如果是的話就調用combineValuesByKey方法對records進行聚合(spark中很多算子實現了mapSideCombine,例如reduceByKey),否則的話就直接返回records。
override def write(records: Iterator[Product2[K, V]]): Unit = {
val iter = if (dep.aggregator.isDefined) { //如果需要聚合操作
if (dep.mapSideCombine) { //如果是map端的聚合
dep.aggregator.get.combineValuesByKey(records, context)
} else {
records
}
} else { //如果不需要聚合
require(!dep.mapSideCombine, "Map-side combine without Aggregator specified!")
records
}
for (elem <- iter) {
val bucketId = dep.partitioner.getPartition(elem._1) //獲取該element需要寫的partition
shuffle.writers(bucketId).write(elem._1, elem._2) //寫到本地,writer調用shuffleBlockResolver.forMapTask方法中的writer
}
}
val bucketId = dep.partitioner.getPartition(elem._1)
這一行代碼對應我們上面說的根據Key的哈希值來計算出對應的partition Id,dep對應著傳入的ShuffleHandle,那么相應的Partitioner就是HashPartitioner,以下是其getPartition的實現:
def getPartition(key: Any): Int = key match {
case null => 0
case _ => Utils.nonNegativeMod(key.hashCode, numPartitions)
}
再點進去看nonNegativeMod方法其實很簡單:key的hashCode對numPartitions取模并保證結果為非負數。
再來看接下來的第二行代碼
shuffle.writers(bucketId).write(elem._1, elem._2)
其中shuffle是ShuffleWriterGroup的實例
private val shuffle: ShuffleWriterGroup = shuffleBlockResolver.forMapTask(dep.shuffleId, mapId, numOutputSplits, ser,writeMetrics)
ShuffleWriterGroup顧名思義writer組,用來存儲writers,為每一個reducer或者說每一個partition都保存一個writer,而這每一個writer其實是DiskBlockObjectWriter的實例,其中封裝了本地文件的信息。
以下是forMapTask方法的代碼:
def forMapTask(shuffleId: Int, mapId: Int, numReducers: Int, serializer: Serializer,
writeMetrics: ShuffleWriteMetrics): ShuffleWriterGroup = {
new ShuffleWriterGroup {
shuffleStates.putIfAbsent(shuffleId, new ShuffleState(numReducers))
private val shuffleState = shuffleStates(shuffleId)
val openStartTime = System.nanoTime
val serializerInstance = serializer.newInstance()
val writers: Array[DiskBlockObjectWriter] = {
Array.tabulate[DiskBlockObjectWriter](numReducers) { bucketId =>
val blockId = ShuffleBlockId(shuffleId, mapId, bucketId)
// 本地文件
val blockFile: File = blockManager.diskBlockManager.getFile(blockId)
val tmp = Utils.tempFileWith(blockFile)
blockManager.getDiskWriter(blockId, tmp, serializerInstance, bufferSize, writeMetrics)
}
}
// Creating the file to write to and creating a disk writer both involve interacting with
// the disk, so should be included in the shuffle write time.
writeMetrics.incShuffleWriteTime(System.nanoTime - openStartTime)
override def releaseWriters(success: Boolean) {
shuffleState.completedMapTasks.add(mapId)
}
}
}
每一個MapTask針對下游的每個partition生成一個本地文件來存儲信息,這樣的話就會生成M*R個中間文件(M為Mapper的數量,R為Reducer的數量),這就是我們上面說的HashBasedShuffle的弊病。
根據Key的哈希值取得對應的writer后,最后通過DiskBlockObjectWriter的write方法將數據寫到本地文件:
/**
* Writes a key-value pair.
*/
def write(key: Any, value: Any) {
if (!initialized) {
open()
}
objOut.writeKey(key)
objOut.writeValue(value)
recordWritten()
}
Sort Shuffle Write
以下是SortShuffleWriter類的write方法:
override def write(records: Iterator[Product2[K, V]]): Unit = {
sorter = if (dep.mapSideCombine) { //如果是map端的聚合
require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!")
new ExternalSorter[K, V, C]( //key value combiner
context, dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer)
} else { //如果不需要聚合
// In this case we pass neither an aggregator nor an ordering to the sorter, because we don't
// care whether the keys get sorted in each partition; that will be done on the reduce side
// if the operation being run is sortByKey.
new ExternalSorter[K, V, V](
context, aggregator = None, Some(dep.partitioner), ordering = None, dep.serializer)
}
sorter.insertAll(records)
// Don't bother including the time to open the merged output file in the shuffle write time,
// because it just opens a single file, so is typically too fast to measure accurately
// (see SPARK-3570).
val output: File = shuffleBlockResolver.getDataFile(dep.shuffleId, mapId)
val tmp = Utils.tempFileWith(output)
try {
val blockId = ShuffleBlockId(dep.shuffleId, mapId, IndexShuffleBlockResolver.NOOP_REDUCE_ID)
// 寫本地文件,返回的是寫入一個partition數據的長度
val partitionLengths: Array[Long] = sorter.writePartitionedFile(blockId, tmp)
// 根據返回的長度寫Index文件
shuffleBlockResolver.writeIndexFileAndCommit(dep.shuffleId, mapId, partitionLengths, tmp)
mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths)
} finally {
if (tmp.exists() && !tmp.delete()) {
logError(s"Error while deleting temp file ${tmp.getAbsolutePath}")
}
}
}
在這個方法中,首先考慮聚合,如果是mapSideCombine,那么創建一個帶有aggregator和Key的排序器的外部排序器ExternalSorter,否則就創建一個不帶聚合和Key的排序器的ExternalSorter,然后將數據都放入排序器中。
然后將數據從排序器的數據結構中利用歸并排序寫入到本地文件中,并根據返回的parititionLengths創建Index文件。
下面,我們可以從外部排序器ExternalSorter入手來了解這個過程,以下是它的writePartitionedFile方法:
def writePartitionedFile(
blockId: BlockId,
outputFile: File): Array[Long] = {
// Track location of each range in the output file
val lengths = new Array[Long](numPartitions)
if (spills.isEmpty) { //如果spills是空的說明數據都在內存中
// Case where we only have in-memory data
//如果有聚合,則內存的數據結構為PartitionedAppendOnlyMap,否則為PartitionedPairBuffer
val collection = if (aggregator.isDefined) map else buffer
// 根據傳入的比較器獲取數據結構中數據的有序迭代器
// 如果是首先是根據分區partition排序的,其次根據Key的Hash值
val it = collection.destructiveSortedWritablePartitionedIterator(comparator)
while (it.hasNext) {
// 獲取writer
val writer = blockManager.getDiskWriter(blockId, outputFile, serInstance, fileBufferSize,
context.taskMetrics.shuffleWriteMetrics.get)
val partitionId = it.nextPartition()
while (it.hasNext && it.nextPartition() == partitionId) {
it.writeNext(writer)
}
writer.commitAndClose() //提交時需要紀錄初始位置和結束位置,結束位置以文件的length來確定
val segment = writer.fileSegment() //根據初始位置和結束位置創建一個FileSegment
lengths(partitionId) = segment.length //記錄Segment的長度和partitionId之間的關系
}
} else { //如果數據已溢出至磁盤,則必須用歸并排序將文件合并
// We must perform merge-sort; get an iterator by partition and write everything directly.
for ((id, elements) <- this.partitionedIterator) { //partitionedIterator中將spills與in-memory合并
if (elements.hasNext) { //獲取writer并按照partitionId寫入FileSegment
val writer = blockManager.getDiskWriter(blockId, outputFile, serInstance, fileBufferSize,
context.taskMetrics.shuffleWriteMetrics.get)
for (elem <- elements) {
writer.write(elem._1, elem._2)
}
writer.commitAndClose()
val segment = writer.fileSegment()
lengths(id) = segment.length
}
}
}
context.taskMetrics().incMemoryBytesSpilled(memoryBytesSpilled)
context.taskMetrics().incDiskBytesSpilled(diskBytesSpilled)
context.internalMetricsToAccumulators(
InternalAccumulator.PEAK_EXECUTION_MEMORY).add(peakMemoryUsedBytes)
lengths
}
這個方法也是一分為二,如果spills是空的,說明數據全在內存的數據結構中,沒有溢寫到磁盤,否則說明內存和磁盤中都有records。
如果數據都在內存中,那么又一分為二,其中定義過aggregator的數據放在PartitionedAppendOnlyMap中,沒有的話就放在PartitionedPairBuffer中:
val collection = if (aggregator.isDefined) map else buffer
// map和buffer實例創建
private var map = new PartitionedAppendOnlyMap[K, C]
private var buffer = new PartitionedPairBuffer[K, C]
這兩個數據結構的實現比較復雜,它們主要的功能是用來存放records,如果達到某個閾值則spill到磁盤,落成文件,兩者的區別為:map用來存放有聚合需求的數據,buffer用來存放沒有聚合需求的數據,具體可關注這兩個類的源碼。
最后在內存中的數據都通過叫作FileSegment的實例封裝,其中包括每個partition的起始和終止position,并且返回一個數組,用來記錄partitionId與每個Segment的長度的關系,即每一個partition的數據寫入一個FileSegment,所有的FileSegment有序落入一個文件。
如果數據已經溢出至外部存儲,那么這部分數據需要采用歸并排序的方式合并成一個文件,實現細節在ExternalSorter的partitionedIterator方法中:
def partitionedIterator: Iterator[(Int, Iterator[Product2[K, C]])] = {
val usingMap = aggregator.isDefined
val collection: WritablePartitionedPairCollection[K, C] = if (usingMap) map else buffer
if (spills.isEmpty) {
// Special case: if we have only in-memory data, we don't need to merge streams, and perhaps
// we don't even need to sort by anything other than partition ID
if (!ordering.isDefined) {
// The user hasn't requested sorted keys, so only sort by partition ID, not key
groupByPartition(collection.partitionedDestructiveSortedIterator(None))
} else {
// We do need to sort by both partition ID and key
groupByPartition(collection.partitionedDestructiveSortedIterator(Some(keyComparator)))
}
} else {
// Merge spilled and in-memory data
merge(spills, collection.partitionedDestructiveSortedIterator(comparator))
}
}
可以看出,這里也是按照是否有mapSideCombine分為使用map還是buffer,并且如果沒有傳入key的比較器,則直接按照partitionId來排序,否則還需要加上KeyComparator,最后將spills的數據與in-memory的數據使用歸并排序合并到一個文件中。
writePartitionedFile方法最后返回一個數組,其中數組下標為partitionID,而內容就是對應的FileSegment的長度,拿到這個數組是用來建立Index文件,至此,sort shuffle write結束。
總結
Hash-Based-Shuffle設計之初是為了避免多余的排序操作,但是出現了中間落地文件過多的問題,即使采用ConsolidateFile機制,也不能有效解決問題,而在生產環境中,當數據量很大時,并行度也會很高,相應的shuffle上下游map和reduce的partition數量就會很多,導致中間落地文件數量過多,從而出現內存溢出和磁盤IO性能瓶頸,在spark 2.0版本以后消失不見。
Sort-Based-Shuffle為了解決這個問題,采用了FileSegment的概念,通過partitionId對數據分桶,寫入一個文件,并且建立Index文件提供offset在Reducer拉取數據時使用,并且在合并文件的時候僅根據partitionId來排序,避免了多余的排序,在spark 1.2版本以后已經成為默認選項。
這篇文章分析了這兩種模式的shuffle-writer過程,下一篇文章將繼續分析shuffle-read過程。