Spark有三种shuffle,分别是hash shuffle、sort shuffle、Tungsten Shuffle。
1、HashShuffle
适合小数据的场景,对小规模数据的处理效率比排序的shuffle高。
1) 每一个Mapper创建出和Reducer数目相同的bucket,bucket实际上是一个buffer,其大小为spark.shuffle.file.buffer.kb(默认32KB)。
2) Mapper产生的结果会根据设置的partition算法填充到每个bucket中去,然后再写入到磁盘文件。
3) Reducer从远端或是本地的block manager中找到相应的文件读取数据。
针对上述Shuffle过程产生的文件过多问题,Spark有另外一种改进的Shuffle过程:consolidation Shuffle,以期显著减少Shuffle文件的数量。在consolidation Shuffle中每个bucket并非对应一个文件,而是对应文件中的一个segment部分。Job的map在某个节点上第一次执行,为每个reduce创建bucket对应的输出文件,把这些文件组织成ShuffleFileGroup,当这次map执行完之后,这个ShuffleFileGroup可以释放为下次循环利用;当又有map在这个节点上执行时,不需要创建新的bucket文件,而是在上次的ShuffleFileGroup中取得已经创建的文件继续追加写一个segment;当前次map还没执行完,ShuffleFileGroup还没有释放,这时如果有新的map在这个节点上执行,无法循环利用这个ShuffleFileGroup,而是只能创建新的bucket文件组成新的ShuffleFileGroup来写输出。
优点
1) 快-不需要排序,也不需要维持hash表
2) 不需要额外空间用作排序
3) 不需要额外IO---数据写入磁盘只需一次,读取也只需一次
缺点
1) 当partitions大时,输出大量的文件(cores * R),性能开始降低 //cores是CPU个数
2) 大量的文件写入,使文件系统开始变为随机写,性能比顺序写要降低100倍
3) 缓存空间占用比较大
Reduce去拖Map的输出数据,Spark提供了两套不同的拉取数据框架:通过socket连接去取数据;使用netty框架去取数据。
Reduce拖过来数据之后以什么方式存储呢?Reduce拖过来的数据会放在一个HashMap中,HashMap中存储的也是
Shuffle取过来的数据全部存放在内存中,对于数据量比较小或者已经在Map端做过合并处理的Shuffle数据,占用内存空间不会太大,但是对于比如group by key这样的操作,Reduce需要得到key对应的所有value,并将这些value组成一个数组放在内存中,这样当数据量较大时,就需要较多内存。
当内存不够时,要不就失败,要不就用老办法把内存中的数据移到磁盘上放着。Spark意识到在处理数据规模远远大于内存空间时所带来的不足,引入了一个具有外部排序的方案。Shuffle过来的数据先放在内存中,当内存中存储的
2、SortShuffle:
SortShuffleManager 的运行机制主要分成两种,一种是普通运行机制,另一种是bypass 运行机制。当 shuffle reduce task 的数量小于等于 spark.shuffle.sort.bypassMergeThreshold 参数的值时(默认为 200),就会启用 bypass 机制。
1)普通运行机制
在该模式下,数据会先写入一个内存数据结构中,此时根据不同的 shuffle 算子, 可能选用不同的数据结构。如果是 reduceByKey 这种聚合类的 shuffle 算子,那么会选用 Map 数据结构, 一边通过 Map 进行聚合,一边写入内存; 如果是 join 这种普通的 shuffle 算子,那么会选用 Array 数据结构,直接写入内存。接着, 每写一条数据进入内存数据结构之后,就会判断一下, 是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘, 然后清空内存数据结构。
在溢写到磁盘文件之前,会先根据 key 对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的 batch 数量是 10000 条,也就是说, 排序好的数据,会以每批 1 万条数据的形式分批写入磁盘文件。写入磁盘文件是通过 Java 的 BufferedOutputStream 实现的。BufferedOutputStream 是 Java 的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这 样可以减少磁盘 IO 次数, 提升性能。
一个 task 将所有数据写入内存数据结构的过程中, 会发生多次磁盘溢写操作, 也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并, 这就是merge 过程, 此时会将之前所有临时磁盘文件中的数据读取出来,然后依次写入最终的磁盘文件之中。此外,由于一个 task 就只对应一个磁盘文件,也就意味着该 task为下游 stage 的 task 准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个 task 的数据在文件中的 start offset 与 end offset。
SortShuffleManager 由于有一个磁盘文件 merge 的过程,因此大大减少了文件数量。比如第一个 stage 有 50 个 task,总共有 10 个 Executor,每个 Executor 执行 5 个 task,而第二个 stage 有 100 个 task。由于每个 task 最终只有一个磁盘文件,因此此时每个 Executor 上只有 5 个磁盘文件, 所有 Executor 只有 50 个磁盘文件。
普通运行机制的 SortShuffleManager 工作原理如图 1-9 所示:
2) bypass 运行机制
bypass运行机制的触发条件如下:
①shuffle map task 数量小于 spark.shuffle.sort.bypassMergeThreshold 参数的值。
②不是聚合类的 shuffle 算子。
此时,每个 Map Task 会为每个下游 Reduce Task 都创建一个临时磁盘文件,并将数据按 key 进行 hash然后根据 key 的 hash 值,将数据写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。
该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的, 因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好。
而该机制与普通 SortShuffleManager 运行机制的不同在于: 第一, 磁盘写机制不同;第二,不会进行排序。也就是说,启用该机制的最大好处在于, shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。
byPass运行机制的 SortShuffleManager 工作原理如图 1-10 所示:
与hash shuffle相比,sort shuffle中每个Mapper只产生一个数据文件和一个索引文件,数据文件中的数据按照Reducer排序,但属于同一个Reducer的数据不排序。Mapper产生的数据先放到AppendOnlyMap这个数据结构中,如果内存不够,数据则会spill到磁盘,最后合并成一个文件。
与Hash shuffle相比,shuffle文件数量减少,内存使用更加可控。但排序会影响速度。
优点
1) map创建文件量较少
2) 少量的IO随机操作,大部分是顺序读写
缺点
1) 要比Hash Shuffle要慢,需要自己通过spark.shuffle.sort.bypassMergeThreshold来设置合适的值。
2) 如果使用SSD盘存储shuffle数据,那么Hash Shuffle可能更合适。
3、Tungsten Shuffle:
Tungsten-sort 算不得一个全新的shuffle 方案,它在特定场景下基于类似现有的Sort Based Shuffle处理流程,对内存/CPU/Cache使用做了非常大的优化。带来高效的同时,也就限定了自己的使用场景。如果Tungsten-sort 发现自己无法处理,则会自动使用 Sort Based Shuffle进行处理。
Tungsten-sort优化点主要在三个方面:
1)直接在序列化后的二进制数据上排序而不是java 对象,减少了memory的开销和GC的overhead。
2)提供cache-efficient sorter,使用一个8bytes的指针,把排序转化成了一个指针数组的排序。
3)spill的merge过程也无需反序列化即可完成。
这些优化的实现导致引入了一个新的内存管理模型,类似OS的Page,对应的实际数据结构为MemoryBlock,支持off-heap 以及 in-heap 两种模式。为了能够对Record 在这些MemoryBlock进行定位,引入了Pointer(指针)的概念。
Spark 默认开启的是Sort Based Shuffle,想要打开Tungsten-sort ,请设置
spark.shuffle.manager=tungsten-sort
对应的实现类是:
org.apache.spark.shuffle.unsafe.UnsafeShuffleManager
名字的来源是因为使用了大量JDK Sun Unsafe API。
当且仅当下面条件都满足时,才会使用新的Shuffle方式:
1) Shuffle dependency 不能带有aggregation 或者输出需要排序
2) Shuffle 的序列化器需要是 KryoSerializer 或者 Spark SQL's 自定义的一些序列化方式.
3) Shuffle 文件的数量不能大于 16777216(2的24次方)
4) 序列化时,单条记录不能大于 128 MB
可以看到,能使用的条件还是挺苛刻的。
这些限制来源于哪里
参看如下代码,page的大小:
this.pageSizeBytes = (int) Math.min( PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES, shuffleMemoryManager.pageSizeBytes());
这就保证了页大小不超过PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES 的值,该值就被定义成了128M。
而产生这个限制的具体设计原因,我们还要仔细分析下Tungsten的内存模型:
这张图其实画的是 on-heap 的内存逻辑图,其中 #Page 部分为13bit, Offset 为51bit,你会发现 2^51 >>128M的。但是在Shuffle的过程中,对51bit 做了压缩,使用了27bit,具体如下:
[24 bit partition number][13 bit memory page number][27 bit offset in page]
这里预留出的24bit给了partition number,为了后面的排序用。上面的好几个限制其实都是因为这个指针引起的:
一个是partition 的限制,前面的数字 16777216 就是来源于partition number 使用24bit 表示的。
第二个是page number
第三个是偏移量,最大能表示到2^27=128M。那一个task 能管理到的内存是受限于这个指针的,最多是 2^13 * 128M 也就是1TB左右。
有了这个指针,我们就可以定位和管理到off-heap 或者 on-heap里的内存了。这个模型还是很漂亮的,内存管理也非常高效,记得之前的预估PartitionedAppendOnlyMap的内存是非常困难的,但是通过现在的内存管理机制,是非常快速并且精确的。
对于第一个限制,那是因为后续Shuffle Write的sort 部分,只对前面24bit的partiton number 进行排序,key的值没有被编码到这个指针,所以没办法进行ordering同时,因为整个过程是追求不反序列化的,所以不能做aggregation(聚合)。
Shuffle Write
核心类:
org.apache.spark.shuffle.unsafe.UnsafeShuffleWriter
数据会通过 UnsafeShuffleExternalSorter.insertRecordIntoSorter 一条一条写入到 serOutputStream 序列化输出流。
这里消耗内存的地方是serBuffer = new MyByteArrayOutputStream(1024 * 1024),默认是1M,类似于Sort Based Shuffle 中的ExternalSorter,在Tungsten Sort 对应的为UnsafeShuffleExternalSorter,记录序列化后就通过sorter.insertRecord方法放到sorter里去了。
这里sorter 负责申请Page,释放Page,判断是否要进行spill都这个类里完成。代码的架子其实和Sort Based 是一样的。
(另外,值得注意的是,这张图里进行spill操作的同时检查内存可用而导致的Exeception 的bug 已经在1.5.1版本被修复了,忽略那条路径)
内存是否充足的条件依然shuffleMemoryManager 来决定,也就是所有task shuffle 申请的Page内存总和不能大于下面的值: ExecutorHeapMemeory * 0.2 * 0.8
上面的数字可通过下面两个配置来更改:
spark.shuffle.memoryFraction=0.2
spark.shuffle.safetyFraction=0.8
UnsafeShuffleExternalSorter 负责申请内存,并且会生成该条记录最后的逻辑地址,也就前面提到的 Pointer。
接着Record 会继续流转到UnsafeShuffleInMemorySorter中,这个对象维护了一个指针数组:
private long[] pointerArray;
数组的初始大小为 4096,后续如果不够了,则按每次两倍大小进行扩充。
假设100万条记录,那么该数组大约是8M 左右,所以其实还是很小的。一旦spill后该UnsafeShuffleInMemorySorter就会被赋为null,被回收掉。
我们回过头来看spill,其实逻辑上也异常简单了,UnsafeShuffleInMemorySorter 会返回一个迭代器,该迭代器粒度每个元素就是一个指针,然后到根据该指针可以拿到真实的record,然后写入到磁盘,因为这些record 在一开始进入UnsafeShuffleExternalSorter 就已经被序列化了,所以在这里就纯粹变成写字节数组了。形成的结构依然和Sort Based Shuffle 一致,一个文件里不同的partiton的数据用fileSegment来表示,对应的信息存在一个index文件里。
另外写文件的时候也需要一个 buffer : spark.shuffle.file.buffer = 32k
另外从内存里拿到数据放到DiskWriter,这中间还要有个中转,是通过
final byte[] writeBuffer = new byte[DISK_WRITE_BUFFER_SIZE=1024 * 1024];
来完成的,都是内存,所以很快。
Task结束前,我们要做一次mergeSpills操作,然后形成一个shuffle 文件。这里面其实也挺复杂的,
如果开启了`spark.shuffle.unsafe.fastMergeEnabled=true`并且没有开启`spark.shuffle.compress=true`
或者压缩方式为:LZFCompressionCodec,则可以非常高效的进行合并,叫做transferTo。不过无论是什么合并,都不需要进行反序列化。