这一篇分析下UnsafeShuffleWriter这种Shuffle方式,之前在“Spark的Shuffle(0) - Shuffle的一些概念”那篇文章提到过这种Shuffle的情况,这里再说一遍防止自己忘了:
- Shuffle dependency不能带有aggregation操作
- 序列化方式需要支持重定位,即使用KryoSerializer或者SparkSQL自定义的一些序列化方式
- Partition个数不能超过16777216(即2^24-1)。
为什么会有这些限制:
因为这种方式下用到了Tungsten优化,排序的是二进制的数据,不会对数据进行反序列化操作(反序列化就是转成Java对象),所以不支持aggregation。并且上一篇”内存模型分析”的文章中提到过Page的概念,说过一个Page中的数据是用一个64位的long来表示,前24位表示PartitionId,再13位表示PageNumber,最后27位表示OffsetInPage,所以Partition个数不能超过16777216。
一些相关的配置:
spark.file.transferTo: 最后对文件合并时是否使用NIO的方式进行file stream的copy,默认为true。
spark.shuffle.sort.initialBufferSize: 初始化的排序缓冲大小,默认为4096Byte(4KB,这个比SortedBaseShuffleWrite那个初始化的缓冲大小要小多了,那个是5M,反正也会扩容的)
spark.memory.offHeap.enable:是否启用堆外内存,默认为false
spark.memory.offHeap.size:参数设定堆外空间的大小
conf.get("spark.shuffle.manager", "sort"):默认不是tungsten-sort,没什么区别,反射调用的类型是一样的
自己调试的代码如下:
def main(args: Array[String]): Unit = { val sc = SparkHolder.getSparkContext() val rdd = sc.parallelize(1 to 4000, 400) //400是为了不用ByPass那种Shuffle方式 .map(x => (x, List(x, x + 2, x + 1))) .sortByKey() rdd.count() }
- partition数量要大于200,不然会使用BypassMergeSortShuffleWriter
- 序列化方式要设置成KryoSerializer,不然默认的JavaSerializer是不能使用UnsafeShuffleWrite的
Shuffle流程源码分析:
之前说过action操作触发的时候,会registerShuffle()选择合适的shuffleHandle,可以看到此时调用的确实是UnsafeShuffleWriter。
初始化UnsafeShuffleWriter:
它里面有一个非常重要的类,即ShuffleExternalSorter,这个类中还有一个非常重要的属性ShuffleInMemorySorter。
ShuffleExternalSorter和SortShuffleWriter中的ExternalSorter差不多,用于管理内存的申请和释放,之前的“内存管理”那篇文章中说过,这里的内存是以Page的形式存在的,Execution的总内存是一个长度为8192的Page数组。ShuffleExternalSorter还有一个作用就是当内存中数据太多的时候,会先spill到磁盘,防止内存溢出。
ShuffleInMemorySorter是专门用来给数据排序的,它内部包含了一个存储数据指针的LongArray数组,存放编码过后的数据。编码过后的数据指的是一条记录的原数据信息,之前说过使用UnSafeShuffleWriter的话,数据是不会进行反序列化的,所以使用Long类型的变量记录记录一条数据的原信息:PartitionId + pageNum + offsetInPage,它们的长度分别分别是24,13,27位,所以设置一个Page的大小不能超过2^27 = 128M,排序操作的就是这些元数据信息。之前在介绍UnifiedMemoryManager这种内存管理方式的时候提到过,代码中将Page的大小默认限制在1M~64M之间。
看下这个open()里面做了啥:
初始化ShuffleExternalSorter + 创建输出流(序列化数据用了,Tungsten不会反序列化数据,操作的直接是二进制数据)
初始化ShuffleExternalSorter:
注意下,写临时文件的阈值和SortShuffleWriter不一样(Long.MAXVALUE),这里的值要小很多,约10.7亿条。最终写磁盘的时候是一样的,默认是32KB写一次磁盘。
看下这里的初始化ShuffleMemorySorter:
默认存放数据记录信息的array的大小是4096 * 8L,也就是说最初ShuffleMemorySorter里面可以存放4096条记录,当然这个数值也可以手动用参数更改(其实没必要改,反正会扩容的)。
从这里也可以看出,一个Page的大小最初就是申请的内存大小…可以扩容么?后面看看 ---- 是的,可以扩容的,写数据的时候就会看到,翻倍或者不够写磁盘)
(ps:array不会让你全部用完,如果排序方式选择的是TimSort,可用空间为array.size/2,如果是默认的RadixSort,可用空间为array.size/1.5。达到这个阈值就会扩容array或者刷写磁盘,具体看后续的分析)
初始化工作完毕,开始Shuffle:
这里数据Key的顺序是根据RangePartitioner来分区的,具体怎么实现的后面分析RangePartitioner的源码的时候再说,目前只要知道通过它来对数据进行分Partition之后,各个Partition之间的数据就是有序的了。
将数据插入到排序器中:
写入排序器中还涉及到了申请内存、扩容、溢出到磁盘等操作:
从growPointerArrayIfNecessary()方法看下内存的申请和扩容:
如果申请不到新的Page的话,就内存溢出了。这是一个OOM的风险点,避免这个OOM的一个办法就是修改上面那个numElementsForSpillThreshold的值(当然,这个是假设Spark自身出现问题的情况下,接着会说到,其实一般是不会出现这种错误的)。
往growPointerArrayIfNecessary()和acquireNewPageIfNecessary()两个方法内部细看的话,它们申请内存时底层的调用的方法是一样的,都是调用taskMemoryManager.allocatePage(required, this),再底层调用的是memoryManager.acquireExecutionMemory(),即申请的是Execution部分的内存,如果申请不到了就先调用所以consumer的spill()方法,将数据先溢出写到磁盘,还是不够的话就抛出OOM异常。
分析下spill()怎么写溢出文件的:
流程和之前说的SortShuffleWriter差不多。但是SortShuffleWriter使用的ExternalSorter会先按照partitionId排序,再按照key进行排序,而这里只会按照partitionId进行排序。(key的排序是在什么时候完成的? -- 看参考”3”应该是在reduce端完成的,后续分析reduce的源码再详细说吧)
Spill()内部调用了writerSortedFile()方法,该方法就是将数据按照partition写到磁盘上,并记录每个Partition的长度,将这些元数据信息存储在一个叫SpillInfo的结构中,这里只介绍下一些核心的部分:
将临时Shuffle文件合并成一个Shuffle文件:
IndexFile其实就是记录了每个block(Partition)的偏移量,这样getBlockData方法就可以算出每个block的begin和end位置,然后拉取到本地来计算。
简单分析下mergeSpills()的流程:
最后,检查磁盘上的临时Shuffle文件是否已经删除,然后清理内存:
遗留的疑问:
- Key的排序在reduce端完成,代码是怎么走的?
- RangePartitioner是怎么保证数据分区之后每个Partition之间的数据是有序的
参考:
https://0x0fff.com/spark-memory-management/#comment-1188(Spark内存模型介绍)
http://spark.apache.org/docs/2.1.0/configuration.html(Spark配置项界面,有些配置项已经废除了)
http://www.aboutyun.com/thread-22069-1-1.html(Spark中的排序是如何完成的)