Spark 的Shuffle过程详解(待续...)

文章目录

  • 1.Shuffle的作用是什么?
  • 2. Spark中shuffle的运行时机
  • 3.Spark目前的ShuffleManage模式及处理机制
    • HashShuffle
      • 3.1 HashShuffle
        • 3.1.1 shuffle write
        • 3.1.2 shuffle read
        • 磁盘小文件过多带来的问题?
      • 3.2 优化后的HashShuffleManager
      • 3.3 两种HashShuffle的磁盘小文件数目的对比
    • SortShuffle
      • 3.4 普通运行机制
      • 3.5 bypass运行机制
  • 4.与MR中shuffle的不同之处
    • 4.1 spark中HashShuffle的shuffle write中没有分组和排序
    • 4.2 Spark中SortShuffle的普通运行机制和MR
    • 4.3 Spark中SortShuffle的bypass运行机制和MR
  • 5. Spark中内存管理和Shuffle参数调优
    • 5.1 shuffle的内存管理
      • 5.1.1 静态内存管理分布图
      • 5.1.2 统一内存管理分布图
    • 5.2 shuffle调优
  • 6. 磁盘小文件
    • 6.1 shuffle过程中,磁盘文件的寻址问题
    • 6.2 磁盘小文件寻址过程中容易OOM的地方

1.Shuffle的作用是什么?

Shuffle的中文解释为“洗牌操作”,可以理解成将集群中所有节点上的数据进行重新整合分类的过程。其思想来源于hadoop的mapReduce,Shuffle是连接map阶段和reduce阶段的桥梁。由于分布式计算中,每个阶段的各个计算节点只处理任务的一部分数据,若下一个阶段需要依赖前面阶段的所有计算结果时,则需要对前面阶段的所有计算结果进行重新整合和分类,这就需要经历shuffle过程。
在spark中,RDD之间的关系包含窄依赖和宽依赖,其中宽依赖涉及shuffle操作。因此在spark程序的每个job中,都是根据是否有shuffle操作进行阶段(stage)划分,每个stage都是一系列的RDD map操作。

2. Spark中shuffle的运行时机

shuffle过程只有在stage与stage之间才会运行,前一个stage可以看作是MR的MapTask;后面的stage可以看作是ReduceTask,而且stage的切分规则是根据RDD的宽窄依赖关系切分的,那么下面列出一些能够产生shuffle的算子

  • 去重
def distinct()
def distinct(numPartitions: Int)
  • 集合
def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)]
def groupBy[K](f: T => K, p: Partitioner):RDD[(K, Iterable[V])]
def groupByKey(partitioner: Partitioner):RDD[(K, Iterable[V])]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C): RDD[(K, C)]
  • 排序
def sortByKey(ascending: Boolean = true, numPartitions: Int = self.partitions.length): RDD[(K, V)]
def sortBy[K](f: (T) => K, ascending: Boolean = true, numPartitions: Int = this.partitions.length)(implicit ord: Ordering[K], ctag: ClassTag[K]): RDD[T]
  • 重分区
def coalesce(numPartitions: Int, shuffle: Boolean = false, partitionCoalescer: Option[PartitionCoalescer] = Option.empty)
def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null)
  • 集合或者表操作
def join[W](other: RDD[(K, W)], partitioner: Partitioner): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)], numPartitions: Int): RDD[(K, (V, W))]
def leftOuterJoin[W](other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]

3.Spark目前的ShuffleManage模式及处理机制

Spark程序中的Shuffle操作是通过shuffleManage对象进行管理。Spark目前支持的ShuffleMange模式主要有两种:HashShuffleMagnage 和SortShuffleManage
Shuffle操作包含当前阶段的Shuffle Write(存盘)和下一阶段的Shuffle Read(fetch),两种模式的主要差异是在Shuffle Write阶段,下面将着重介绍。

HashShuffle

3.1 HashShuffle

HashShuffle是根据task的计算结果的key值的hashcode%ReduceTask来决定放入哪一个区分,这样保证相同的数据一定放入一个分区,Hash Shuffle过程如下:
Spark 的Shuffle过程详解(待续...)_第1张图片

3.1.1 shuffle write

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


那么每个执行shuffle write的task,要为下一个stage创建多少个磁盘文件呢?很简单,下一个stage的task即Reduce Task有多少个,当前stage的每个task就要创建多少份磁盘文件。比如下一个stage总共有100个task,那么当前stage的每个task都要创建100份磁盘文件。如果当前stage有50个task,总共有10个Executor,每个Executor执行5个Task,那么每个Executor上总共就要创建500个磁盘文件,所有Executor上会创建5000个磁盘文件。由此可见,未经优化的shuffle write操作所产生的磁盘文件的数量是极其惊人的。


由此可以得到,磁盘小文件的个数=map Task * reduce Task

3.1.2 shuffle read

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

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

磁盘小文件过多带来的问题?

  1. Write阶段创建大量的写文件的对象
  2. read阶段就要进行多次网络通信,来拉取磁盘小文件
  3. read阶段创建大量的读文件对象

造成的影响,创建的对象过多,会导致JVM内存不足,JVM内存不足又会导致GC垃圾回收OOM。所以之后提出了合并机制的HashShuffle.

3.2 优化后的HashShuffleManager

因为上述HashShuffle存在着使GCOOM的风险,所以提出了优化后的HashShuffle。

下图说明了优化后的HashShuffleManager的原理。这里说的优化,是指我们可以设置一个参数,spark.shuffle.consolidateFiles。该参数默认值为false,将其设置为true即可开启优化机制。通常来说,如果我们使用HashShuffleManager,那么都建议开启这个选项。
Spark 的Shuffle过程详解(待续...)_第2张图片

开启consolidate机制之后,在shuffle write过程中,task就不是为下游stage的每个task创建一个磁盘文件了。此时会出现shuffleFileGroup的概念,每个shuffleFileGroup会对应一批磁盘文件,shuffleFileGroup与Executor进程分配的核数相同,磁盘文件的数量与下游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执行5个task。那么原本使用未经优化的HashShuffleManager时,每个Executor会产生500个磁盘文件,所有Executor会产生5000个磁盘文件的。但是此时经过优化之后,每个Executor创建的磁盘文件的数量的计算公式为:CPU core的数量 * 下一个stage的task数量。也就是说,每个Executor此时只会创建100个磁盘文件,所有Executor只会创建1000个磁盘文件。

由此可以得到,磁盘小文件的个数=core * reduce Task

3.3 两种HashShuffle的磁盘小文件数目的对比

  1. HashShuffle的磁盘小文件=map Task * reduce Task
  2. 优化后的HashShuffle磁盘小文件=core * reduce Task

两者不同的就是core数目和MapTask数目,那么到底是core数目多呢还是Map Task数目多呢?
一般来说,每一个core会分配2-3个Task来执行,所以肯定是优化后的HashShuffle磁盘小文件,但是为什么一个core要分配2-3个core来执行呢?

因为是核core就会有性能的高低,如果一个core分配一个task的话,他们这个计算时间就会由最慢的core的计算时间按来决定,这就造成了木桶效应;但若是一个核分配2-3个task,此时性能高的core优先处理好自己的task之后,它们会帮助性能差的core来处理它未来得及处理的task,这样就解决了木桶效应。(必须是同一节点上的core核和task)

SortShuffle

SortShuffleManager的运行机制主要分成两种,一种是普通运行机制,另一种是bypass运行机制。当shuffle read task(Reduce Task)的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认为200),就会启用bypass机制。

3.4 普通运行机制

下图说明了普通的SortShuffleManager的原理。在该模式下,数据会先写入一个内存数据结构中,此时根据不同的shuffle算子,可能选用不同的数据结构。如果是reduceByKey这种聚合类的shuffle算子,那么会选用Map数据结构,一边通过Map进行聚合,一边写入内存;如果是join这种普通的shuffle算子,那么会选用Array数据结构,直接写入内存。

其实这里和MR的运行过程十分相似,当数据写入内存之前也会进行“打标签”,当内存满了之后申请资源申请不下来时,会先进行排序,然后溢写。

在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。但是,这一个Task的整个过程只产生2个磁盘文件,一个是溢写的磁盘文件,一个是索引文件(其中标识了下游各个task的数据在文件中的start offset与end offset),因为当第一个磁盘写文件溢写完成后,剩下的每次产生的小文件都会与之前的文件进行合并,所以一直只会产生两个磁盘文件。这 , 就是merge过程


注意: 这个内存(Executor中内存)的数据结构的默认大小约为5M。那它为什么是约为5M呢?
因为Executor是一个JVM进程,而Spark只是一个框架,不能准确的控制JVM的资源情况,所以说这个初始值约等于5M,当这5M满了之后,此时监控此内存结构的监控对象会去申请资源(当前已用资源*2-5M),如果申请到了,则不会溢写,如果申请不到则会溢写。

Spark 的Shuffle过程详解(待续...)_第3张图片


由此可以得到,磁盘小文件的个数=map Task * 2

3.5 bypass运行机制

下图说明了bypass SortShuffleManager的原理。bypass运行机制的触发条件如下:

  1. shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数(200)的值。
  2. 不是聚合类的shuffle算子(比如reduceByKey)。

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

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

而该机制与普通SortShuffleManager运行机制的不同在于:第一,磁盘写机制不同;第二,不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。
Spark 的Shuffle过程详解(待续...)_第4张图片

4.与MR中shuffle的不同之处

4.1 spark中HashShuffle的shuffle write中没有分组和排序

首先,MR的计算思想就是,相同key值的数据为一组,每一组调用一次reduce()函数,但是spark中没有这个说法,所以HashShuffle没有分组和排序.

4.2 Spark中SortShuffle的普通运行机制和MR

Spark中SortShuffle的普通运行机制和MR的过程几乎是一模一样的,都有写入内存前的打标签、内存写满之后的排序、溢写到磁盘;但是有一点不同,就是SortShuffle的内存是约等于5M且动态变化的,而MR的内存是固定100M的(当然可以改配置文件来修改)

4.3 Spark中SortShuffle的bypass运行机制和MR

Spark中SortShuffle的bypass运行机制中没有排序,Spark shuffle默认是SortShuffle的bypass运行机制,因为它没有排序和分组,所以这也是比MR快的原因之一。

5. Spark中内存管理和Shuffle参数调优

5.1 shuffle的内存管理

Spark执行应用程序时,Spark集群会启动Driver和Executor两种JVM进程,Driver负责创建SparkContext上下文,提交任务,task的分发等。Executor负责task的计算任务,并将结果返回给Driver。同时需要为需要持久化的RDD提供储存。Driver端的内存管理比较简单,这里所说的Spark内存管理针对Executor端的内存管理。

Spark内存管理分为静态内存管理和统一内存管理Spark1.6之前使用的是静态内存管理,Spark1.6之后引入了统一内存管理。

静态内存管理中存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置。

统一内存管理与静态内存管理的区别在于储存内存和执行内存共享同一块空间,可以互相借用对方的空间。

Spark1.6以上版本默认使用的是统一内存管理,可以通过参数spark.memory.useLegacyMode 设置为true(默认为false)使用静态内存管理。

5.1.1 静态内存管理分布图

Spark 的Shuffle过程详解(待续...)_第5张图片

5.1.2 统一内存管理分布图

Spark 的Shuffle过程详解(待续...)_第6张图片

5.2 shuffle调优

spark.shuffle.file.buffer
默认值:32k
参数说明:该参数用于设置shuffle write task的BufferedOutputStream的buffer缓冲大小。将数据写到磁盘文件之前,会先写入buffer缓冲中,待缓冲写满之后,才会溢写到磁盘。
调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如64k,一定是成倍的增加),从而减少shuffle write过程中溢写磁盘文件的次数,也就可以减少磁盘IO次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升


spark.reducer.maxSizeInFlight
默认值:48m
参数说明:该参数用于设置shuffle read taskbuffer缓冲大小 而这个buffer缓冲决定了每次能够拉取多少数据。
调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如96m),从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升。

spark.shuffle.io.maxRetries
默认值:3
参数说明:shuffle read task从shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致作业执行失败。
调优建议:对于那些包含了特别耗时的shuffle操作的作业,建议增加重试最大次数(比如60次),以避免由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败。在实践中发现,对于针对超大数据量(数十亿~上百亿)的shuffle过程,调节该参数可以大幅度提升稳定性。
shuffle file not find taskScheduler不负责重试task,由DAGScheduler负责重试stage


spark.shuffle.io.retryWait

默认值:5s
参数说明:具体解释同上,该参数代表了每次重试拉取数据的等待间隔,默认是5s。
调优建议:建议加大间隔时长(比如60s),以增加shuffle操作的稳定性

spark.shuffle.memoryFraction
默认值:0.2
参数说明:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例,默认是20%。
调优建议:在资源参数调优中讲解过这个参数。如果内存充足,而且很少使用持久化操作,建议调高这个比例,给shuffle read的聚合操作更多内存,以避免由于内存不足导致聚合过程中频繁读写磁盘。在实践中发现,合理调节该参数可以将性能提升10%左右。


spark.shuffle.manager
默认值:sort|hash
参数说明:该参数用于设置ShuffleManager的类型。Spark 1.5以后,有三个可选项:hash、sort和tungsten-sort。HashShuffleManager是Spark 1.2以前的默认选项,但是Spark 1.2以及之后的版本默认都是SortShuffleManager了。tungsten-sort与sort类似,但是使用了tungsten计划中的堆外内存管理机制,内存使用效率更高。
调优建议:由于SortShuffleManager默认会对数据进行排序,因此如果你的业务逻辑中需要该排序机制的话,则使用默认的SortShuffleManager就可以;而如果你的业务逻辑不需要对数据进行排序,那么建议参考后面的几个参数调优,通过bypass机制或优化的HashShuffleManager来避免排序操作,同时提供较好的磁盘读写性能。这里要注意的是,tungsten-sort要慎用,因为之前发现了一些相应的bug。


spark.shuffle.sort.bypassMergeThreshold
默认值:200
参数说明:当ShuffleManager为SortShuffleManager时,如果shuffle read task的数量小于这个阈值(默认是200),则shuffle write过程中不会进行排序操作,而是直接按照未经优化的HashShuffleManager的方式去写数据,但是最后会将每个task产生的所有临时磁盘文件都合并成一个文件,并会创建单独的索引文件。
调优建议:当你使用SortShuffleManager时,如果的确不需要排序操作,那么建议将这个参数调大一些,大于shuffle read task的数量。那么此时就会自动启用bypass机制,map-side就不会进行排序了,减少了排序的性能开销。但是这种方式下,依然会产生大量的磁盘文件,因此shuffle write性能有待提高。


spark.shuffle.consolidateFiles
默认值:false
参数说明:如果使用HashShuffleManager,该参数有效。如果设置为true,那么就会开启consolidate机制,会大幅度合并shuffle write的输出文件,对于shuffle read task数量特别多的情况下,这种方法可以极大地减少磁盘IO开销,提升性能。
调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%。


6. 磁盘小文件

6.1 shuffle过程中,磁盘文件的寻址问题

问题的提出:
若一个DAGScheduler把一个DAG分为2个stage,则第一个stage(暂称为stage0)中的task根据计算找数据,肯定是运行在数据对应节点上,stage0与stage1之前肯定是shuffle操作,因为它们是宽依赖关系,那么stage1的task应该在哪运行才不会违背计算找数据原则?

当stage0计算完成后发生shuffle write每个Task会把它的计算结果写入到磁盘文件中,stage1的task运行在这些磁盘文件对应的节点上就会保证计算找数据了,那么下图就是解释stage1怎么找到这写磁盘小文件所在的节点了。

Spark 的Shuffle过程详解(待续...)_第7张图片

其中MapOutputTrackerMaster、MapOutputTrackerWorker是两个对象,跟踪Map的输出,分别对应主从节点。

  1. Map Task运行完毕后,将自己的执行结果信息(磁盘小文件位置,最终执行状态)封装到mapstatus中,然后会调用本进程中的MapOutputTrackerWorker,将mapstatus对象发送给Driver中的MapOutputTrackerMaster
  2. 所以根据上述,Driver会掌握整个计算过程中的所有磁盘小文件的位置信息
  3. 然后Reduce Task所在的节点worker中的MapOutputTrackerWorker就会根据Driver的MapOutputTrackerMaster信息,拿到它需要的磁盘小文件的位置,之后它会把每个磁盘小文件的位置告诉BlockManagerSlave,然后由BlockManagerSlave对磁盘小文件进行拉取数据,BlockManagerSlave默认启动5个子线程去拉数据,这个5个子线程每次总共拉取的数据量不能超过48M
  4. 子线程拉来的数据放入 Executor中用来shuffle的内存区域(占Executor内存*20%*80%),然后reduce task以pipeline模式来计算这些数据(即Map Task产生的结果)。

6.2 磁盘小文件寻址过程中容易OOM的地方

Spark 的Shuffle过程详解(待续...)_第8张图片

当reduce Task的处理速度没有子线程拉取磁盘小文件的速度快的时候,因为磁盘小文件被子线程拉取到executor的shuffle内存区域,当数据大小大于此内存的时候,就会发生OOM,那么如何解决它?

  1. 提高executor的内存
  2. 提高shuffle集合的内存比例(默认20%)
  3. 减少子线程每次拉取的数据量

你可能感兴趣的:(Spark)