Shuffle 的核心要点
1.ShuffleMapStage 与 ResultStage
- 在划分 stage 时,最后一个 stage 称为 finalStage,它本质上是一个
ResultStage
对象,前面的所有 stage 被称为ShuffleMapStage
。 - ShuffleMapStage 的结束伴随着 shuffle 文件的写磁盘,每个Stage的开始伴随着从磁盘中读取shuffle文件
- ResultStage 基本上对应代码中的 action 算子,即将一个函数应用在 RDD 的各个 partition的数据集上,意味着一个 job 的运行结束。
- 因为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 线程。
下游有 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,那么都建议开启这个选项。
优化后的HashShuffle:
- 同一个Core并发的MapTask共用同一个Buffer
- MapTask
溢写文件个数 = cpu Core 个数 * ReduceTask个数
- 缓解了未优化的小文件压力,但不彻底
2.3 终极优化
- 一个core只产生一个文件,附带一个索引文件;
- 此优化方案是现在用的SortShuffle
ShuffleStage的读写
shuffleStage分为 ShuffleMapStage和 ShuffleReduceStage
ShuffleMapStage写磁盘, ShuffleReduceStage读取磁盘;
ShuffleManager
其实就是研究上游是如何往磁盘写的,在上面已经图示过了,这边看代码!
通过ShuffleManager写出
ShuffleManager中getWriter方法中,会根据不同的Handler获取不同的Writer,因此不同的Handler的写方式是不一样的!那么handler是如何获取的呢?
针对不同的条件可以获取不同的Handler:(1)获取BypassMergeSortShuffleHandler结论:
- map端不能有预聚合
- 下游分区数,即 reduceTask数量 <= 200(可配置)
(2)获取SerializedShuffleHandler
有序列化的能力,可以将对象序列化后保存到内存;
使用条件:
- 序列化规则支持重定位操作(Java序列化不支持,Kryo序列化框架支持)
- Map端不能有预聚合
- 下游分区数必须<= 16777216
(3)获取BaseShuffleHandler
其他情况
总结
SortShuffle 解析
SortShuffleManager 的运行机制主要分成三种:
- 普通运行机制;
- bypass 运行机制
当 shuffle read task 的数量小于等spark.shuffle.sort.bypassMergeThreshold参数的值时(默认为 200),就会启用 bypass 机制; - Tungsten Sort 运行机制
开启此运行机制需设置配置项 spark.shuffle.manager=tungsten-sort。开启此项配置也不能保证就一定采用此运行机制(后面会解释)。
普通 SortShuffle
普通的SortShuffle过程指的就是
BaseShuffleHandler的SortShuffleWriter
的写磁盘过程
- 在该模式下,数据会先写入一个内存数据结构中,此时根据不同的 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 算子。
过程
- 每个 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 实现机制条件还是比较苛刻的。