本文基于spark源碼2.11
1. 前言
shuffle是spark job中一個重要的階段,發生在map和reduce之間,涉及到map到reduce之間的數據的移動,以下面一段wordCount為例:
def main(args:Array[String]){
val sparkConf = new SparkConf().setAppName("Log Query")
val sc = new SparkContext(sparkConf)
val lines = sc.textFile("README.md",3)
val words = lines.flatMap(line => line.split(" "))
val wordOne = words.map(word => (word,1))
val wordCount = wordOne.reduceByKey(_ + _,3)
wordCount.foreach(println)
}
其RDD的轉換如下:
上圖中map和flatMap這種轉換只會產生rdd之間的窄依賴,因此對一個分區上進行map和flatMap可以如同流水線一樣只在同一臺的機器上盡心,不存在多個節點之間的數據移動,而reduceByKey這樣的操作,涉及到需要將相同的key做聚合操作。上圖中Stage1中按key做hash 到三個分區做reduce操作,對于Stage1中任意一個partition而言,其輸入可能存在與上游Stage0中每一個分區中,因此需要從上游的每一個partition所在的機器上拉取數據,這個過程稱為shuffle。
解釋一下: spark的stage劃分就是以shuffle依賴為界限劃分的,上圖中只存在一次shuffle操作,所以被劃分為兩個stage
從上圖中可以看出shuffle首先涉及到stage0最后一個階段需要寫出map結果, 以及stage1從上游stage0中每一個partition寫出的數據中讀取屬于當前partition的數據。
2. Shuffle Write
spark中rdd由多個partition組成,任務運行作用于partition。spark有兩種類型的task:
- ShuffleMapTask, 負責rdd之間的transform,map輸出也就是shuffle write
- ResultTask, job最后階段運行的任務,也就是action(上面代碼中foreach就是一個action,一個action會觸發生成一個job并提交)操作觸發生成的task,用來收集job運行的結果并返回結果到driver端。
“關于job的創建,stage的劃分以及task的提交在另一篇文章中介紹(待填坑)”
shuffle write的操作發生在ShuffleMapTask#runTask中,其代碼如下:
override def runTask(context: TaskContext): MapStatus = {
// Deserialize the RDD using the broadcast variable.
val threadMXBean = ManagementFactory.getThreadMXBean
val deserializeStartTime = System.currentTimeMillis()
val deserializeStartCpuTime = if (threadMXBean.isCurrentThreadCpuTimeSupported) {
threadMXBean.getCurrentThreadCpuTime
} else 0L
val ser = SparkEnv.get.closureSerializer.newInstance()
val (rdd, dep) = ser.deserialize[(RDD[_], ShuffleDependency[_, _, _])](
ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader)
_executorDeserializeTime = System.currentTimeMillis() - deserializeStartTime
_executorDeserializeCpuTime = if (threadMXBean.isCurrentThreadCpuTimeSupported) {
threadMXBean.getCurrentThreadCpuTime - deserializeStartCpuTime
} else 0L
var writer: ShuffleWriter[Any, Any] = null
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
}
}
調用val (rdd, dep) = ser.deserialize(...)
獲取任務運行的rdd和shuffle dep,這是在由DAGScheduler序列化然后提交到當前任務運行的executor上的。
調用writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context)
獲得shuffle writer,調用writer.write(rdd.iterator)
寫出map output。idd.iterator在迭代過程中,會往上游一直追溯當前rdd依賴的rdd,然后從上至下調用rdd.compute()完成數據計算并返回iterator迭代轉換計算的結果。 此處manager在SparkEnv中實例化微SortShuffleManager,下面是SortShuffleManager#getWriter方法:
override def getWriter[K, V](
handle: ShuffleHandle,
mapId: Int,
context: TaskContext): ShuffleWriter[K, V] = {
numMapsForShuffle.putIfAbsent(
handle.shuffleId, handle.asInstanceOf[BaseShuffleHandle[_, _, _]].numMaps)
val env = SparkEnv.get
handle match {
case unsafeShuffleHandle: SerializedShuffleHandle[K @unchecked, V @unchecked] =>
new UnsafeShuffleWriter(
env.blockManager,
shuffleBlockResolver.asInstanceOf[IndexShuffleBlockResolver],
context.taskMemoryManager(),
unsafeShuffleHandle,
mapId,
context,
env.conf)
case bypassMergeSortHandle: BypassMergeSortShuffleHandle[K @unchecked, V @unchecked] =>
new BypassMergeSortShuffleWriter(
env.blockManager,
shuffleBlockResolver.asInstanceOf[IndexShuffleBlockResolver],
bypassMergeSortHandle,
mapId,
context,
env.conf)
case other: BaseShuffleHandle[K @unchecked, V @unchecked, _] =>
new SortShuffleWriter(shuffleBlockResolver, other, mapId, context)
}
}
”上面提到shuffleManager被實例化為SortShuffleManager,老版本里還有HashShuffleManager,似乎不用了,這里有一篇兩種方式的性能比較文章SortShuffleManager和HashShuffleManager性能比較“
有三種類型的ShuffleWriter,取決于handle的類型。
- UnsafeShuflleWriter, 不清楚
- BypassMergeSortShuffleWriter, 這個writer會根據reduce的個數n(reduceByKey中指定的參數,有partitioner決定)創建n個臨時文件,然后計算iterator每一個key的hash,放到對應的臨時文件中,最后合并這些臨時文件成一個文件,同時還是創建一個索引文件來記錄每一個臨時文件在合并后的文件中偏移。當reducer取數據時根據reducer partitionid就能以及索引文件就能找到對應的數據塊。
- SortShuffleWriter, 會在map做key的aggregate操作,(key,value)會先在保存在內存里,并按照用戶自定義的aggregator做key的聚合操作,并在達到一定的內存大小后,對內存中已有的記錄按(partition,key)做排序,然后保存到磁盤上的臨時文件。最終對生成的文件再做一次merge操作。
2.1 BypassMergeSortShuffleWriter
1. 什么情況下使用
不需要在map端做combine操作,且partitioner產生的分區數量(也就是reducer的個數)小于配置文件中spark.shuffle.sort.bypassMergeThreshold
定義的大小(默認值是200)
2. 如何寫出map output
下圖是BypassMergeSortShuffleWriter寫出數據的方式:
輸入數據是(nation,city)的鍵值對,調用reduceByKey(_ + "," + _,3)
。運行在在partition-0上的ShuffleMapTask使用BypassMergeSortShuffleWriter#write的過程如下:
- 根據reducer的個數(partitioner決定)n 創建n個
DiskBlockObjectWriter
,每一個創建一個臨時文件,臨時文件命名規則為temp_shuffle_uuid
,也就是每一個臨時文件放的就是下游一個reduce的輸入數據。 - 迭代訪問輸入的數據記錄,調用
partitioner.getPartition(key)
計算出記錄的應該落在哪一個reducer擁有的partition,然后索引到對應的DiskBlockObjectWriter
對象,寫出key, value - 創建一個名為
shuffle_shuffleid_mapid_0.uuid
這樣的臨時且絕對不會重復的文件,然后將1中生成的所有臨時文件寫入到這個文件中,寫出的順序是partitionid從小到大開始的(這里之所以使用uuid創建文件,主要是不使用uuid的話可能有另外一個任務也寫出過相同的文件,文件名中的0本來應該是reduceid,但是由于合并到只剩一個文件,就用0就行了)。 - 寫出索引文件,索引文件名為
shuffle_shuffleid_mapid_0.index.uuid
(使用uuid和3中的原因是一樣的)。由于map的輸出數據被合并到一個文件中,reducer在讀取數據時需要根據索引文件快速定位到應該讀取的數據在文件中的偏移和大小。 - 索引文件只順序寫出partition_0 ~ partition_n的偏移的值
- 還需要將3中
shuffle_shuffleid_mapid_0.uuid
重命名為``shuffle_shuffleid_mapid_0`, 過程是驗證一下是不是已經存在這么一個文件以及文件的長度是否等于 1 中所有臨時文件相加的大小,不是的話就重命名索引文件和數據文件(去掉uuid)。否則的話表示先前已經有一個任務成功寫出了數據,直接刪掉臨時索引和數據文件,返回。
以上就是BypassMergeSortShuffleWriter寫數據的方式。有如下特點:
- map端沒有按照key做排序,也沒有按照key做聚合操作, [(China, Beijing),(China,Hefei),(China,Shanghai)]如果在map端聚合的話會變成(China,“Beijing,Hefei,Shanghai”)。
- 如果有M格mapper,N格reducer,那么會產生M*N個臨時文件,但是最終會合并生成M個數據文件,M個索引文件。
2.2 SortShuffleWriter
下面是SortShuffleWrite#write方法
override def write(records: Iterator[Product2[K, V]]): Unit = {
sorter = if (dep.mapSideCombine) {
require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!")
new ExternalSorter[K, V, C](
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 = shuffleBlockResolver.getDataFile(dep.shuffleId, mapId)
val tmp = Utils.tempFileWith(output)
try {
val blockId = ShuffleBlockId(dep.shuffleId, mapId, IndexShuffleBlockResolver.NOOP_REDUCE_ID)
val partitionLengths = sorter.writePartitionedFile(blockId, tmp)
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}")
}
}
}
- 先創建了一個ExternalSorter,sort.insertAll(records)會將數據寫到多個磁盤文件中。
- 接下來和BypassMergeSortShuffleWriter類似,創建一個名為
shuffle_shuffleid_mapid_0.uuid
的這種唯一的臨時數據文件,將 1 中的多個磁盤文件合并寫出到這個臨時數據文件中,并寫出索引文件,最終的數據文件中相同分區的數據一定是連續分布的,這樣就能根據索引文件中的偏移值快速定位到對應分區的數據。
由于寫數據的核心在ExternalSorter#insertAll中,下文會主要介紹ExternalSorter。
1. 什么情況下使用
ShuffleredRDD#mapSideCombine為true,且定義了aggregate的情況下會使用SortShuffleWriter。
2. 原理
根據mapSizeCombine是否為true,SortShuffleWriter在寫出map output時也會做不同處理,為true時會按用戶自定聚合方法按key聚合,并按照(partitionId,key)排序(沒指定key的排序方法時就只根據partitionid排序),然后寫出到磁盤文件;為false時不會不會做聚合操作,只會進行排序然后寫出到磁盤。下文先介紹沒有聚合,然后介紹有聚合。兩者之間有很多的共同之處,都會先將數據緩存在內存當中,在達到一定大小之后刷到磁盤,但是最大的區別也在此,他們使用了不同的集合緩存數據。
2.2.1 ExternalSorter
下面是ExternalSorter的一些重要的成員:
1. private val blockManager = SparkEnv.get.blockManager
寫出臨時文件到磁盤需要blockManager
2. private var map = new PartitionedAppendOnlyMap[K, C]
private var buffer = new PartitionedPairBuffer[K, C]
下文介紹在map端執行聚合操作和不在map聚合是數據會以不同的方式緩存在內存中,map就是在map端聚合是數據緩存的方式
3. private val keyComparator: Comparator[K]
key的比較方式,在map端聚合時,數據排序方式是先按partitionId然后按key排序。不在map聚合時這個字段是空,只按partitionId排序
4. private val spills = new ArrayBuffer[SpilledFile]
緩存在內存中的數據(map或者buffer)在達到一定大小后就會寫出到磁盤中,spills保存了所有寫出過的磁盤文件,后續會根據spills做merge成一個文件。
2.2.2 不在map端聚合
下面是ExternalSorter#insertAll的源碼:
def insertAll(records: Iterator[Product2[K, V]]): Unit = {
// TODO: stop combining if we find that the reduction factor isn't high
val shouldCombine = aggregator.isDefined
if (shouldCombine) {
...
...
// 此處省略了map做combine的代碼
} else {
// Stick values into our buffer
while (records.hasNext) {
addElementsRead()
val kv = records.next()
buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])
maybeSpillCollection(usingMap = false)
}
}
}
while循環獲取(key,value)記錄,然后調用buffer.insert(...)
插入記錄,此處buffer是PartitionedPairBuffer
的實例(PartitionedPairBuffer介紹見附錄4.1)。insert會將(key,value)轉換成((partition_id,key), value)的形式插入,例如("China","Beijing") ->((1, "China"), "Beijing").
maybeSpillCollection則會根據具體情況決定是否將buffer中的記錄寫出到磁盤。經過如下調用鏈路進入到寫磁盤操作:
maybeSpillCollection (調用buffer.estimateSize 估算當前buffer大小)
--> mybeSpill (會嘗試擴容)
--> spill (寫到磁盤中)
下面是spill方法
override protected[this] def spill(collection: WritablePartitionedPairCollection[K, C]): Unit = {
val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)
val spillFile = spillMemoryIteratorToDisk(inMemoryIterator)
spills += spillFile
}
collection.destructiveSortedWritablePartitionedIterator(comparator)
做了很多事情,參數comparator在這種情況下是null。
下面是它的調用序列:
destructiveSortedWritablePartitionedIterator
-> partitionedDestructiveSortedIterator
-> PartitionedPairBuffer#partitionedDestructiveSortedIterator
進入到PartitionedPairBuffer#partitionedDestructiveSortedIterator
代碼如下:
override def partitionedDestructiveSortedIterator(keyComparator: Option[Comparator[K]])
: Iterator[((Int, K), V)] = {
val comparator = keyComparator.map(partitionKeyComparator).getOrElse(partitionComparator)
new Sorter(new KVArraySortDataFormat[(Int, K), AnyRef]).sort(data, 0, curSize, comparator)
iterator
}
此處參數keyComparator從前面一直傳下來的,此處是空值,因此comparator使用partitionComparator,也就是只按照buffer數據所屬的partitionId排序。
Sort#sort方法對buffer排序(排序直接在buffer底層數據上移動,也就是說會破壞buffer原有的數據順序)之后返回iterator,此時這個iterator迭代出來的數據就是按照partitionId排序的數據,同時也就意味者相同的partitionId的數據一定會連續的分布。
回到上面spill方法,spillMemoryIteratorToDisk接收上面提到的iterator作為參數開始輸出磁盤, 這個方法大體如下:
- 使用batchSizes保存每批量flush的大小,
- elementsPerPartition保存每個partition,鍵值對個數
- 創建臨時文件,buffer中記錄批量寫出,只寫出key,value,partitionId不寫
- 返回SpilledFile,里面有blockId,file,elementsPerPartitionbatchSizes這些信息,后續會將SpilledFile合并成一個文件。
和Bypass方式的區別
兩者在寫map out數據時都會產生多個臨時文件,bypass方式產生的每一個臨時文件中的數據指揮是下游一個reducer的輸入數據,后續合并成同一個文件時很簡單只要逐個將臨時文件copy就行,但是sort方式中臨時文件中的數據可能輸入多個reducer,也就意味著在合并到同一個文件時,需要考慮將多個臨時文件相同的分區合并好在輸出到最終文件中。關于sort的文件合并會在下一節“map端做聚合”之后。
2.2.3 在map端做聚合
定義聚合方法
reduce轉換會是的兩個RDD之間存在ShuffleDependency,ShuffleDependency,ShuffleDependency的屬性aggregator: Aggregator
定義了按key聚合的方式,Aggregator類如下:
case class Aggregator[K, V, C] (
createCombiner: V => C,
mergeValue: (C, V) => C,
mergeCombiners: (C, C) => C) {
...}
- K,V分別時key、value的類型,C是V聚合后的類型。
- createCombiner, 第一個value轉換成聚合后類型。
- mergeValue, 并入的value。
- 合并兩個已經聚合的數據。
例如我們將相同key的value(String類型)合并到一個List中,則定義:
createCombiner: (s String) => List(s) 將string轉成List
mergeValue: (c:List[String],v: String) => v::c 將string加到列表
mergeCombiners: (c1:List[String],c2: List[String]) => c1:::c2 合并兩個列表
write過程
下圖是一個map端做聚合的shuffle write過程:
reduceByKey(_ + "," + _)
操作把key相同的所有value用“,”連接起來。
依然是調用ExternalSorter#insertAll完成排序,aggregate以及寫出到磁盤的過程。此時使用map作為內存緩存的數據結構。寫的流程如下:
- 從輸入iterator中一次讀入(key,value),使用partitioner計算key的partitionid,調用map.insert插入數據,格式為((partitionid,key),value),插入時就會對key相同的做aggregate,形成的內存數據布局如上圖map(上圖map數據已經排序了,但是插入時不會排序,而是在寫出磁盤時排序)。
- 當map的數據達到一定大小時,使用blockManager創建臨時文件temp_shuffle_uuid,然后對map數據排序,輸出到臨時文件。排序時現按照partitionid排序,然后按照key排序,保證臨時文件中相同partitionid的數據一定是連續分布的。
- 完成ExternalSorter#insertAll調用,生成若干臨時文件,合并這些文件。
源碼解析
源碼基本和不做聚合時一樣,區別主要是在用作內存緩存的集合buffer和map的區別。附錄介紹了buffer和map的原理。
3. ShuffleRead
前面RDD轉換圖中,RDD#reduceByKey產生了MapPartitionRDD到ShufferedRDD的轉換,shuffle read操作發生在轉換ShufferedRDD的compute方法中,下面是ShufferedRDD#compute方法:
override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = {
val dep = dependencies.head.asInstanceOf[ShuffleDependency[K, V, C]]
SparkEnv.get.shuffleManager.getReader(dep.shuffleHandle, split.index, split.index + 1, context)
.read()
.asInstanceOf[Iterator[(K, C)]]
}
通過shuffleManager.getReader獲得ShuffleReader,返回的是BlockStoreShuffleReader的實例,參數[split.index,split.index+1)表示需要從上游stage0 所有task產生的數據文件中讀取split.index這一個分區的記錄。
下面是BlockStoreShuffleReader#read方法
/** Read the combined key-values for this reduce task */
override def read(): Iterator[Product2[K, C]] = {
val wrappedStreams = new ShuffleBlockFetcherIterator(
context,
blockManager.shuffleClient,
blockManager,
mapOutputTracker.getMapSizesByExecutorId(handle.shuffleId, startPartition, endPartition),
serializerManager.wrapStream,
// Note: we use getSizeAsMb when no suffix is provided for backwards compatibility
SparkEnv.get.conf.getSizeAsMb("spark.reducer.maxSizeInFlight", "48m") * 1024 * 1024,
SparkEnv.get.conf.getInt("spark.reducer.maxReqsInFlight", Int.MaxValue),
SparkEnv.get.conf.getBoolean("spark.shuffle.detectCorrupt", true))
val serializerInstance = dep.serializer.newInstance()
// Create a key/value iterator for each stream
val recordIter = wrappedStreams.flatMap { case (blockId, wrappedStream) =>
// Note: the asKeyValueIterator below wraps a key/value iterator inside of a
// NextIterator. The NextIterator makes sure that close() is called on the
// underlying InputStream when all records have been read.
serializerInstance.deserializeStream(wrappedStream).asKeyValueIterator
}
// Update the context task metrics for each record read.
val readMetrics = context.taskMetrics.createTempShuffleReadMetrics()
val metricIter = CompletionIterator[(Any, Any), Iterator[(Any, Any)]](
recordIter.map { record =>
readMetrics.incRecordsRead(1)
record
},
context.taskMetrics().mergeShuffleReadMetrics())
// An interruptible iterator must be used here in order to support task cancellation
val interruptibleIter = new InterruptibleIterator[(Any, Any)](context, metricIter)
val aggregatedIter: Iterator[Product2[K, C]] = if (dep.aggregator.isDefined) {
if (dep.mapSideCombine) {
// We are reading values that are already combined
val combinedKeyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, C)]]
dep.aggregator.get.combineCombinersByKey(combinedKeyValuesIterator, context)
} else {
// We don't know the value type, but also don't care -- the dependency *should*
// have made sure its compatible w/ this aggregator, which will convert the value
// type to the combined type C
val keyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, Nothing)]]
dep.aggregator.get.combineValuesByKey(keyValuesIterator, context)
}
} else {
require(!dep.mapSideCombine, "Map-side combine without Aggregator specified!")
interruptibleIter.asInstanceOf[Iterator[Product2[K, C]]]
}
// Sort the output if there is a sort ordering defined.
dep.keyOrdering match {
case Some(keyOrd: Ordering[K]) =>
// Create an ExternalSorter to sort the data. Note that if spark.shuffle.spill is disabled,
// the ExternalSorter won't spill to disk.
val sorter =
new ExternalSorter[K, C, C](context, ordering = Some(keyOrd), serializer = dep.serializer)
sorter.insertAll(aggregatedIter)
context.taskMetrics().incMemoryBytesSpilled(sorter.memoryBytesSpilled)
context.taskMetrics().incDiskBytesSpilled(sorter.diskBytesSpilled)
context.taskMetrics().incPeakExecutionMemory(sorter.peakMemoryUsedBytes)
CompletionIterator[Product2[K, C], Iterator[Product2[K, C]]](sorter.iterator, sorter.stop())
case None =>
aggregatedIter
}
}
}
這是一個很復雜的方法,從上游的map output讀區屬于當前分區的block,層層封裝迭代器,從上面代碼可以看到有如下迭代器:
ShuffleBlockFetcherIterator
其next方法返回類型為(BlockId, InputStream)
。當前reduce分區需要從上游map 輸出數據中fetch多個block。這個迭代器負責從上游fetch到blockid中的數據(由于write階段數據是合并到一個blockid文件中,所以數據是其中一段),然后將從數據創建InputStream,并把blockid以及創建的stream返回。顯然如果上游有三個partition,每個partition的輸出數據文件中有一段是當前的輸入,那這個迭代器三次就結束了。val recordIter = wrappedStreams.flatMap { ...}
1 中迭代器產生(BlockId,InputStream),但是作為read 而言spark最終需要的讀出一個個(key,value),在 1 的iterator上做一次flatMap將(BlockId,InputStream)轉換成(key,value)。
先是調用serializerInstance.deserializeStream(wrappedStream)
使用自定義的序列化方式包裝一下1中的輸入流,這樣就能正常讀出反序列化后的對象;然后調用asKeyValueIterator
轉換成NextIterator,其next方法就反序列化后的流中讀出(key,value)。val metricIter = CompletionIterator...
這個迭代器包裝2中迭代器,next方法也只是包裝了2中的迭代器,但是多了一個度量的功能,統計讀入多少(key,value)。InterruptibleIterator, 這個迭代器使得任務取消是優雅的停止讀入數據。
val aggregatedIter: Iterator[Product2[K, C]] = if ...
從前面shuffle write的過程可以知道,即便每一個分區任務寫出時做了value的聚合,在reducer端的任務里,由于有多個分區的數據,因此依然還要需要對每個分區里的相同的key做value的聚合。
這個iterator就是完成這個功能。
首先,會從4 中迭代器中一個個讀入數據,緩存在內存中(map緩存,因為要做聚合),并且在必要時spill到磁盤(spill之前會按key排序)。這個過程和shuffle write中在map端聚合時操作差不多。
然后, 假設上一部產生了多個spill文件,那么每一個spill文件必然時按key排序的,再對這個spill文件做歸并,歸并時key相同的進行聚合。
最后, 迭代器的next返回key以及聚合后的value。dep.keyOrdering match {...
5中相同key的所有value都按照用戶自定義的聚合方法聚合在一起了,但是iterator輸出是按key的hash值排序輸出的,用戶可能自定義了自己的排序方法。這里又使用了ExternalSorter,按照自定義排序方式排序(根據前面External介紹,可能又會有spill磁盤的操作。。。),返回的iterator按照用戶自定義排序返回聚合后的key。
至此shuffle read算是完成。
3.1 Shuffle Read源碼解析
層層包裝的iterator中,比較復雜的在兩個地方:
- 上面1中 ShuffleBlockFetcherIterator,從上游依賴的rdd讀區分區數據。
- 上面5中aggregatedIter,對讀取到的各個分區數據做reducer端的aggregate
這里只介紹上面2處。
3.1.1 ShuffleBlockFetchIterator
下面是BlockStoreShuffleReader#read創建該iterator時的代碼:
val wrappedStreams = new ShuffleBlockFetcherIterator(
context,
blockManager.shuffleClient,
blockManager,
mapOutputTracker.getMapSizesByExecutorId(handle.shuffleId, startPartition, endPartition),
serializerManager.wrapStream,
// Note: we use getSizeAsMb when no suffix is provided for backwards compatibility
SparkEnv.get.conf.getSizeAsMb("spark.reducer.maxSizeInFlight", "48m") * 1024 * 1024,
SparkEnv.get.conf.getInt("spark.reducer.maxReqsInFlight", Int.MaxValue),
SparkEnv.get.conf.getBoolean("spark.shuffle.detectCorrupt", true))
- blockManager.shuffleClient, 上NettyBlockTranseferService的實例,這在《Spark初始化》文章中介紹過,用來傳輸datablock。NettyBlockTransferService可以參考《Spark 數據傳輸》
- mapOutputTracker.getXXX返回executorId到BlockId的映射,表示當前partition需要讀取的上游的的block的blockid,以及blockid所屬的executor。
- serializerManager.wrapStream, 反序列化流,上有數據被包裝成輸入流之后,再使用反序列化流包裝之后讀出對象。
創建ShuffleBlockFetchIterator時會調用它的initialize方法,該方法如下:
private[this] def initialize(): Unit = {
// Add a task completion callback (called in both success case and failure case) to cleanup.
context.addTaskCompletionListener(_ => cleanup())
// Split local and remote blocks.
val remoteRequests = splitLocalRemoteBlocks()
// Add the remote requests into our queue in a random order
fetchRequests ++= Utils.randomize(remoteRequests)
assert ((0 == reqsInFlight) == (0 == bytesInFlight),
"expected reqsInFlight = 0 but found reqsInFlight = " + reqsInFlight +
", expected bytesInFlight = 0 but found bytesInFlight = " + bytesInFlight)
// Send out initial requests for blocks, up to our maxBytesInFlight
fetchUpToMaxBytes()
val numFetches = remoteRequests.size - fetchRequests.size
logInfo("Started " + numFetches + " remote fetches in" + Utils.getUsedTimeMs(startTime))
// Get Local Blocks
fetchLocalBlocks()
logDebug("Got local blocks in " + Utils.getUsedTimeMs(startTime))
}
- splitLocalRemoteBlocks, 根據executorId區分出在本地的的block和遠程的block,然后構建出FetchRequest(每一個request可能包含多個block,但是block都是屬于一個executor)。
- fetchUpToMaxBytes和fetchLocalBlocks,從本地或者遠程datablock,數據放在buffer中,包裝好buffer放到其成員results(一個阻塞隊列)中。
作為iterator,它的next方法每次從results中取出一個,從數據buffer中創建出InputStream,使用wrapStream包裝InputStream返回。
3.1.2 aggregatedIter
用來將上游各個partition中的數據在reducer再聚合的,
調用dep.aggregator.get.combineCombinersByKey(combinedKeyValuesIterator, context)
創建aggregatedIter,下面是combineCombinersByKey方法:
def combineCombinersByKey(
iter: Iterator[_ <: Product2[K, C]],
context: TaskContext): Iterator[(K, C)] = {
val combiners = new ExternalAppendOnlyMap[K, C, C](identity, mergeCombiners, mergeCombiners)
combiners.insertAll(iter)
updateMetrics(context, combiners)
combiners.iterator
}
調用ExternalAppendOnlyMap#insertAll將輸入數據,這個類和PartitionedAppendOnlyMap原理十分類似,實際上它內部使用
@volatile private var currentMap = new SizeTrackingAppendOnlyMap[K, C]
這個成員來緩存數據,插入數據同時會合并key相同的value,在內存不夠時,會保存到磁盤上,返回的iterator則會迭代磁盤中的文件合并的結果,可以參考附錄4.2節。
關于ExternalAppendOnlyMap#iterator的介紹見附錄4.3 ExternalAppendOnlyMap
4. 附錄
4.1 PartitionedPairBuffer
數據存放格式
2.2.2節中說到當不在Map 端做聚合時,ExternalSorter使用buffer作為內存緩存數據時的數據結構,調用buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])
插入數據記錄。插入數據時將(key,value)轉換成((partition-id,key), value)的形式插入。
下面PartitionedPairBuffer的核心屬性:
private var capacity = initialCapacity
private var curSize = 0
private var data = new Array[AnyRef](2 * initialCapacity)
- data是一個數據,就是PartitionedPairBuffer底層用來存儲數據,其初始長度是0。
下面是PartitionedPairBuffer的insert方法
def insert(partition: Int, key: K, value: V): Unit = {
if (curSize == capacity) {
growArray()
}
data(2 * curSize) = (partition, key.asInstanceOf[AnyRef])
data(2 * curSize + 1) = value.asInstanceOf[AnyRef]
curSize += 1
afterUpdate()
}
依次插入key,和value。因此PartitionedPairBuffer中數據排列的方式
_______________________________________________________
| key1 | value1 | key2 | value2 | ... | keyN | valueN |
_______________________________________________________
數據是連續分布的。
數據排序
ExternalSorter使用buffer的size達到一定大小后會將buffer中數據spill到磁盤,在此之前需要對亂序的data數據排序。
PartitionedPairBuffer#partitionedDestructiveSortedIterator(keyComparator: Option[Comparator[K]])
方法對data數據中的數據進行排序,按照key排序,參數keyComparator定義key的比較方式。
在ExternalSorter中,data數組中key是(partition-id,key)。keyComparator取partition-id比較大小排序。這樣就保證相同的partition-id連續分布在寫到磁盤中的文件中。
排序所用的算法為timsort(優化后的歸并排序),參考timsort wiki
4.2 PartitionedAppendOnlyMap
2.2.3 節中介紹當shuffle write對寫出的數據做map端聚合時,用來做內存緩存數據的數據結構式map。
數據存放格式
PartitionedAppendOnlyMap類有如下繼承關系:
AppendOnlyMap
^
|
SizeTrackingAppendOnlyMap WritablePartitionedPairCollection
^ ^
| |
_______________________________
^
|
PartitionedAppendOnlyMap
2.2.3節中ExternalSorter向map中插入數據的代碼如下:
insertAll(...){
...
if (shouldCombine) {
// Combine values in-memory first using our AppendOnlyMap
val mergeValue = aggregator.get.mergeValue
val createCombiner = aggregator.get.createCombiner
var kv: Product2[K, V] = null
val update = (hadValue: Boolean, oldValue: C) => {
if (hadValue) mergeValue(oldValue, kv._2) else createCombiner(kv._2)
}
while (records.hasNext) {
addElementsRead()
kv = records.next()
map.changeValue((getPartition(kv._1), kv._1), update)
maybeSpillCollection(usingMap = true)
}
}
...
}
mergeValue,createCombiner即定義在Aggregator中合并value的函數。
調用的map.changeValue插入數據,這個方法還傳入的參數update函數,
map調用changeValue插入數據時,會首先調用update,update做如下判斷:
1. 若key之前已經在map中(hadValue=true),調用mergeValue合并key相同的value
2. key不存在(hadValue=false),轉換value。
所以綜上所述,在map端按key聚合就是在插入數據的過程的完成的。
調用PartitionedAppednOnlyMap#insert(),會有下面調用鏈:
PartitionedAppendOnlyMap#changeValue(key,value)
-> SizeTrackingAppendOnlyMap#changeValue( (partition-id,key), value) 和buffer插入一樣,將key轉換(partition-id,key)
->AppendOnlyMap#changeValue( (partition-id,key),value )
底層數據結構在AppendOnlyMap中,AppendOnlyMap有如下屬性:
private var data = new Array[AnyRef](2 * capacity)
底層存儲數據依然使用data數組。
下面是AppendOnlyMap#changeValue方法:
def changeValue(key: K, updateFunc: (Boolean, V) => V): V = {
assert(!destroyed, destructionMessage)
val k = key.asInstanceOf[AnyRef]
if (k.eq(null)) {
if (!haveNullValue) {
incrementSize()
}
nullValue = updateFunc(haveNullValue, nullValue)
haveNullValue = true
return nullValue
}
var pos = rehash(k.hashCode) & mask
var i = 1
while (true) {
val curKey = data(2 * pos)
if (curKey.eq(null)) {
// curKey是null,表示沒有插入過相同的key,不需要合并
// updateFunc就是上面提到的update,合并value的
val newValue = updateFunc(false, null.asInstanceOf[V])
data(2 * pos) = k
data(2 * pos + 1) = newValue.asInstanceOf[AnyRef]
incrementSize()
return newValue
} else if (k.eq(curKey) || k.equals(curKey)) {
// curKey不是null,表示有插入過相同的key,需要合并
// updateFunc就是上面提到的update,合并value的
val newValue = updateFunc(true, data(2 * pos + 1).asInstanceOf[V])
data(2 * pos + 1) = newValue.asInstanceOf[AnyRef]
return newValue
} else {
val delta = i
pos = (pos + delta) & mask
i += 1
}
}
null.asInstanceOf[V] // Never reached but needed to keep compiler happy
}
上面代碼中對于待插入的(key,value),不像buffer中那樣直接放在數據尾部,而是調用pos = rehash(...)
確定插入的位置,因此其底層的數據可能是下面這樣的:
______________________________________________________________________
| key1 | value1 | | | key2 | value2 | ... | keyN | valueN | |
______________________________________________________________________
使用hash的方式確定位置,意味著數據不是連續的,存在空槽。
數據排序
和buffer排序有點區別,buffer由于數據是連續分布,沒有空槽,timsort可以直接在數組上排序。但是map由于空槽的存在,需要先將數據聚攏在一起,然后使用和buffer一樣的排序。
4.3 ExternalAppendOnlyMap
它有如下核心成員:
@volatile private var currentMap = new SizeTrackingAppendOnlyMap[K, C]
private val spilledMaps = new ArrayBuffer[DiskMapIterator]
- currentMap是其內部用來緩存數據
- spilledMaps,currentMap的size達到一定大小之后,會將數據寫到磁盤,這個里面保存了用來迭代返回磁盤文件中(key,value)。
這里主要介紹ExternalAppendOnlyMap#iterator。下面是iterator方法:
override def iterator: Iterator[(K, C)] = {
if (currentMap == null) {
throw new IllegalStateException(
"ExternalAppendOnlyMap.iterator is destructive and should only be called once.")
}
if (spilledMaps.isEmpty) {
CompletionIterator[(K, C), Iterator[(K, C)]](
destructiveIterator(currentMap.iterator), freeCurrentMap())
} else {
new ExternalIterator()
}
}
- spilledMap.isEmpty表示內存夠用,沒有spill到磁盤,這個時候比較好辦不需要再將磁盤文件合并的,直接在底層存儲結構currentMap上迭代就行了。
- 否則,需要合并磁盤文件,創建ExternalIterator用來合并文件。
ExternalIterator
對spill到磁盤文件做外部歸并的。
它有如下成員:
private val mergeHeap = new mutable.PriorityQueue[StreamBuffer]
// Input streams are derived both from the in-memory map and spilled maps on disk
// The in-memory map is sorted in place, while the spilled maps are already in sorted order
private val sortedMap = CompletionIterator[(K, C), Iterator[(K, C)]](destructiveIterator(
currentMap.destructiveSortedIterator(keyComparator)), freeCurrentMap())
private val inputStreams = (Seq(sortedMap) ++ spilledMaps).map(it => it.buffered)
- inputStreams, sortedMap是當前內存currentMap的迭代器,spilledMaps是磁盤文件的迭代器,將這些迭代器轉換成BufferedIterator(可以預讀下一個數據,而移動迭代器)。
- mergeHeap,小根堆。
ExternalIterator實例化話調用如下方法:
inputStreams.foreach { it =>
val kcPairs = new ArrayBuffer[(K, C)]
readNextHashCode(it, kcPairs)
if (kcPairs.length > 0) {
mergeHeap.enqueue(new StreamBuffer(it, kcPairs))
}
- readNextHashCode,連續讀區it迭代器中相同的key的所有記錄,碰到不同key時停止
- mergeHeap.enqueue,將1中的所有(key,value)包裝方入小根堆中。StreamBuffer重寫了comparaTo方法,按照key的hash值排序。key小的就在堆頂端。
- foreach,對每一個待歸并的文件,每次取出其靠前的key相同的連續記錄,放到小根堆
接下來是其next方法:
override def next(): (K, C) = {
if (mergeHeap.isEmpty) {
throw new NoSuchElementException
}
// Select a key from the StreamBuffer that holds the lowest key hash
//從堆中取出key hash最小的(key, value)序列
val minBuffer = mergeHeap.dequeue()
val minPairs = minBuffer.pairs
val minHash = minBuffer.minKeyHash
// 從(key,value)序列中取下第一個(key,value)記錄
val minPair = removeFromBuffer(minPairs, 0)
val minKey = minPair._1
var minCombiner = minPair._2
assert(hashKey(minPair) == minHash)
val mergedBuffers = ArrayBuffer[StreamBuffer](minBuffer)
// 判斷堆中當前key hash最小的和剛剛取出來的第一個記錄hash是
//不是一樣,是一樣則有可能是同一個key,但也可能不是同一個
// key,因為在inputStreams.foreach中是使用hashcode判斷key
// 相等的,和reducer端則是使用==判斷。
while (mergeHeap.nonEmpty && mergeHeap.head.minKeyHash == minHash) {
val newBuffer = mergeHeap.dequeue()
// 可能需要合并,newBuffer中存放的是key的hashCode相等
// 的序列,但是key1==minKey不一定成立,所以可能只會合并
// minBuffer和newBuffer中的一部分數據
minCombiner = mergeIfKeyExists(minKey, minCombiner, newBuffer)
mergedBuffers += newBuffer
}
// 前面說到buffer中數據可能只會有一部分合并,對于沒有合并的
// 還需要重新添加到堆中,等待下一輪合并
mergedBuffers.foreach { buffer =>
if (buffer.isEmpty) {
// minBuffer和newBuffer全部合并了,那可以從迭代器中讀區下.
// 一批key的hashcode一樣的連續記錄了
readNextHashCode(buffer.iterator, buffer.pairs)
}
if (!buffer.isEmpty) {
mergeHeap.enqueue(buffer)
}
}
(minKey, minCombiner)
}