细解spark的shuffle

DAGScheduler 以 Shuffle 为边界,将计算图DAG切分为多个Stages.显然shuffle起着关键的作用。

什么是shuffle

Shuffle 的本意是扑克的“洗牌”,在分布式计算场景中,它被引申为集群范围内跨节点、跨进程的数据分发。

分布式数据集在集群内的分发,会引入大量的磁盘 I/O 与网络 I/O。在 DAG 的计算链条中,Shuffle 环节的执行性能是最差的。

shuffle工作原理

先举例说明一下: 在 wordcount例子中,引入 Shuffle 操作的是 reduceByKey 算子。

image.png 如上图所示,以 Shuffle 为边界,reduceByKey 的计算被切割为两个执行阶段。约定俗成地,我们把 Shuffle 之前的 Stage 叫作 Map 阶段, 而把 Shuffle 之后的 Stage 称作 Reduce 阶段。在 Map 阶段,每个 Executors 先把自己负责的数据分区做初步聚合(又叫 Map 端聚合、局部聚合);在 Shuffle 环节,不同的单词被分发到不同节点的 Executors 中;最后的 Reduce 阶段,Executors 以单词为 Key 做第二次聚合(又叫全局聚合),从而完成统计计数的任务。

仔细观察上图你就会发现,与其说 Shuffle 是跨节点、跨进程的数据分发,不如说 Shuffle 是 Map 阶段与 Reduce 阶段之间的数据交换。

那么如何交换的呢?

Shuffle 中间文件

如果用一句来概括的话,那就是,Map 阶段与 Reduce 阶段,通过生产与消费 Shuffle 中间文件的方式,来完成集群范围内的数据交换。换句话说,Map 阶段生产 Shuffle 中间文件,Reduce 阶段消费 Shuffle 中间文件,二者以中间文件为媒介,完成数据交换。

如下图为shuffle的中间文件。

image.png 在作业调度的时候,DAGScheduler 会为每一个 Stage 创建任务集合 TaskSet,而每一个 TaskSet 都包含多个分布式任务(Task)。在 Map 执行阶段,每个 Task(以下简称 Map Task)都会生成包含 data 文件与 index 文件的 Shuffle 中间文件,如上图所示。也就是说,Shuffle 文件的生成,是以 Map Task 为粒度的,Map 阶段有多少个 Map Task,就会生成多少份 Shuffle 中间文件。

Shuffle 中间文件是统称、泛指,它包含两类实体文件,一个是记录(Key,Value)键值对的 data 文件,另一个是记录键值对所属 Reduce Task 的 index 文件。也就是说index 文件标记了 data 文件中的哪些记录,应该由下游 Reduce 阶段中的哪些 Task(简称 Reduce Task)消费。如上图中,按照首字母S,c,i的单词分别交给下游的3个Reduce Task去消费。

在spark中,shuffle环节实际的交换数据规则很复杂。数据交换规则又叫分区规则,因为它定义了分布式数据集在Reduce阶段如何划分数据分区。假设Reduce阶段有N个Task,这个N个Task对应着N个分区,那么在Map阶段,每条记录应该分发到那个Reduce Task,是由如下公式定义的。

P = Hash(Record Key) % N
复制代码

简单来说就是,对于任意一条数据记录,Spark先按照既定的哈希算法,计算记录主键的哈希值,然后把哈希值对N取模,计算得到的结果数字,就是这条记录在Reduce阶段的数分区编号P。换句话说,这条记录在shuffle的过程中,应该被分到Reduce阶段的P号分区。

Shuffle Write

Shuffle 中间文件,是以 Map Task 为粒度生成的,下图是以Map Task为粒度进行的中间文件的生成的。

image.png 在生成中间文件的过程中,Spark会借助一种类似于Map的数据结构,来计算、缓存并排序数据分区中的数据记录。这种Map结构的Key是(Reduce Task Partition ID,Record Key),而 Value 是原数据记录中的数据值,如图中的“内存数据结构”所示。

对于数据分区中的数据记录,Spark会根据我们前面提到的如何划分分区逐条计算记录所属的目标分区 ID,然后把主键(Reduce Task Partition ID,Record Key)和记录的数据值插入到 Map 数据结构中。当 Map 结构被灌满之后,Spark 根据主键对 Map 中的数据记录做排序,然后把所有内容溢出到磁盘中的临时文件,如图中的步骤 1 所示。之后当Map结构被清空后,Spark 可以继续读取分区内容并继续向 Map 结构中插入数据,满了之后在溢出,图步骤2.如此反复,知道分区中的数据都被处理完毕。

经过上面的步骤之后,磁盘中存有若干个溢出的临时文件,而内存中的Map结构中留有部分数据,Spark使用归并排序算法对所有临时文件和 Map 结构剩余数据做合并(按照partition ID,value),分别生成 data 文件、和与之对应的 index 文件,如上图所示。

总结下来就是4个步骤:

  1. 对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;
  2. 当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;
  3. 重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;
  4. 对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。

接下来就是reduce阶段,不同的Tasks基于中间文件,定位属于自己的那部分数据,完成数据拉取。

Shuffle Read

我们知道,每一个Map Task生成的中间文件,其中的目标分区数量是由Reduce阶段的任务数量(又叫并行度)决定的。我们设Reduce阶段的并行度为3,因此,Map task的中间文件会包含3个目标分区的数据,如下图所示,Index文件,恰恰是用来标记目标分区所属数据记录的起始索引。

image.png 对于所有 Map Task 生成的中间文件,Reduce Task 需要通过网络从不同节点的硬盘中下载并拉取属于自己的数据内容。不同的 Reduce Task 正是根据 index 文件中的起始索引来确定哪些数据内容是“属于自己的”。Reduce Task 拉取数据的过程,往往也被叫做 Shuffle Read。

Shuffle 中的哈希与排序操作会大量消耗 CPU,而 Shuffle Write 生成中间文件的过程,会消耗宝贵的内存资源与磁盘 I/O,最后,Shuffle Read 阶段的数据拉取会引入大量的网络 I/O。

Spark 的两种核心 Shuffle

在 MapReduce 框架中,Shuffle 阶段是连接 Map 与 Reduce 之间的桥梁,Map阶段通过 Shuffle 过程将数据输出到 Reduce 阶段中。由于 Shuffle 涉及磁盘的读写和网络 I/O,因此 Shuffle 性能的高低直接影响整个程序的性能。 Spark也有 Map 阶段和 Reduce 阶段,因此也会出现 Shuffle 。

spark shuffle

Spark Shuffle 分为两种:一种是基于 Hash 的 Shuffle;另一种是基于 Sort的 Shuffle。

在Spark1.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 方式己经不再使用

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

Hash Shuffle

image-20220503145007124.png 在基于Hash的Shuffle实现方式中,这里我们先明确一个假设前提:每个Executor只有1个CPU core,也就是说,无论这个Executor上分配多少个task线程,同一时间都只能执行一个task线程。

每个Mapper阶段的Task会为每个Reduce阶段的Task生成一个文件,(其分区是按照前面说的Hash值进行计算区分的)。这样做其实会产生大量的文件,(其文件个数为M* R,其中,M表示Mapper阶段的Task个数,R表示Reduce阶段的Task个数)。例如上图中,map为4个task,reduce为3个task,所以产生的中间为3 * 4=12个文件。这样伴随大量的随机磁盘I/O操作与大量的内存开销。

按照上面的中间件产生的过程具体划分:

  1. shuffle write阶段

其主要的作用是在一个Stage结束计算之后,为了下一个Stage可以执行的shuffle类算子(比如reduceByKey,groupByKey),而将每一个task处理的数据按key进行分区“key”。其实也就是对key进行hash计算,从而将相同的key写入同一个磁盘文件中,而每一个磁盘文件都只属于reduce端的stage的一个task。由上面的讲解知道,将数据写入磁盘之前,会先将数写入内存缓存中,当填满之后,才会溢写到磁盘文件。

我们假设一下,下一个stage总共由100个task,当前由50个task,由10个executor,每个executor执行5个task,那么在shuffle writer阶段每个execuetor会产生500个中间文件,总共产生5000个。所以未经优化shffle其产生的文件数量很大。

  1. shuffle read阶段

其主要作用是在stage开始操作前,该stage的每一个task需要将上一个stage的计算结果中相同的key,从各个节点上通过网络都拉取到自己所在的节点上,然后进行聚合或连接等操作。可以理解为,reduce 端的task只需要从上一个stage所在节点中拉取属于自己的哪一个磁盘文件即可。因为,上一个stage的task,为其每一个task都创建了一个磁盘文件。

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

普通机制的问题

  1. Shuffle在磁盘上会产生海量的小文件,生成大量文件,占用文件描述符,同时引入 DiskObjectWriter 带来的 Writer Handler 的缓存也非常消耗内存; (因為产生过多的小文件)
  2. 可能导致OOM,大量耗时低效的 IO 操作 ,导致写磁盘时的对象过多,读磁盘时候的对象也过多,这些对象存储在堆内存中,会导致堆内存不足,相应会导致频繁的GC,GC会导致OOM。

合并机制的Hash shuffle

为了缓解普通shuffle机制带来大量的文件的弊端,在 Spark 0.8.1 版本中为基于 Hash 的 Shuffle 实现引入 了 Shuffle Consolidate 机制(即文件合并机制),将 Mapper 端生成的中间文件进行合并的处理机制。开启合并机制的配置是spark.shuffle.consolidateFiles。该参数默认值为false,将其设置为true即可开启优化机制。

image-20220503151500157.png 这假设还是1个cpu core。还是和上面的一样,map task 4个,reduce task3个。在同一进程中,不管多少个task,最终都会将相同的hash key值放入相同的buffer中,然后把buffer中的数据写入以core为单位的磁盘文件中,(一个Core只有一种类型的Key的数据),每1个Task所在的进程中,分别写入共同进程中的3份本地文件,这里有4个Mapper Tasks,所以总共输出是 2个Cores x 3个分类文件 = 6个本地小文件。

简单说一下,如上图,为什么假设一个core,因为一个core的话,在同一时间只能执行task,那么第二个task,也会进入这个core,两个task公用一个Buffer内存,产生的文件是每一个core产生reduce task并行度个数的文件。所以上面都是只针对一个core来说。

图解: 开启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的性能。

还是按照上面假设,reduce端的task为100,当前task为50,10个excutor,core为1(和上面一样的core),每个executor执行5个task,那么每个executor的文件按数量为 core(1)* reduce task(100)=100;总共文件为1000.所以下降了。

合并机制的问题

如果 Reducer 端的并行任务或者是数据分片过多的话则 Core * Reducer Task 依旧过大,也会产生很多小文件。

Sort Shuffle

Spark1.1 版本引入了 Sort Shuffle: 基于Hash的shuffle的实现方式中,生成的中间文件的个数会依赖于reduce阶段的Task个数,因此文件数是不可控的,无法正真的解决问题。为了更好地解决问题,在 Spark1.1 版本引入了基于 Sort的 Shuffle 实现方式,并且在 Spark 1.2 版本之后,默认的实现方式也从基于Hash 的 Shuffle,修改为基于 Sort 的 Shuffle 实现方式,即使用的ShuffleManager 从默认的 hash 修改为 sort。

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

sort的普通机制

image-20220503151500157.png

在基于 Sort 的 Shuffle 中,每个 Mapper 阶段的 Task 不会为每 Reduce 阶段的 Task 生成一个单独的文件,而是全部写到一个数据(Data)文件中,同时生成一个索引(Index)文件, Reduce 阶段的各个 Task 可以通过该索引文件获取相关的数据。

具体来说,大致分为如下几个步骤:

  1. 写入内存

就是为了处理那些shuffle过程中需要进行排序的操作,sortShuffleManager根据下游的reduce个数,进行内存级别的分区,会将数据写入一个内存数据结构中(默认5M),其结构会根据shuffle算子的不同进行相应的选择,(如reduceByKey这种聚合类的shuffle算子,那么会选用Map数据结构,一边通过Map进行聚合,一边写入内存;如果是join这种普通的shuffle算子,那么会选用Array数据结构,直接写入内存。)。根据内存设置的阈值判断内存进行磁盘的溢写。 2. 排序 在溢写磁盘前,会并针对多个内存分区进行排序,然后将排序后结果批量写入缓冲区中,缓冲区写满之后溢写到磁盘文件,一个缓冲区对应一个磁盘文件,此时和未优化的Hash shuffle一样, 3. merge 接下来就是对刚才文件进行合并,合并成一个磁盘文件,为了分清楚合并后的数据对应reduce task中的那部分,会生成一个索引文件。

还是按照上面的例子进行分析,reduce task 100,map task 50,executor 10,每个executor执行5task,每个task生成一个数据文件和索引文件,为10,总共为100个。

sort的bypass机制

image-20220503155510273.png

触发条件如下:

  1. shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold(默认为200)参数的值。(其实也就是reduce的并行度小于这个值)。
  2. 不是聚合类的shuffle算子(比如reduceByKey)。

具体来说,有几个如下步骤: 1.写入临时文件 和基本的hash shuffle一样,当前的task会为reduce的每一个task都创建一个临时磁盘文件,并按照key的hash写入相应的磁盘中,其流程是先写入缓冲,再写入磁盘。 2. merge 接下来就是对刚才文件进行合并,合并成一个磁盘文件,为了分清楚合并后的数据对应reduce task中的那部分,会生成一个索引文件。

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

与sort shuffle机制的不同
第一,磁盘写机制不同;

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

总结

shuffle过程其实就是将map端获取的数据使用分区器进行划分,并将数发送给对应的Reducer的过程。

shuffle是map和reduce之间的中间件,其性能影响了整个程序的性能和吞吐量。

spark的shuffle分两种实现:HashShuffle和SortShuffle。

基于 Hash 的 Shuffle 机制的优缺点

优点

  • 可以省略不必要的排序开销
  • 避免了排序所需的内存开销

缺点

  • 生产过多的文件,会对文件系统造成压力。
  • 大量的文件随机读写带来一定的磁盘开销。
  • 数据块写入时所需的缓存空间也会随之增加,堆内存造成压力。

基于 Sort 的 Shuffle 机制的优缺点

优点

  • 小文件的数量大量减少,Mapper 端的内存占用变少;
  • Spark 不仅可以处理小规模的数据,即使处理大规模的数据,也不会很容易达到性能瓶颈

缺点:

  • 如果 Mapper 中 Task 的数量过大,依旧会产生很多小文件,此时在Shuffle 传数据的过程中到 Reducer 端,Reducer 会需要同时大量地记录进行反序列化,导致大量内存消耗和 GC 负担巨大,造成系缓慢,甚至崩溃;
  • 强制了在 Mapper 端必须要排序,即使数据本身并不需要排序;
  • 它要基于记录本身进行排序,这就是 Sort-Based Shuffle 最致命的性能消耗。

猜你喜欢

转载自juejin.im/post/7107588570067992583