Spark中Shuffle的前世今生

1.Shuffle的变迁

Spark 0.8及以前 Hash Based Shuffle
Spark 0.8.1 为Hash Based Shuffle引入File Consolidation机制
Spark 0.9 引入ExternalAppendOnlyMap
Spark 1.1 引入Sort Based Shuffle,但默认仍为Hash Based Shuffle
Spark 1.2 默认的Shuffle方式改为Sort Based Shuffle
Spark 1.4 引入Tungsten-Sort Based Shuffle
Spark 1.6 Tungsten-sort并入Sort Based Shuffle
Spark 2.0 Hash Based Shuffle退出历史舞台

2.HashBaseShuffle

虽然HashBaseShuffle在Spark2.0已经不再使用,但了解它有利于我们完整理解Shuffle的演变。

优化前:

优化前HasdBaseShuffle工作流程
上图有2个Executor,每个Executor有1个core,total-executor-cores为数为 2,每个 task 的执行结果会被溢写到本地磁盘上。每个 task 包含 R 个缓冲区,R = reducer 个数(也就是下一个 stage 中 task 的个数),缓冲区被称为 bucket,其大小为spark.shuffle.file.buffer.kb ,默认是 32KB。

其实bucket表示缓冲区,即ShuffleMapTask 调用分区器的后数据要存放的地方。

ShuffleMapTask 的执行过程:先根据 pipeline 的计算逻辑对数据进行运算,然后根据分区器计算出每一个record的分区编号。每得到一个 record 就将其送到对应的 bucket 里,具体是哪个 bucket 由partitioner.getPartition(record.getKey()))决定。每个 bucket 里面的数据会满足溢写的条件会被溢写到本地磁盘上,形成一个 ShuffleBlockFile,或者简称 FileSegment。之后的下游的task会根据分区会去 fetch 属于自己的 FileSegment,进入 shuffle read 阶段。

老版本的HashShuffleManager存在的问题:

1.产成的 FileSegment 过多。每个 ShuffleMapTask 产生 R(下游Task的数量)个 FileSegment,M 个 ShuffleMapTask 就会产生 M * R 个文件。一般 Spark job 的 M 和 R 都很大,因此磁盘上会存在大量的数据文件。
2.缓冲区占用内存空间大。每个 ShuffleMapTask 需要开 R 个 bucket,M 个 ShuffleMapTask 就会产生 M * R 个 bucket。虽然一个 ShuffleMapTask 结束后,对应的缓冲区可以被回收,但一个 worker node 上同时存在的 bucket 个数可以达到 cores R 个,占用的内存空间也就达到了cores * R * 32 KB。对于 8 核 1000 个 reducer 来说,占用内存就是 256MB。

优化后

优化后的HashBaseShuffle工作流程
在一个core上连续执行的ShuffleMapTasks可以共用一个输出文件 ShuffleFile。先执行完的 ShuffleMapTask 形成ShuffleBlock i,后执行的 ShuffleMapTask可以将输出数据直接追加到ShuffleBlock i后面,形成ShuffleBlock’,每个ShuffleBlock被称为FileSegment。下一个stage的reducer只需要fetch整个 ShuffleFile就行了。这样每个Executor持有的文件数降为cores*R。
FileConsolidation 功能可以通过spark.shuffle.consolidateFiles=true来开启。

3.Spark2.0+中的三种Shuffle

3.1Sort-Based Shuffle

由于 HashShuffle 会产生很多的磁盘文件,引入 Consolidation 机制虽然在一定程度上减少了磁盘文件数量,但是不足以有效提高 Shuffle 的性能,适合中小型数据规模的大数据处理。

为了让 Spark 在更大规模的集群上更高性能处理更大规模的数据,因此在 Spark 1.1 版本中,引入了 SortShuffle。
ShortBaseShuffle工作机制
该机制每一个 ShuffleMapTask 都只创建一个文件,将所有的 ShuffleReduceTask 的输入都写入同一个文件,并且对应生成一个索引文件。

以前的数据是放在内存缓存中,等到数据计算完了再刷到磁盘,现在为了减少内存的使用,在内存不够用的时候,可以将输出溢写到磁盘,结束的时候,再将这些不同的文件联合内存的数据一起进行归并,从而减少内存的使用量。一方面文件数量显著减少,另一方面减少Writer 缓存所占用的内存大小,而且同时避免 GC 的风险和频率。

但对于 Rueducer 数比较少的情况,Hash Shuffle 要比 Sort Shuffle 快,因此 Sort Shuffle 有个 “fallback” 计划,对于 Reducers 数少于 “spark.shuffle.sort.bypassMergeThreshold” (200 by default),将使用 fallback 计划,hashing 相关数据到分开的文件,然后合并这些文件为一个。

3.2三种Shuffle的选择逻辑

Shuffle的选择逻辑
Shuffle 的整个生命周期由 ShuffleManager 来管理,Spark 2.3中,唯一的支持方式为 SortShuffleManager,SortShuffleManager 中定义了 writer 和 reader 对应shuffle 的 map 和 reduce 阶段。reader 只有一种实现 BlockStoreShuffleReader,writer 有三种运行实现:

BypassMergeSortShuffleWriter:当前 shuffle 没有聚合, 并且分区数小于 spark.shuffle.sort.bypassMergeThreshold(默认200)
UnsafeShuffleWriter:当条件不满足 BypassMergeSortShuffleWriter 时, 并且当前 rdd 的数据支持序列化(即 UnsafeRowSerializer),也不需要聚合, 分区数小于 2^24
SortShuffleWriter:剩余情况

3.3 BypassMergeSortShuffleWriter

BypassMergeSortShuffleWriter 的运行机制的触发条件如下:

当shuffle reduce task(即partition)数量小于spark.shuffle.sort.bypassMergeThreshold 参数的值,且没有map side aggregations。
note: map side aggregations是指在 map 端的聚合操作,通常来说一些聚合类的算子都会都 map 端的 aggregation。不过对于 groupByKey 和combineByKey, 如果设定 mapSideCombine 为false,就不会有 map side aggregations。
BypassMergeSortShuffleHandle 算法适用于没有聚合,数据量不大的场景。 给每个分区分配一个临时文件,对每个 record 的 key 使用分区器(模式是hash,如果用户自定义就使用自定义的分区器)找到对应分区的输出文件句柄,写入文件对应的文件。

写入磁盘文件是通过 Java的 BufferedOutputStream 实现的,BufferedOutputStream 是 Java 的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘 IO 次数,提升性能。所以图中会有内存缓冲的概念。

BypassMergeSortShuffle Writer的流程
最后,会将所有临时文件合并成一个磁盘文件,并创建一个索引文件标识下游各个 reduce task 的数据在文件中的 start offset与 end offset,可以随机access某个partition的所有数据。
BypassMergeSortShuffle输出文件的格式
该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一样的,也会创建很多的临时文件(所以触发条件中会有 reduce task 数量限制),只是在最后会做一个磁盘文件的合并,对于 shuffle reader 会更友好一些。

BypassMergeSortShuffleWriter 所有的中间数据都是在磁盘里,并没有利用内存。而且它只保证分区索引的排序,而并不保证数据的排序。

3.4 SortShuffleWriter

可以先考虑一个问题,假如有 100 亿条数据,内存只有 1M,但是磁盘很大,现在要对这 100 亿条数据进行排序,是没法把所有的数据一次性的 load 进行内存进行排序的,这就涉及到一个外部排序的问题。

假设 1M 内存能装进 1 亿条数据,每次能对这 1 亿条数据进行排序,排好序后输出到磁盘,总共输出 100 个文件,最后把这 100 个文件进行 merge 成一个全局有序的大文件,这是归并的思路:
可以每个文件(有序的)都取一部分头部数据最为一个 buffer, 并且把这 100个 buffer 放在一个堆里面,进行堆排序,比较方式就是对所有堆元素(buffer)的head 元素进行比较大小, 然后不断的把每个堆顶的 buffer 的head 元素 pop 出来输出到最终文件中, 然后继续堆排序,继续输出。如果哪个 buffer 空了,就去对应的文件中继续补充一部分数据。最终就得到一个全局有序的大文件。
SortShuffleWirter 的实现大概就是这样,和 Hadoop MR 的实现相似。
SortShuffleWriter工作流程
该模式下,数据首先写入一个内存数据结构中,此时根据不同的 shuffle 算子,可能选用不同的数据结构。有些 shuffle 操作涉及到聚合,对于这种需要聚合的操作,使用 PartitionedAppendOnlyMap 来排序。对于不需要聚合的,则使用 PartitionedPairBuffer 排序。

在进行 shuffle 之前,map 端会先将数据进行排序。排序的规则,根据不同的场景,会分为两种。首先会根据 Key 将元素分成不同的 partition。第一种只需要保证元素的 partitionId 排序,但不会保证同一个 partitionId 的内部排序。第二种是既保证元素的 partitionId 排序,也会保证同一个 partitionId 的内部排序。

接着,往内存写入数据,每隔一段时间,当向 MemoryManager 申请不到足够的内存时,或者数据量超过 spark.shuffle.spill.numElementsForceSpillThreshold 这个阈值时 (默认是 Long 的最大值,不起作用),就会进行 Spill 内存数据到文件,然后清空内存数据结构。假设可以源源不断的申请到内存,那么 Write 阶段的所有数据将一直保存在内存中,由此可见,PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 是比较吃内存的。

在溢写到磁盘文件之前,会先根据 key 对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的 batch 数量是 10000 条,也就是说,排序好的数据,会以每批 1 万条数据的形式分批写入磁盘文件。写入磁盘文件也是通过 Java 的 BufferedOutputStream 实现的。

一个 task 将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就会产生多个临时文件。在将最终排序结果写入到数据文件之前,需要将内存中的 PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 和已经 spill 到磁盘的 SpillFiles 进行合并。

此外,由于一个 task 就只对应一个磁盘文件,也就意味着该 task 为下游 stage 的 task 准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个 task 的数据在文件中的 start offset 与 end offset。

BypassMergeSortShuffleWriter 与该机制相比:

第一,磁盘写机制不同;第二,不会进行排序。也就是说,启用 BypassMerge 机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销,当然需要满足那两个触发条件。

4.5 UnsafeShuffleWriter

触发条件有三个:

  • Serializer 支持 relocation。Serializer 支持 relocation 是指,Serializer可以对已经序列化的对象进行排序,这种排序起到的效果和先对数据排序再序列化一致。支持 relocation 的 Serializer 是 KryoSerializer,Spark 默认使用 JavaSerializer,通过参数 spark.serializer 设置;

  • 没有指定 aggregation 或者 key 排序, 因为 key 没有编码到排序指针中,所以只有 partition 级别的排序。

  • partition 数量不能大于指定的阈值(2^24),因为 partition number 使用24bit 表示的。

UnsafeShuffleWriter 将 record 序列化后插入sorter,然后对已经序列化的 record 进行排序,并在排序完成后写入磁盘文件作为 spill file,再将多个 spill file 合并成一个输出文件。在合并时会基于 spill file 的数量和 IO compression codec 选择最合适的合并策略。
UnsafeShuffleWriter 首先将数据序列化,保存在 MemoryBlock 中。然后将该数据的地址和对应的分区索引,保存在 ShuffleInMemorySorter 内存中,利用ShuffleInMemorySorter 根据分区排序。当内存不足时,会触发 spill 操作,生成spill 文件。最后会将所有的 spill文 件合并在同一个文件里。

整个过程可以想象成归并排序。ShuffleExternalSorter 负责分片的读取数据到内存,然后利用 ShuffleInMemorySorter 进行排序。排序之后会将结果存储到磁盘文件中。这样就会有很多个已排序的文件, UnsafeShuffleWriter 会将所有的文件合并。

下图来自 Spark ShuffleWriter 原理,表示了map端一个分区的shuffle过程:

一个分区Shuffle的过程

UnsafeShuffleWriter 是对 SortShuffleWriter 的优化,大体上也和 SortShuffleWriter 差不多。从内存使用角度看,主要差异在以下两点:

一方面,在 SortShuffleWriter 的 PartitionedAppendOnlyMap 或者 PartitionedPairBuffer 中,存储的是键值或者值的具体类型,也就是 Java 对象,是反序列化过后的数据。而在 UnsafeShuffleWriter 的 ShuffleExternalSorter 中数据是序列化以后存储到实际的 Page 中,而且在写入数据过程中会额外写入长度信息。总体而言,序列化以后数据大小是远远小于序列化之前的数据。

另一方面,UnsafeShuffleWriter 中需要额外的存储记录(LongArray),它保存着分区信息和实际指向序列化后数据的指针(经过编码的Page num 以及 Offset)。相对于 SortShuffleWriter, UnsafeShuffleWriter 中这部分存储的开销是额外的。

参考文章:https://www.jianshu.com/p/286173f03a0b
https://zhmin.github.io/2019/01/26/spark-shuffle-writer/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值