Background
在MapReduce框架中,shuffle是連接Map和Reduce之間的橋梁,Map的輸出要用到Reduce中必須經(jīng)過shuffle這個環(huán)節(jié),shuffle的性能高低直接影響了整個程序的性能和吞吐量。Spark作為MapReduce框架的一種實現(xiàn),自然也實現(xiàn)了shuffle的邏輯,本文就深入研究Spark的shuffle是如何實現(xiàn)的,有什么優(yōu)缺點,與Hadoop MapReduce的shuffle有什么不同。
Shuffle
Shuffle是MapReduce框架中的一個特定的phase,介于Map phase和Reduce phase之間,當Map的輸出結(jié)果要被Reduce使用時,輸出結(jié)果需要按key哈希,并且分發(fā)到每一個Reducer上去,這個過程就是shuffle。由于shuffle涉及到了磁盤的讀寫和網(wǎng)絡(luò)的傳輸,因此shuffle性能的高低直接影響到了整個程序的運行效率。
下面這幅圖清晰地描述了MapReduce算法的整個流程,其中shuffle phase是介于Map phase和Reduce phase之間。
概念上shuffle就是一個溝通數(shù)據(jù)連接的橋梁,那么實際上shuffle這一部分是如何實現(xiàn)的的呢,下面我們就以Spark為例講一下shuffle在Spark中的實現(xiàn)。
Spark Shuffle進化史
先以圖為例簡單描述一下Spark中shuffle的整一個流程:
首先每一個Mapper會根據(jù)Reducer的數(shù)量創(chuàng)建出相應(yīng)的bucket,bucket的數(shù)量是M
×
R
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">M×R
M
×
R
,其中M
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">M
M
是Map的個數(shù),R
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">R
R
是Reduce的個數(shù)。
其次Mapper產(chǎn)生的結(jié)果會根據(jù)設(shè)置的partition算法填充到每個bucket中去。這里的partition算法是可以自定義的,當然默認的算法是根據(jù)key哈希到不同的bucket中去。
當Reducer啟動時,它會根據(jù)自己task的id和所依賴的Mapper的id從遠端或是本地的block manager中取得相應(yīng)的bucket作為Reducer的輸入進行處理。
這里的bucket是一個抽象概念,在實現(xiàn)中每個bucket可以對應(yīng)一個文件,可以對應(yīng)文件的一部分或是其他等。
接下來我們分別從shuffle write和shuffle fetch這兩塊來講述一下Spark的shuffle進化史。
Shuffle Write
在Spark 0.6和0.7的版本中,對于shuffle數(shù)據(jù)的存儲是以文件的方式存儲在block manager中,與rdd.persist(StorageLevel.DISk_ONLY)
采取相同的策略,可以參看:
override def run(attemptId: Long): MapStatus = {
val numOutputSplits = dep.partitioner.numPartitions
...
// Partition the map output.
val buckets = Array.fill(numOutputSplits)(new ArrayBuffer[(Any, Any)])
for (elem <- rdd.iterator(split, taskContext)) {
val pair = elem.asInstanceOf[(Any, Any)]
val bucketId = dep.partitioner.getPartition(pair._1)
buckets(bucketId) += pair
}
...
val blockManager = SparkEnv.get.blockManager
for (i <- 0 until numOutputSplits) {
val blockId = "shuffle_" + dep.shuffleId + "" + partition + "" + i
// Get a Scala iterator from Java map
val iter: Iterator[(Any, Any)] = buckets(i).iterator
val size = blockManager.put(blockId, iter, StorageLevel.DISK_ONLY, false)
totalBytes += size
}
...
}
我已經(jīng)將一些干擾代碼刪去。可以看到Spark在每一個Mapper中為每個Reducer創(chuàng)建一個bucket,并將RDD計算結(jié)果放進bucket中。需要注意的是每個bucket是一個ArrayBuffer
,也就是說Map的輸出結(jié)果是會先存儲在內(nèi)存。
接著Spark會將ArrayBuffer中的Map輸出結(jié)果寫入block manager所管理的磁盤中,這里文件的命名方式為:shuffle_ + shuffle_id + "" + map partition id + "" + shuffle partition id
。
早期的shuffle write有兩個比較大的問題:
Map的輸出必須先全部存儲到內(nèi)存中,然后寫入磁盤。這對內(nèi)存是一個非常大的開銷,當內(nèi)存不足以存儲所有的Map output時就會出現(xiàn)OOM。
每一個Mapper都會產(chǎn)生Reducer number個shuffle文件,如果Mapper個數(shù)是1k,Reducer個數(shù)也是1k,那么就會產(chǎn)生1M個shuffle文件,這對于文件系統(tǒng)是一個非常大的負擔。同時在shuffle數(shù)據(jù)量不大而shuffle文件又非常多的情況下,隨機寫也會嚴重降低IO的性能。
在Spark 0.8版本中,shuffle write采用了與RDD block write不同的方式,同時也為shuffle write單獨創(chuàng)建了ShuffleBlockManager
,部分解決了0.6和0.7版本中遇到的問題。
首先我們來看一下Spark 0.8的具體實現(xiàn):
override def run(attemptId: Long): MapStatus = {
...
val blockManager = SparkEnv.get.blockManager
var shuffle: ShuffleBlocks = null
var buckets: ShuffleWriterGroup = null
try {
// Obtain all the block writers for shuffle blocks.
val ser = SparkEnv.get.serializerManager.get(dep.serializerClass)
shuffle = blockManager.shuffleBlockManager.forShuffle(dep.shuffleId, numOutputSplits, ser)
buckets = shuffle.acquireWriters(partition)
// Write the map output to its associated buckets.
for (elem <- rdd.iterator(split, taskContext)) {
val pair = elem.asInstanceOf[Product2[Any, Any]]
val bucketId = dep.partitioner.getPartition(pair._1)
buckets.writers(bucketId).write(pair)
}
// Commit the writes. Get the size of each bucket block (total block size).
var totalBytes = 0L
val compressedSizes: Array[Byte] = buckets.writers.map { writer: BlockObjectWriter =>
writer.commit()
writer.close()
val size = writer.size()
totalBytes += size
MapOutputTracker.compressSize(size)
}
...
} catch { case e: Exception =>
// If there is an exception from running the task, revert the partial writes
// and throw the exception upstream to Spark.
if (buckets != null) {
buckets.writers.foreach(_.revertPartialWrites())
}
throw e
} finally {
// Release the writers back to the shuffle block manager.
if (shuffle != null && buckets != null) {
shuffle.releaseWriters(buckets)
}
// Execute the callbacks on task completion.
taskContext.executeOnCompleteCallbacks()
}
}
}
在這個版本中為shuffle write添加了一個新的類ShuffleBlockManager
,由ShuffleBlockManager
來分配和管理bucket。同時ShuffleBlockManager
為每一個bucket分配一個DiskObjectWriter
,每個write handler擁有默認100KB的緩存,使用這個write handler將Map output寫入文件中。可以看到現(xiàn)在的寫入方式變?yōu)閎uckets.writers(bucketId).write(pair)
,也就是說Map output的key-value pair是逐個寫入到磁盤而不是預(yù)先把所有數(shù)據(jù)存儲在內(nèi)存中在整體flush到磁盤中去。
ShuffleBlockManager
的代碼如下所示:
private[spark]
class ShuffleBlockManager(blockManager: BlockManager) {
def forShuffle(shuffleId: Int, numBuckets: Int, serializer: Serializer): ShuffleBlocks = {
new ShuffleBlocks {
// Get a group of writers for a map task.
override def acquireWriters(mapId: Int): ShuffleWriterGroup = {
val bufferSize = System.getProperty("spark.shuffle.file.buffer.kb", "100").toInt * 1024
val writers = Array.tabulateBlockObjectWriter { bucketId =>
val blockId = ShuffleBlockManager.blockId(shuffleId, bucketId, mapId)
blockManager.getDiskBlockWriter(blockId, serializer, bufferSize)
}
new ShuffleWriterGroup(mapId, writers)
}
override def releaseWriters(group: ShuffleWriterGroup) = {
// Nothing really to release here.
}
}
}
}
Spark 0.8顯著減少了shuffle的內(nèi)存壓力,現(xiàn)在Map output不需要先全部存儲在內(nèi)存中,再flush到硬盤,而是record-by-record寫入到磁盤中。同時對于shuffle文件的管理也獨立出新的ShuffleBlockManager
進行管理,而不是與rdd cache文件在一起了。
但是這一版Spark 0.8的shuffle write仍然有兩個大的問題沒有解決:
首先依舊是shuffle文件過多的問題,shuffle文件過多一是會造成文件系統(tǒng)的壓力過大,二是會降低IO的吞吐量。
其次雖然Map output數(shù)據(jù)不再需要預(yù)先在內(nèi)存中evaluate顯著減少了內(nèi)存壓力,但是新引入的DiskObjectWriter
所帶來的buffer開銷也是一個不容小視的內(nèi)存開銷。假定我們有1k個Mapper和1k個Reducer,那么就會有1M個bucket,于此同時就會有1M個write handler,而每一個write handler默認需要100KB內(nèi)存,那么總共需要100GB的內(nèi)存。這樣的話僅僅是buffer就需要這么多的內(nèi)存,內(nèi)存的開銷是驚人的。當然實際情況下這1k個Mapper是分時運行的話,所需的內(nèi)存就只有cores * reducer numbers * 100KB
大小了。但是reducer數(shù)量很多的話,這個buffer的內(nèi)存開銷也是蠻厲害的。
為了解決shuffle文件過多的情況,Spark 0.8.1引入了新的shuffle consolidation,以期顯著減少shuffle文件的數(shù)量。
首先我們以圖例來介紹一下shuffle consolidation的原理。
假定該job有4個Mapper和4個Reducer,有2個core,也就是能并行運行兩個task。我們可以算出Spark的shuffle write共需要16個bucket,也就有了16個write handler。在之前的Spark版本中,每一個bucket對應(yīng)的是一個文件,因此在這里會產(chǎn)生16個shuffle文件。
而在shuffle consolidation中每一個bucket并非對應(yīng)一個文件,而是對應(yīng)文件中的一個segment,同時shuffle consolidation所產(chǎn)生的shuffle文件數(shù)量與Spark core的個數(shù)也有關(guān)系。在上面的圖例中,job的4個Mapper分為兩批運行,在第一批2個Mapper運行時會申請8個bucket,產(chǎn)生8個shuffle文件;而在第二批Mapper運行時,申請的8個bucket并不會再產(chǎn)生8個新的文件,而是追加寫到之前的8個文件后面,這樣一共就只有8個shuffle文件,而在文件內(nèi)部這有16個不同的segment。因此從理論上講shuffle consolidation所產(chǎn)生的shuffle文件數(shù)量為C
×
R
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">C×R
C
×
R
,其中C
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">C
C
是Spark集群的core number,R
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">R
R
是Reducer的個數(shù)。
需要注意的是當 M
=
C
" role="presentation" style="display: inline; font-style: normal; font-weight: normal; line-height: normal; font-size: 14px; text-indent: 0px; text-align: left; text-transform: none; letter-spacing: normal; word-spacing: normal; word-wrap: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">M=C
M
=
C
時shuffle consolidation所產(chǎn)生的文件數(shù)和之前的實現(xiàn)是一樣的。
Shuffle consolidation顯著減少了shuffle文件的數(shù)量,解決了之前版本一個比較嚴重的問題,但是writer handler的buffer開銷過大依然沒有減少,若要減少writer handler的buffer開銷,我們只能減少Reducer的數(shù)量,但是這又會引入新的問題,下文將會有詳細介紹。
講完了shuffle write的進化史,接下來要講一下shuffle fetch了,同時還要講一下Spark的aggregator,這一塊對于Spark實際應(yīng)用的性能至關(guān)重要。
Shuffle Fetch and Aggregator
Shuffle write寫出去的數(shù)據(jù)要被Reducer使用,就需要shuffle fetcher將所需的數(shù)據(jù)fetch過來,這里的fetch包括本地和遠端,因為shuffle數(shù)據(jù)有可能一部分是存儲在本地的。Spark對shuffle fetcher實現(xiàn)了兩套不同的框架:NIO通過socket連接去fetch數(shù)據(jù);OIO通過netty server去fetch數(shù)據(jù)。分別對應(yīng)的類是BasicBlockFetcherIterator
和NettyBlockFetcherIterator
。
在Spark 0.7和更早的版本中,只支持BasicBlockFetcherIterator
,而BasicBlockFetcherIterator
在shuffle數(shù)據(jù)量比較大的情況下performance始終不是很好,無法充分利用網(wǎng)絡(luò)帶寬,為了解決這個問題,添加了新的shuffle fetcher來試圖取得更好的性能。對于早期shuffle性能的評測可以參看Spark usergroup。當然現(xiàn)在BasicBlockFetcherIterator
的性能也已經(jīng)好了很多,使用的時候可以對這兩種實現(xiàn)都進行測試比較。
接下來說一下aggregator。我們都知道在Hadoop MapReduce的shuffle過程中,shuffle fetch過來的數(shù)據(jù)會進行merge sort,使得相同key下的不同value按序歸并到一起供Reducer使用,這個過程可以參看下圖:
所有的merge sort都是在磁盤上進行的,有效地控制了內(nèi)存的使用,但是代價是更多的磁盤IO。
那么Spark是否也有merge sort呢,還是以別的方式實現(xiàn),下面我們就細細說明。
首先雖然Spark屬于MapReduce體系,但是對傳統(tǒng)的MapReduce算法進行了一定的改變。Spark假定在大多數(shù)用戶的case中,shuffle數(shù)據(jù)的sort不是必須的,比如word count,強制地進行排序只會使性能變差,因此Spark并不在Reducer端做merge sort。既然沒有merge sort那Spark是如何進行reduce的呢?這就要說到aggregator了。
aggregator本質(zhì)上是一個hashmap,它是以map output的key為key,以任意所要combine的類型為value的hashmap。當我們在做word count reduce計算count值的時候,它會將shuffle fetch到的每一個key-value pair更新或是插入到hashmap中(若在hashmap中沒有查找到,則插入其中;若查找到則更新value值)。這樣就不需要預(yù)先把所有的key-value進行merge sort,而是來一個處理一個,省下了外部排序這一步驟。但同時需要注意的是reducer的內(nèi)存必須足以存放這個partition的所有key和count值,因此對內(nèi)存有一定的要求。
在上面word count的例子中,因為value會不斷地更新,而不需要將其全部記錄在內(nèi)存中,因此內(nèi)存的使用還是比較少的。考慮一下如果是group by key這樣的操作,Reducer需要得到key對應(yīng)的所有value。在Hadoop MapReduce中,由于有了merge sort,因此給予Reducer的數(shù)據(jù)已經(jīng)是group by key了,而Spark沒有這一步,因此需要將key和對應(yīng)的value全部存放在hashmap中,并將value合并成一個array。可以想象為了能夠存放所有數(shù)據(jù),用戶必須確保每一個partition足夠小到內(nèi)存能夠容納,這對于內(nèi)存是一個非常嚴峻的考驗。因此Spark文檔中建議用戶涉及到這類操作的時候盡量增加partition,也就是增加Mapper和Reducer的數(shù)量。
增加Mapper和Reducer的數(shù)量固然可以減小partition的大小,使得內(nèi)存可以容納這個partition。但是我們在shuffle write中提到,bucket和對應(yīng)于bucket的write handler是由Mapper和Reducer的數(shù)量決定的,task越多,bucket就會增加的更多,由此帶來write handler所需的buffer也會更多。在一方面我們?yōu)榱藴p少內(nèi)存的使用采取了增加task數(shù)量的策略,另一方面task數(shù)量增多又會帶來buffer開銷更大的問題,因此陷入了內(nèi)存使用的兩難境地。
為了減少內(nèi)存的使用,只能將aggregator的操作從內(nèi)存移到磁盤上進行,Spark社區(qū)也意識到了Spark在處理數(shù)據(jù)規(guī)模遠遠大于內(nèi)存大小時所帶來的問題。因此PR303提供了外部排序的實現(xiàn)方案,相信在Spark 0.9 release的時候,這個patch應(yīng)該能merge進去,到時候內(nèi)存的使用量可以顯著地減少。
End
本文詳細地介紹了Spark的shuffle實現(xiàn)是如何進化的,以及遇到問題解決問題的過程。shuffle作為Spark程序中很重要的一個環(huán)節(jié),直接影響了Spark程序的性能,現(xiàn)如今的Spark版本雖然shuffle實現(xiàn)還存在著種種問題,但是相比于早期版本,已經(jīng)有了很大的進步。開源代碼就是如此不停地迭代推進,隨著Spark的普及程度越來越高,貢獻的人越來越多,相信后續(xù)的版本會有更大的提升。