读懂 Spark Shuffle

目录

1. HashShuffleManager

2. 优化的 HashShuffleManager

3 spark SortShuffle流程

4 Tungsten Sort Shuffle 运行机制

5 SortShuffle具体详解

5.1 map阶段

5.2 Reduce阶段



1. HashShuffleManager

shuffle write 阶段,主要就是在一个 stage 结束计算之后,为了下一个 stage 可以执行 shuffle 类的算子(比如 reduceByKey),而将每个 task 处理的数据按 key 进行“划分”。所谓“划分”,就是对相同的 key 执行 hash 算法从而将相同 key 都写入同一个磁盘文件中而每一个磁盘文件都只属于下游 stage 的一个 task。在将数据写入磁盘之前,会先将数据写入内存缓冲中,当内存缓冲填满之后,才会溢写到磁盘文件中去。

下一个 stage 的 task 有多少个,当前 stage 的每个 task 就要创建多少份磁盘文件。比如下一个 stage 总共有 100 个 task,那么当前 stage 的每个 task 都要创建 100 份磁盘文件。如果当前 stage 有 50 个 task,总共有 10 个 Executor,每个 Executor 执行 5 个 task,那么每个 Executor 上总共就要创建 500 个磁盘文件,所有 Executor 上会创建 5000 个磁盘文件。由此可见,未经优化的 shuffle write 操作所产生的磁盘文件的数量是极其惊人的。【hashshuffle主要痛点在于产生的小文件过多】

shuffle read 阶段,通常就是一个 stage 刚开始时要做的事情。此时该 stage 的每一个 task 就需要将上一个 stage 的计算结果中的所有相同 key,从各个节点上通过网络都拉取到自己所在的节点上,然后进行 key 的聚合或连接等操作。由于 shuffle write 的过程中,map task 给下游 stage 的每个 reduce task 都创建了一个磁盘文件,因此 shuffle read 的过程中,每个 reduce task 只要从上游 stage 的所有 map task 所在节点上,拉取属于自己的那一个磁盘文件即可。

shuffle read 的拉取过程是一边拉取一边进行聚合的。每个 shuffle read task 都会有一个自己的 buffer 缓冲,每次都只能拉取与 buffer 缓冲相同大小的数据,然后通过内存中的一个 Map 进行聚合等操作。聚合完一批数据后,再拉取下一批数据,并放到 buffer 缓冲中进行聚合操作。以此类推,直到最后将所有数据到拉取完,并得到最终的结果。

HashShuffleManager 工作原理如下图所示:

2. 优化的 HashShuffleManager

为了优化 HashShuffleManager 我们可以设置一个参数:spark.shuffle.consolidateFiles,该参数默认值为 false,将其设置为 true 即可开启优化机制,通常来说,如果我们使用 HashShuffleManager,那么都建议开启这个选项

开启 consolidate 机制之后,在 shuffle write 过程中,task 就不是为下游 stage 的每个 task 创建一个磁盘文件了,此时会出现shuffleFileGroup的概念,每个 shuffleFileGroup 会对应一批磁盘文件,磁盘文件的数量与下游 stage 的 task 数量是相同的。一个 Executor 上有多少个 cpu core,就可以并行执行多少个 task。而第一批并行执行的每个 task 都会创建一个 shuffleFileGroup,并将数据写入对应的磁盘文件内。

当 Executor 的 cpu core 执行完一批 task,接着执行下一批 task 时,下一批 task 就会复用之前已有的 shuffleFileGroup,包括其中的磁盘文件,也就是说,此时 task 会将数据写入已有的磁盘文件中,而不会写入新的磁盘文件中。因此,consolidate 机制允许不同的 task 复用同一批磁盘文件,这样就可以有效将多个 task 的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升 shuffle write 的性能

假设第二个 stage 有 100 个 task,第一个 stage 有 50 个 task,总共还是有 10 个 Executor(Executor CPU 个数为 1),每个 Executor 执行 5 个 task。那么原本使用未经优化的 HashShuffleManager 时,每个 Executor 会产生 500 个磁盘文件,所有 Executor 会产生 5000 个磁盘文件的。但是此时经过优化之后,每个 Executor 创建的磁盘文件的数量的计算公式为:cpu core的数量 * 下一个stage的task数量,也就是说,每个 Executor 此时只会创建 100 个磁盘文件,所有 Executor 只会创建 1000 个磁盘文件。

这个功能优点明显,但为什么 Spark 一直没有在基于 Hash Shuffle 的实现中将功能设置为默认选项呢,官方给出的说法是这个功能还欠稳定。

优化后的 HashShuffleManager 工作原理如下图所示:

图片

基于 Hash 的 Shuffle 机制的优缺点

优点

  • 可以省略不必要的排序开销。

  • 避免了排序所需的内存开销。

缺点

  • 生产的文件过多,会对文件系统造成压力。

  • 大量小文件的随机读写带来一定的磁盘开销。

  • 数据块写入时所需的缓存空间也会随之增加,对内存造成压力。

3 spark SortShuffle流程

  • 普通运行机制
  • bypass 运行机制,当 shuffle read task 的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认为 200),就会启用 bypass 机制;
  • Tungsten Sort 运行机制,开启此运行机制需设置配置项 spark.shuffle.manager=tungsten-sort。开启此项配置也不能保证就一定采用此运行机制

具体shuffle过程如下图所示:

     在该模式下,数据会先写入一个数据结构,reduceByKey写入Map,一边通过Map局部聚合,一边写入内存。Join算子写入ArrayList直接写入内存中。然后需要判断是否达到阈值,如果达到就会将内存数据结构的数据写入到磁盘,清空内存数据结构。

     在溢写磁盘前,先根据key进行排序,排序过后的数据,会分批写入到磁盘文件中。默认批次为10000条,数据会以每批一万条写入到磁盘文件。写入磁盘文件通过缓冲区溢写的方式,每次溢写都会产生一个磁盘文件,也就是说一个Task过程会产生多个临时文件

     最后在每个Task中,将所有的临时文件合并,这就是merge过程,此过程将所有临时文件读取出来,一次写入到最终文件。意味着一个Task的所有数据都在这一个文件中。同时单独写一份索引文件,标识下游各个Task的数据在文件中的索引,start offset和end offset。 

bypassShuffle和SortShuffle的区别就是不对数据排序。

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

1)shuffle reduce task数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值,默认为200。

2)不是聚合类的shuffle算子(比如reduceByKey不行,reducebykey有预聚合操作)。

此时task会为每个reduce端的task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。

该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,shuffle read的性能会更好。

而该机制与普通SortShuffleManager运行机制的不同在于:

  • 第一,磁盘写机制不同;
  • 第二,不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。

4 Tungsten Sort Shuffle 运行机制

Tungsten Sort 是对普通 Sort 的一种优化,Tungsten Sort 会进行排序,但排序的不是内容本身,而是内容序列化后字节数组的指针(元数据),把数据的排序转变为了指针数组的排序,实现了直接对序列化后的二进制数据进行排序。由于直接基于二进制数据进行操作,所以在这里面没有序列化和反序列化的过程。内存的消耗大大降低,相应的,会极大的减少的 GC 的开销。

Spark 提供了配置属性,用于选择具体的 Shuffle 实现机制,但需要说明的是,虽然默认情况下 Spark 默认开启的是基于 SortShuffle 实现机制,但实际上,参考 Shuffle 的框架内核部分可知基于 SortShuffle 的实现机制与基于 Tungsten Sort Shuffle 实现机制都是使用 SortShuffleManager,而内部使用的具体的实现机制,是通过提供的两个方法进行判断的:

对应非基于 Tungsten Sort 时,通过 SortShuffleWriter.shouldBypassMergeSort 方法判断是否需要回退到 Hash 风格的 Shuffle 实现机制,当该方法返回的条件不满足时,则通过 SortShuffleManager.canUseSerializedShuffle 方法判断是否需要采用基于 Tungsten Sort Shuffle 实现机制,而当这两个方法返回都为 false,即都不满足对应的条件时,会自动采用普通运行机制。

因此,当设置了 spark.shuffle.manager=tungsten-sort 时,也不能保证就一定采用基于 Tungsten Sort 的 Shuffle 实现机制。

要实现 Tungsten Sort Shuffle 机制需要满足以下条件:

  • Shuffle 依赖中不带聚合操作或没有对输出进行排序的要求。
  • Shuffle 的序列化器支持序列化值的重定位(当前仅支持 KryoSerializer Spark SQL 框架自定义的序列化器)。
  • Shuffle 过程中的输出分区个数少于 16777216 个。

实际上,使用过程中还有其他一些限制,如引入 Page 形式的内存管理模型后,内部单条记录的长度不能超过 128 MB (具体内存模型可以参考 PackedRecordPointer 类)。另外,分区个数的限制也是该内存模型导致的。

所以,目前使用基于 Tungsten Sort Shuffle 实现机制条件还是比较苛刻的。

小结

Shuffle 过程本质上都是将 Map 端获得的数据使用分区器进行划分,并将数据发送给对应的 Reducer 的过程。

shuffle作为处理连接map端和reduce端的枢纽,其shuffle的性能高低直接影响了整个程序的性能和吞吐量。map端的shuffle一般为shuffle的Write阶段,reduce端的shuffle一般为shuffle的read阶段。Hadoop和spark的shuffle在实现上面存在很大的不同,spark的shuffle分为两种实现,分别为HashShuffle和SortShuffle,

HashShuffle又分为普通机制和合并机制,普通机制因为其会产生M*R个数的巨量磁盘小文件而产生大量性能低下的Io操作,从而性能较低,因为其巨量的磁盘小文件还可能导致OOM,HashShuffle的合并机制通过重复利用buffer从而将磁盘小文件的数量降低到Core*R个,但是当Reducer 端的并行任务或者是数据分片过多的时候,依然会产生大量的磁盘小文件。

SortShuffle也分为普通机制和bypass机制,普通机制在内存数据结构(默认为5M)完成排序,会产生2M个磁盘小文件。而当shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值。或者算子不是聚合类的shuffle算子(比如reduceByKey)的时候会触发SortShuffle的bypass机制,SortShuffle的bypass机制不会进行排序,极大的提高了其性能

在Spark 1.2以前,默认的shuffle计算引擎是HashShuffleManager,因为HashShuffleManager会产生大量的磁盘小文件而性能低下,在Spark 1.2以后的版本中,默认的ShuffleManager改成了SortShuffleManager。SortShuffleManager相较于HashShuffleManager来说,有了一定的改进。主要就在于,每个Task在进行shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件,因此每个Task就只有一个磁盘文件。在下一个stage的shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的部分数据即可。

5 SortShuffle具体详解

5.1 map阶段

    Map 阶段最终生产的数据会以中间文件的形式物化到磁盘中,这些中间文件就存储在 spark.local.dir 设置的文件目录里。中间文件包含两种类型:一类是后缀为 data 的数据文件,存储的内容是 Map 阶段生产的待分发数据;另一类是后缀为 index 的索引文件,它记录的是数据文件中不同分区的偏移地址。这里的分区是指 Reduce 阶段的分区,因此,分区数量与 Reduce 阶段的并行度保持一致。这里需要注意,Map 阶段每一个 Task 的执行流程都是一样的,每个 Task 最终都会生成一个数据文件和一个索引文件。因此,数据文件的数量与 Map 阶段的并行度保持一致。换句话说,有多少个 Task,Map 阶段就会生产相应数量的数据文件和索引文件。

   Map Task 会把每条数据记录和它的目标分区,放到一个特殊的数据结构里,这个数据结构叫做“PartitionedPairBuffer”,每条数据记录应该分发到哪个目标分区,是由 Key 的哈希值决定的。

 “PartitionedPairBuffer”,它本质上就是一种数组形式的缓存结构。它是怎么存储数据记录的呢?

以以下例子为例

flowers.txt 文件:


黄色花朵
紫色花朵
红色花朵
橙色花朵
青色花朵
黄色花朵
紫色花朵
橙色花朵
青色花朵
......

需求:按颜色归类再把归类后的花朵放到相应桌子上

//加载花朵文件
val flowers = spark.sparkContext.textFile("flowers.txt")
//给5个小同学分发花朵
val flowersForKids = flowers.coalesce(5)
//按照花朵颜色对每个颜色标1成对。形成每条数据记录
val flowersKV = flowersForKids.map((_, 1))
//大家先各自按颜色归类,然后再把归类后的花朵放到相应的课桌上 
flowersKV.groupByKey.collect

基于 pairRDD 的 Key,也就是花朵的颜色,Map Task 就可以计算每条数据记录在 Reduce 阶段的目标分区,目标分区也就是课桌。在制定的策略中,哪种花放到哪张桌子是事先商定好的,但在 Spark 中,每条数据记录应该分发到哪个目标分区,是由 Key 的哈希值决定的。

每条数据记录都会占用数组中相邻的两个元素空间,第一个元素是(目标分区,Key),第二个元素是 Value。假设 PartitionedPairBuffer 的大小是 4,也就是最多只能存储 4 条数据记录。那么,如果我们还以数据分区 0 为例,小红的前 4 枚花朵在 PartitionedPairBuffer 中的存储状态就会如下所示。

 对我们来说,最理想的情况当然是 PartitionedPairBuffer 足够大,大到足以容纳 Map Task 所需处理的所有数据。不过,每个 Task 分到的内存空间是有限的,PartitionedPairBuffer 自然也不能保证能容纳分区中的所有数据。因此,Spark 需要一种计算机制,来保障在数据总量超出可用内存的情况下,依然能够完成计算。这种机制就是:排序、溢出、归并。

就拿大小为 4 的 PartitionedPairBuffer 来说,数据分区 0 里面有 16 朵花,对应着 16 条数据记录,它们至少要分 4 批才能依次完成处理。在处理下一批数据之前,Map Task 得先把 PartitionedPairBuffer 中已有的数据腾挪出去,腾挪的方式简单粗暴,Map Task 直接把数据溢出到磁盘中的临时文件。不过,在溢出之前,对于 PartitionedPairBuffer 中已有的数据,Map Task 会先按照数据记录的第一个元素【参考上图】,也就是目标分区 + Key 进行排序。也就是说,尽管数据暂时溢出到了磁盘,但是临时文件中的数据也是有序的。就这样,PartitionedPairBuffer 腾挪了一次又一次,数据分区 0 里面的花朵处理了一批又一批,直到所有的花朵都被处理完。

分区 0 有 16 朵花,PartitionedPairBuffer 的大小是 4,因此,PartitionedPairBuffer 总共被腾挪了 3 次,生成了 3 个临时文件,每个临时文件中包含 4 条数据记录。16 条数据,有 12 条分散在 3 个文件中,还有 4 条缓存在 PartitionedPairBuffer 里。到此为止,我们离 Map 阶段生产的、用于在网络中分发数据的中间文件仅有一步之遥了。还记得吗?Map 阶段生产的中间文件有两类,一类是数据文件,另一类是索引文件。分散在 3 个临时文件和 PartitionedPairBuffer 里的数据记录,就是生成这两类文件的输入源。最终,Map Task 用归并排序的算法,将 4 个输入源中的数据写入到数据文件和索引文件中去,如下图所示。

以上就是map阶段的整个过程,虽然 Map 阶段的计算步骤很多,但其中最主要的环节可以归结为 4 步:

  • 对于分片中的数据记录,逐一计算其目标分区,并将其填充到 PartitionedPairBuffer;
  •  PartitionedPairBuffer 填满后,如果分片中还有未处理的数据记录,就对 Buffer 中的数据记录按(目标分区 ID,Key)进行排序,将所有数据溢出到临时文件,同时清空缓存;
  •  重复步骤 1、2,直到分片中所有的数据记录都被处理;
  • 对所有临时文件和 PartitionedPairBuffer 归并排序,最终生成数据文件和索引文件。

接下来,我们来分析一下 reduceByKey 的 Map 阶段计算,相比 groupByKey 有何不同。就 Map 端的计算步骤来说,reduceByKey 与刚刚讲的 groupByKey 一样,都是先填充内存数据结构,然后排序溢出,最后归并排序。区别在于,在计算的过程中,reduceByKey 采用一种叫做 PartitionedAppendOnlyMap 的数据结构来填充数据记录。这个数据结构是一种 Map,而 Map 的 Value 值是可累加、可更新的。因此,PartitionedAppendOnlyMap 非常适合聚合类的计算场景,如计数、求和、均值计算、极值计算等等。

 在上图中,4 个 KV 对的 Value 值,是扫描到数据分区 0 当中青色花朵之前的状态。在 PartitionedAppendOnlyMap 中,由于 Value 是可累加、可更新的,因此这种数据结构可以容纳的花朵数量一定比 4 大。因此,相比 PartitionedPairBuffer,PartitionedAppendOnlyMap 的存储效率要高得多,溢出数据到磁盘文件的频率也要低得多。以此类推,最终合并的数据文件也会小很多。依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸,我们就能大幅降低了 Shuffle 过程中的磁盘和网络开销。事实上,相比 groupByKey、collect_list 这些收集类算子,聚合类算子(reduceByKey、aggregateByKey 等)在执行性能上更占优势。因此,我们要避免在聚合类的计算需求中,引入收集类的算子。虽然这种做法不妨碍业务逻辑实现,但在性能调优上可以说是大忌。

5.2 Reduce阶段

由上面可以看出map阶段实际上就是 Shuffle 过程中的数据分类及分发,而reduce阶段实际上就是shuffle过程中主动地从 Map 端的中间文件中拉取数据。类似于一个push过程一个是pull过程,map阶段的使命就是为了给reduce阶段准备数据,其生成的结果就是redcue阶段的数据源。

每个 Map Task 都会生data数据中间文件,文件中的分区数与 Reduce 阶段的并行度一致。换句话说,每个 Map Task 生成的数据文件,都包含所有 Reduce Task 所需的部分数据。因此,任何一个 Reduce Task 要想完成计算,必须先从所有 Map Task 的中间文件里去拉取属于自己的那部分数据。索引文件正是用于帮助判定哪部分数据属于哪个 Reduce TaskReduce Task 通过网络拉取中间文件的过程,实际上就是不同 Stages 之间数据分发、交换的过程。

6 小 结

  •  1)以终为始:map阶段的最终目的就是为了给reduce阶段准备数据。
  •  2)一个task形成一个数据文件,一个task消费一个分区数据。后缀为 data 的数据文件
  •  3)一个task形成的数据文件会对应一个索引文件【后缀为 index 的索引文件】。该索引文件通过start offset和end offset来标记下游redcue阶段每个task将来拉取哪部分数据,该索引是用来标记reduce阶段分区数据的,因此改标记的分区数是和下游reduce阶段的分区数是一致的.
  •  4)一个task在形成data数据文件之前会进行溢写,溢写会分批次写入到磁盘文件,默认是每次10000条数据作为buffle写入到磁盘文件,每次溢写都会产生一个临时文件,注意临时文件是会删除的,因此一个data数据文件的形成会产生多个临时文件。
  • 5)map阶段采用的数据结构叫PartitionedPairBuffer,本质上就是一种数组形式的缓存结构,每条数据记录都会占用数组中相邻的两个元素空间,第一个元素是(目标分区,Key),第二个元素是 Value,目标分区的计算是通过key的hash值来计算的,也就是将每个key都进行hash取余计算,根据结果最终决定key进入哪个分区。
  • 6) PartitionedPairBuffer 内存有限,为了能够保证计算分区的所有数据,Spark采用了排序、溢写、合并的机制来解决该问题。
  • 7)在数据溢出之前,对于 PartitionedPairBuffer 中已有的数据,Map Task 会先按照数据记录的第一个元素目标分区 + Key 进行排序,保证数据在溢写到磁盘的时候是分区内有序及分区间有序,注意采用sortShuffle的方式时,只有满足在shuffleDependency里面aggeragator或者sort这两个字段有效时,才会根据partitionid和key排序,否则只根据partitionid排序。如不会按照key排序的算子有repartition
  • 8)对溢写的临时文件,Spark会进行归并排序,形成最终的数据文件及索引文件。数据文件的输入源是临时文件及 PartitionedPairBuffer中的数据,也就是说最终归并的就是这两类数据源。
  • 9)当满足bypass机制的条件时会回退到hashshuffle

疑问

(1)map端做归并排序的目的是什么?

归并排序主要是保证运行的稳定性,因为归并排序可以通过磁盘来做,也就是用External Sorter来做这事,这样不会因为内存受限而频繁地oom。

(2)groupByKey和reduceByKey是否都不需要引入排序操作?

在Spark的byPass机制下,groupByKey可以通过设置spark.shuffle.sort.bypassMergeThreshold从而跳过排序操作,但是,reduceByKey不行,因为它有Aggregate聚合操作。

 (3) 为什么 Spark 放弃了 HashShuffle ,选择了 Sorted-Based Shuffle?

原因是Spark最根本最迫切要解决的是在shuffle过程中产生大量小文件的问题,大量的小文件严重制约了Spark的性能及水平扩展能力,所以 Spark 必须要解决这个问题,减少 Mapper 端 ShuffleWriter 产生的文件数量,这样便可以让 Spark 从几百台集群的规模瞬间变成可以支持几千台,甚至几万台集群的规模。但使用 Sorted-Based Shuffle 也有缺点,其缺点是它强制要求数据在 Mapper 端必须先进行排序,所以导致它排序的速度有点慢。好在出现了 Tungsten-Sort Shuffle ,它对排序算法进行了改进,优化了排序的速度。Tungsten-Sort Shuffle 已经并入了 Sorted-Based Shuffle,Spark 的引擎会自动识别程序需要的是 Sorted-Based Shuffle,还是 Tungsten-Sort Shuffle。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值