Shuffle 的核心要点

1.ShuffleMapStage 与 ResultStage

spark create temporary view 落盘_序列化

  • 在划分 stage 时,最后一个 stage 称为 finalStage,它本质上是一个ResultStage对象,前面的所有 stage 被称为ShuffleMapStage
  • ShuffleMapStage 的结束伴随着 shuffle 文件的写磁盘,每个Stage的开始伴随着从磁盘中读取shuffle文件
  • ResultStage 基本上对应代码中的 action 算子,即将一个函数应用在 RDD 的各个 partition的数据集上,意味着一个 job 的运行结束。

shuffle为什么一定会落盘呢?

  • 因为shuffle下游的一个分区会依赖上游多个分区,这样一来,必须得等到上游所有分区执行完毕,下游分区才能获取完整数据进行运算;
  • 如果上游某些分区先运算完毕,在内存中等待,会造成内存挤压!所以必须写到磁盘中进行等待

那么除了上游预聚合减少shuffle数据量可以提高shuffle性能以外还有什么方法呢?

2. Spark Shuffle 历程

Spark Shuffle 分为两种:

  • 一种是基于 Hash 的 Shuffle;
  • 另一种是基于 Sort 的 Shuffle。

先介绍下它们的发展历程,有助于我们更好的理解 Shuffle

发展过程

  • Spark 1.1 之前, Spark 中只实现了一种 Shuffle 方式,即基于 Hash 的 Shuffle 。
  • Spark 1.1 版本中引入了基于 Sort 的 Shuffle 实现方式
  • Spark 1.2 版本之后,默认的实现方式从基于 Hash 的 Shuffle 修改为基于 Sort 的 Shuffle 实现方式,即使用的 ShuffleManager 从默认的 hash 修改为 sort。
  • 在 Spark 2.0 版本中, Hash Shuffle 方式己经不再使用。

HashShuffle

Spark 之所以一开始就提供基于 Hash 的 Shuffle 实现机制,其主要目的之一就是为了避免不需要的排序,大家想下 Hadoop 中的 MapReduce,是将 sort 作为固定步骤,有许多并不需要排序的任务,MapReduce 也会对其进行排序,造成了许多不必要的开销。

在基于 Hash 的 Shuffle 实现方式中,每个 Mapper 阶段的 Task 会为每个 Reduce 阶段的 Task 生成一个文件,通常会产生大量的文件(即对应为 M*R 个中间文件,其中, M 表示 Mapper 阶段的 Task 个数, R 表示 Reduce 阶段的 Task 个数) 伴随大量的随机磁盘 I/O 操作与大量的内存开销。

2. HashShuffle 解析

2.1 未优化的 HashShuffle

https://www.bilibili.com/video/BV11A411L7CK?p=147

这里我们先明确一个假设前提:每个 Executor 只有 1 个 CPU core,也就是说,无论这个 Executor 上分配多少个 task 线程,同一时间都只能执行一个 task 线程。

spark create temporary view 落盘_数据_02

下游有 3 个 Reducer,当MapTask运算结束 进入shuffle 需要落盘,如果MapTask只将数据写入一个文件的话,下游ReduceTask 就不知道从文件哪个位置获取属于自己分区的数据;因此MapTask 在落盘的时候,写入ReduceTask个数 个文件中,这样Reducer 会在每个 Task 中把属于自己类别的数据收集过来,进行聚合运算;

总结 未优化的HashShuffle的特点:

  • MapTask 溢写磁盘时 文件个数 = mapTask reduceTask
  • 会产生大量小文件

2.2 优化后的 HashShuffle

1.并发MapTask合并机制

  • 优化的 HashShuffle 过程就是启用合并机制
  • 合并机制就是对于同一个CPU Core 并发的MapTask 共用同一个 buffer
  • 开启合并机制的配置是spark.shuffle.consolidateFiles。该参数默认值为 false,将其设置为 true 即可开启优化机制。
  • 通常来说,如果我们使用 HashShuffleManager,那么都建议开启这个选项。

spark create temporary view 落盘_spark_03

优化后的HashShuffle:

  • 同一个Core并发的MapTask共用同一个Buffer
  • MapTask溢写文件个数 = cpu Core 个数 * ReduceTask个数
  • 缓解了未优化的小文件压力,但不彻底

2.3 终极优化

spark create temporary view 落盘_序列化_04

  • 一个core只产生一个文件,附带一个索引文件;
  • 此优化方案是现在用的SortShuffle

ShuffleStage的读写

shuffleStage分为 ShuffleMapStage和 ShuffleReduceStage
ShuffleMapStage写磁盘, ShuffleReduceStage读取磁盘;

ShuffleManager

其实就是研究上游是如何往磁盘写的,在上面已经图示过了,这边看代码!

spark create temporary view 落盘_spark_05


通过ShuffleManager写出

spark create temporary view 落盘_运行机制_06


ShuffleManager中getWriter方法中,会根据不同的Handler获取不同的Writer,因此不同的Handler的写方式是不一样的!那么handler是如何获取的呢?

spark create temporary view 落盘_数据_07

针对不同的条件可以获取不同的Handler:

spark create temporary view 落盘_运行机制_08

(1)获取BypassMergeSortShuffleHandler

spark create temporary view 落盘_序列化_09


spark create temporary view 落盘_序列化_10


结论:

  1. map端不能有预聚合
  2. 下游分区数,即 reduceTask数量 <= 200(可配置)

(2)获取SerializedShuffleHandler

有序列化的能力,可以将对象序列化后保存到内存;

spark create temporary view 落盘_序列化_11


使用条件:

  • 序列化规则支持重定位操作(Java序列化不支持,Kryo序列化框架支持)
  • Map端不能有预聚合
  • 下游分区数必须<= 16777216

(3)获取BaseShuffleHandler

其他情况

总结

spark create temporary view 落盘_运行机制_12

SortShuffle 解析

SortShuffleManager 的运行机制主要分成三种:

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

普通 SortShuffle

普通的SortShuffle过程指的就是 BaseShuffleHandler的SortShuffleWriter 的写磁盘过程

spark create temporary view 落盘_数据_13

  • 在该模式下,数据会先写入一个内存数据结构中,此时根据不同的 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 个磁盘文件。

bypass SortShuffle

应用场景:

  • Reducer 端任务数比较少的情况下,基于 Hash Shuffle 实现机制明显比基于 Sort Shuffle 实现机制要快,因此基于 Sort Shuffle 实现机制提供了一个带 Hash 风格的回退方案,就是 bypass 运行机制。

使用条件
对于 Reducer 端任务数少于配置spark.shuffle.sort.bypassMergeThreshold设置的个数时,使用带 Hash 风格的回退计划。

bypass 运行机制的触发条件

  • shuffle map task 数量小于spark.shuffle.sort.bypassMergeThreshold=200参数的值。
  • 不是聚合类的 shuffle 算子。

spark create temporary view 落盘_序列化_14

过程

  • 每个 task 会为每个下游 task 都创建一个临时磁盘文件,并将数据按 key 进行 hash 然后根据 key 的 hash 值,将 key 写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。
  • 该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好。
  • 而该机制与普通 SortShuffleManager 运行机制的不同在于:第一,磁盘写机制不同;第二,不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。

SortShuffle和byPassSortShuffle 最终都是形成一个数据文件一个索引文件,只不过方式不同,SortShuffle是通过排序,ByPass是先单独往多个文件写,再将这些文件合并,从而避免排序,因此Bypass会产生大量的中间小文件,所以这也是为什么ByPassSortShuffle的使用要求是ReduceTask数量不能大于200

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 实现机制条件还是比较苛刻的。