Shuffle详解
以Shuffle为边界,Spark将一个Job划分为不同的Stage。Spark的Shuffle分为Write和Read两个阶段,分属于两个不同的Stage,前者是Parent Stage的最后一步,后者是Child Stage的第一步
Spark 的 Stage 分为两种
- ResultStage。负责返回计算结果
- ShuffleMapStage。其他的均为ShuffleMapStage
如果按照 map 端和 reduce 端来分析的话:
- ShuffleMapStage可以即是map端任务,又是reduce端任务
- ResultStage只能充当reduce端任务
Spark Shuffle的流程简单抽象为以下几步:
-
Shuffle Write
-
Map side combine (if needed)
-
Write to local output file
-
Shuffle Read
-
Block fetch
-
Reduce side combine
-
Sort (if needed)
-
Shuffle涉及到了本地磁盘(非hdfs)的读写和网络的传输类的磁盘IO以及序列化等耗时操作。
Spark的Shuffle经历了Hash、Sort、Tungsten-Sort(堆外内存)三阶段发展历程:
- 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退出历史舞台
Shuffle的发展历程简述
1、Hash Base Shuffle V1
-
Shuffle Map Task过程按照 Hash 的方式重组 Partition 的数据,不进行排序。每个Map Task需要为每个下游的Reduce Task创建一个单独的文件
-
Shuffle过程中会生成海量的小文件。同时打开过多文件、伴随大量的随机磁盘 I/O 操作与大量的内存开销
-
-
存在问题:
-
生成大量文件,占用文件描述符,同时引入 DiskObjectWriter 带来的 Writer Handler 的缓存也非常消耗内存
-
如果在 Reduce Task 时需要合并操作的话,会把数据放在一个 HashMap 中进行合并,若数据量较大,很容易引发 OOM
-
2、Hash Bash Shuffle V2--File Consolidation
- 针对第一问题,spark引入了File Consolidation机制。一个 Executor 上所有的 Map Task 生成的分区文件只有一份,即将所有的 Map Task 相同的分区文件合并,这样每个 Executor 上最多只生成 N 个分区文件
- 允许不同的task复用同一批磁盘文件,有效将多个task的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升shuffle write的性能。一定程度上解决了Hash V1中的问题,但不彻底。
- Hash Shuffle 规避了排序,提高了性能;
3、sort Base Shuffles
-
Sort Base Shuffle大大减少了shuffle过程中产生的文件数,提高Shuffle的效率;
-
每个 Task 不会为后续的每个 Task 创建单独的文件,而是将所有对结果写入同一个文件。该文件中的记录首先是按照Partition Id 排序,每个 Partition 内部再按照 Key 进行排序,Map Task 运行期间会顺序写每个 Partition 的数据,同时生成一个索引文件记录每个 Partition 的大小和偏移量
-
在 Reduce 阶段,Reduce Task 拉取数据做 Combine 时不再采用 HashMap,而是采用ExternalAppendOnlyMap,该数据结构在做 Combine 时,如果内存不足,会刷写磁盘,避免大数据情况下的 OOM
-
Sort Shuffle 解决了 Hash Shuffle 的所有弊端,但是因为需要其 Shuffle 过程需要对记录进行排序,所以在性能上有所损失。
-
Tungsten-Sort Based Shuffle / Unsafe Shuffle
-
它的做法是将数据记录用二进制的方式存储,直接在序列化的二进制数据上 Sort 而不是在 Java 对象上,这样一方面可以减少内存的使用和 GC 的开销,另一方面避免 Shuffle 过程中频繁的序列化以及反序列化。在排序过程中,它提
供 cache-efficient sorter,使用一个 8 bytes 的指针,把排序转化成了一个指针数组的排序,极大的优化了排序性能。 -
限制:
-
Shuffle 阶段不能有 aggregate 操作
-
分区数不能超过一定大小(2^24-1,这是可编码的最大 Parition Id),
-
像 reduceByKey 这类有 aggregate 操作的算子是不能使用Tungsten-Sort Based Shuffle,它会退化采用 Sort Shuffle
-
SortShuffleV2
- 从 Spark1.6.0 开始,把 Sort Shuffle 和 Tungsten-Sort Based Shuffle 全部统一到 Sort Shuffle 中,如果检测到满足Tungsten-Sort Based Shuffle 条件会自动采用 Tungsten-Sort Based Shuffle,否则采用 Sort Shuffle
- 从Spark2.0 开始,Spark 把 Hash Shuffle 移除, Spark2.x 中只有一种 Shuffle,即为 Sort Shuffle
Shuffle Writer
在DAG阶段以shuffle为界,划分stage,上游stage做map task,每个map task将计算结果数据分成多份,每一份对应到下游stage的每个partition中,并将其临时写到磁盘,该过程叫做shuffle write;
-
ShuffleWriter(抽象类),有3个具体的实现:
- SortShuffleWriter。sortShulleWriter 需要在 Map 排序
- UnsafeShuffleWriter。使用 Java Unsafe 直接操作内存,避免Java对象多余的开销和GC 延迟,效率高
- BypassMergeSortShuffleWriter。和Hash Shuffle的实现基本相同,区别在于map task输出汇总一个文件,同时还会产生一个index file
-
ShuffleWriter 有各自的应用场景。分别如下:
- 不满足以下条件使用 SortShuffleWriter
- 没有map端聚合,RDD的partitions分区数小于16,777,216,且 Serializer支持 relocation,使用UnsafeShuffleWriter
- 没有map端聚合操作 且 RDD的partition分区数小于200个,使用 BypassMergerSortShuffleWriter
-
bypass运行机制
-
shuffle map task数量 <= spark.shuffle.sort.bypassMergeThreshold (缺省200)
-
不是聚合类的shuffle算子
-
Bypass机制 Writer 流程如下:
-
每个Map Task为每个下游 reduce task 创建一个临时磁盘文件,并将数据按key进行hash然后根据hash值写入内存缓冲,缓冲写满之后再溢写到磁盘文件
-
最后将所有临时磁盘文件都合并成一个磁盘文件,并创建索引文件
-
Bypass方式的Shuffle Writer机制与Hash Shuffle是类似的,在shuffle过程中会创建很多磁盘文件,最后多了一个磁盘文件合并的过程。Shuffle Read的性能会更好
-
Bypass方式与普通的Sort Shuffle方式的不同在于
-
磁盘写机制不同
-
根据key求hash,减少了数据排序操作,提高了性能
-
-
Shuffle Writer 流程
- 数据先写入一个内存数据结构中:不同的shuffle算子,可能选用不同的数据结构(reduceByKey会选用Map结构;join类的shuffle算子,选用Array数据结构)
- 检查是否达到内存阈值。
- 数据排序:在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认是10000条
- 数据写入缓冲区:写入磁盘文件是通过Java的 BufferedOutputStream 实现的
- 重复写多个临时文件:一个 Task 将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,会产生多个临时文件
- 临时文件合并:将所有的临时磁盘文件进行合并,这就是merge过程。
- 写索引文件:标识了下游各个 Task 的数据在文件中的 start offset 与 end offset
Shuffle MapOutputTracker
-
MapOutputTracker负责管理Writer和Reader的沟通.
-
Shuffle Writer会将中间数据保存到Block里面,然后将数据的位置发送给MapOutputTracker
-
Shuffle Reader通过向 MapOutputTracker 获取中间数据的位置之后,才能读取到数据。
-
Shuffle Reader 需要提供 shuffleId、mapId、reduceId 才能确定一个中间数据
- shuffleId,表示此次shuffle的唯一id
- mapId,表示map端 rdd 的分区索引,表示由哪个父分区产生的数据
- reduceId,表示reduce端的分区索引,表示属于子分区的那部分数据
-
MapOutputTracker在executor和driver端都存在:
- MapOutputTrackerMaster 和 MapOutputTrackerMasterEndpoint(负责通信) 存在于driver
- MapOutputTrackerWorker 存在于 executor 端
- MapOutputTrackerMaster 负责管理所有 shuffleMapTask 的输出数据,每个 shuffleMapTask 执行完后会把执行结果(MapStatus对象)注册到MapOutputTrackerMaster
- MapOutputTrackerMaster 会处理 executor 发送的 GetMapOutputStatuses 请求,并返回serializedMapStatus 给 executor 端
- MapOutputTrackerWorker 负责为 reduce 任务提供 shuffleMapTask 的输出数据信息(MapStatus对象)
- 如果MapOutputTrackerWorker在本地没有找到请求的 shuffle 的 mapStatus,则会向MapOutputTrackerMasterEndpoint 发送 GetMapOutputStatuses 请求获取对应的 mapStatus
Shuffle Reader
-
下游stage做reduce task,每个reduce task通过网络拉取上游stage中所有map task的指定分区结果数据,该过程叫做shuffle read
-
Map Task 执行完毕后会将文件位置、计算状态等信息封装到 MapStatus 对象中,再由本进程中的MapOutPutTrackerWorker 对象将其发送给Driver进程的MapOutPutTrackerMaster对象
-
Reduce Task开始执行之前会先让本进程中的 MapOutputTrackerWorker 向 Driver 进程中的MapOutputTrackerMaster 发动请求,获取磁盘文件位置等信息
-
当所有的Map Task执行完毕后,Driver进程中的 MapOutputTrackerMaster 就掌握了所有的Shuffle文件的信息。此时MapOutPutTrackerMaster会告诉MapOutPutTrackerWorker磁盘小文件的位置信息
-
完成之前的操作之后,由 BlockTransforService 去 Executor 所在的节点拉数据,默认会启动五个子线程。每次拉取的数据量不能超过48M
Hadoop Shuffle 与 Spark Shuffle 的区别
共同点:
- 二者从功能上看是相似的;从High Level来看,没有本质区别,实现(细节)上有区别
区别:
- Hadoop中有一个Map完成,Reduce便可以去fetch数据了,不必等到所有Map任务完成;而Spark的必须等到父stage完成,也就是父stage的 map 操作全部完成才能去fetch数据。这是因为spark必须等到父stage执行完,才能执行子stage,主要是为了迎合stage规则
- Hadoop的Shuffle是sort-base的,那么不管是Map的输出,还是Reduce的输出,都是partition内有序的,而spark不要求这一点
- Hadoop的Reduce要等到fetch完全部数据,才将数据传入reduce函数进行聚合,而 Spark是一边fetch一边聚合
Shuffle优化
Spark作业的性能主要就是消耗了shuffle过程,因为该环节包含了众多低效的IO操作:磁盘IO、序列化、网络数据传输等;如果要让作业的性能更上一层楼,就有必要对 shuffle 过程进行调优
开发过程中对 Shuffle 的优化:
- 减少Shuffle过程中的数据量
- 避免Shuffle
Shuffle 的优化主要是参数优化
-
优化一:调节 map 端缓冲区大小
- spark.shuffle.file.buffer 默认值为32K。调节map端缓冲的大小,避免频繁的磁盘IO操作,进而提升任务整体性能
-
优化二:调节 reduce 端拉取数据缓冲区大小
- spark.reducer.maxSizeInFlight 默认值为48M,设置shuffle read阶段buffer缓冲区大小,这个buffer缓冲决定了每次能够拉取多少数据
- 在内存资源充足的情况下,可适当增加参数的大小(如96m),减少拉取数据的次数及网络传输次数,进而提升性能
-
优化三:调节 reduce 端拉取数据重试次数及等待间隔
- spark.shuffle.io.maxRetries,默认值3。最大重试次数
- spark.shuffle.io.retryWait,默认值5s。每次重试拉取数据的等待间隔
- 一般调高最大重试次数,不调整时间间隔
-
优化四:调节 Sort Shuffle 排序操作阈值
- spark.shuffle.sort.bypassMergeThreshold,默认值为200
- 当使用SortShuffleManager时,如果的确不需要排序操作,建议将这个参数调大
-
优化五:调节 Shuffle 内存大小
- Spark给 Shuffle 阶段分配了专门的内存区域,这部分内存称为执行内存
- 如果内存充足,而且很少使用持久化操作,建议调高这个比例,给 shuffle 聚合操作更多内存,以避免由于内存不足导致聚合过程中频繁读写磁盘
- 合理调节该参数可以将性能提升10%左右
- 尽量减少shuffle次数
// 两次shuffle
rdd.map(...).repartition(1000).reduceByKey(_ + _, 3000)
// 一次shuffle
rdd.map(...).repartition(3000).reduceByKey(_ + _)
-
必要时主动shuffle,通常用于改变并行度,提高后续分布式运行速度
rdd.repartiton(largerNumPartition).map(…)… -
使用treeReduce & treeAggregate替换reduce & aggregate。数据量较大时,reduce & aggregate一次性聚合,shuffle量太大,而treeReduce & treeAggregate是分批聚合,更为保险。
思维导图