上一篇“shuffle的一些概念”中提到了三种shuffle的方式,此处先来分析下SortShuffleWriter,结合代码一起调试下看看它内部到底是如何运行的。
选择带有聚合的算子调试就行了,例如对一个pairRDD进行reduceByKey操作,然后就可以跳到对应的源码里面了,可以看出reduceByKey算子使用的是确实是SortShuffleWriter:
直接跑到运行Task的代码中看它到低是如何进行Shuffle的。
Ps:这里我有个疑问,我代码里面并没有选择Kryo的序列化方式,为什么这里显示的是kryo?默认的不应该是JavaSerializer么?
SortShuffleWriter流程图如下所示:
Shuffle相关的一些配置项:
这两个用于控制shuffle时落盘的频率:
spark.shuffle.file.buffer: 默认32K
spark.shuffle.spill.batchSize: 默认10000
shuffle时临时文件相关配置:
spark.shuffle.spill.initialMemoryThreshold: shuffle时初始的缓冲区大小,扩容时会扩容一辈,申请不到一倍的内存就会生成临时shuffle文件,默认5*1024*1024(5M)
spark.shuffle.spill.numElementsForceSpillThreshold: shuffle时,缓冲区的数量达到这量就会生成临时shuffle文件,默认Long.MaxValue
Shuffle主要过程个人总结为如下5步:
接下来对上面这些流程进行一步步的详细分析。
1.创建用于对数据进行排序、聚合、写缓存等操作ExternalSort
比较重要的参数是下面4个:
private val fileBufferSize = conf.getSizeAsKb("spark.shuffle.file.buffer", "32k").toInt * 1024
private val serializerBatchSize = conf.getLong("spark.shuffle.spill.batchSize", 10000)
@volatile private var map = new PartitionedAppendOnlyMap[K, C]
@volatile private var buffer = new PartitionedPairBuffer[K, C]
由于Shuffle将数据写到磁盘之前,会先写到缓冲区中,满了之后才会溢出写到磁盘。写磁盘的时候默认32K或者10000条数据写一次磁盘。
后面两个数据结构就是所谓的缓冲区,map适合有Aggregator场景,buffer则不是。
2.接着调用ExternalSort中的insertAll()方法将数据写入到缓冲区。如果数据量太多,会flush()成多个临时的shuffle文件
map.changeValue(…)是根据用于定义的函数,更新缓冲区中的值,缓冲区里面长这样:
接下来的maySpillCollection()方法内部会调用mayBeSpill()判断是否需要生成临时的Shuffle文件(所以干嘛要分成两个方法呢?一个方法不就好了么?!):
Sparkenv.get.conf.getLong("spark.shuffle.spill.initialMemoryThreshold", 5 * 1024 * 1024)
缓冲区初始化大小,可以扩大缓冲区的话,会尝试申请一倍的内存Sparkenv.get.conf.getLong("spark.shuffle.spill.numElementsForceSpillThreshold", Long.MaxValue)
默认达到Long类型的最大长度才会刷新缓冲区,我早就设置成10了,哈哈哈,就是为了看它是如何生成临时文件的。
2.1分析下如何生成临时Shuffle文件:
mayBeSpill()就是根据上面定义的两个参数,将内存中的数据刷写到磁盘上生成临时的Shuffle文件:
注意:写临时文件完成之后会释放这个Task所占用的内存空间,到它最初所占有的内存大小,就是那个默认的5M:
写临时文件的方法是spill(collection),它的核心是生成一个inMemoryIterator迭代器,然后使用这个迭代器将数据刷到磁盘上,并记录下这个临时shuffle文件。
2.1.1获取内存数据迭代器:
因为这里reduceByKeyy使用的是PartitionedAppendOnlyMap结构,所以这里就看下它的迭代器:
这个迭代器的key是一个tuple(partition ID,K),然后还有个keyComparator,估计是还要按照key排序一把,点进去看下。
首先是生成一个partitionKeyComparator,先比较partition,在比较key
这个comparator用于对AppendOnlyMap中的元素进行排序
最后返回的这个迭代器迭代的时候,会按照partition的顺序返回
2.1.2将内存中的数据刷写到磁盘上生成临时Shuffle文件
spillMemoryIteratorToDisk(inMemoryIterator:WritablePartitionedIterator)通过上面inMemoryIterator的将数据刷写到磁盘生成临时文件,临时文件的目录和文件名称命名如下:
2.1.3可以看到最终会生成很多个临时的temp_shuffle文件
至此,写内存的过程就结束了。注意如果数据量少的话,就不会有这些临时的Shuffle文件,而是全部在内存里面。
3.接着将map端缓存的文件写入到磁盘中,最终只会生成一个Shuffle文件,临时Shuffle文件会被合并到一起。
writePartitionedFile()方法就是往最终的那一个Shuffle文件中写数据,内部流程如下所示:
this.partitionedItreator这个迭代器是按照下面的方式获取的:
最终会为每个partition生成一个迭代器,并且每个partition中的元素已经完成了agg和order操作(如果定义过这两个操作的话)。
4.生成索引文件
这个方法看了下,好像没什么好说的,就是写了一个IndexFile,这个文件记录的是每个partition在那个正式的Shuffle文件中的偏移量,以便reduce任务拉取时使用。
5.返回MapStatus
MapStatus会返回给Driver,这样Driver就可以知道每个Executor上的Shuffle文件的位置了。
如果MapStatus太大的话会对它进行压缩。
参考:
《Spark内核设计的艺术》
https://www.cnblogs.com/20125126chen/p/4534190.html(写文件时,文件系统如何定位)
http://www.importnew.com/14111.html(写文件时,Java I/O底层是如何工作的)