大数据shuffle原理 | 青训营笔记

411 阅读15分钟

这是我参与「第四届青训营 」笔记创作活动的第1天

Based on batch processing

shuffle概述

为什么shuffle如此重要

shuffle指的是不同分区数据交换的过程

设:Mapper个数为:M
Reducer个数为 R(也是partition的数量)

1.jpeg

  • 每一个Reducer都需要向每一个Mapper中拿数据,则总共有 M*R 次的网络请求,M*R 次数据移动,从而导致大量的数据序列化,反序列化操作,从而消耗大量cpu
  • 数据在移动过程中可能存在丢失的风险,一旦丢失就需要重算。
  • 在mapper端可能存在大量的排序操作
  • 当数据量很大的时候,我们在存储的时候可能还会涉及压缩和解压缩,从而消耗CPU性能

shuffle算子

shuffle算子分类

  • 重分区类

repartition: 其实际上调用的是coalesce(numPartitions, shuffle = true)方法

无论子RDD重新分区数大于或者等于或者小于父RDD的分区数,重新分区的过程中一定会产生shuffle

  • Distinct

可以理解为一种特殊的ByKey操作

  • ByKey

groupByKey / reduceByKey / aggregateByKey / combineByKey / sortByKey / sortBy

  • join

cogroup / leftOuterJoin / intersection / subtract / subtractByKey

spark如何知道一个操作是宽依赖还是窄依赖

宽依赖(一个父RDD对应多个子RDD)对应的一个对象是ShuffleDependency。ShuffleDependency被CoGroupedRDD和ShuffledRDD所创建

  • 当调用Cogroup操作的时候:fullOuterJoin / rightOuterJoin / leftOuterJoin / join算子的时候会创建CoGroupedRDD
  • 当调用combineByKeyWithClassTag(combineByKey / reduceByKey)操作,Coalesce操作,sortByKey(sortBy)操作的时候会创建ShuffledRDD

Shuffle Dependency构造

A single key-value pair RDD

Partitioner

负责给定一个key的时候,将这个Key映射为一个数字,这个数字就是这个Key所对应的分区。 Partitioner是一个抽象类,有两个借口接口: numberPartitions / getPartition

  • 默认实现:HashPartitioner

Serializer

负责把一个对象映射为一段二进制流和将一段二进制流映射为一个对象。

Optional key ordering

Optioner Aggregator

在进行shuffle的时候一个很重要的性能优化器。其将一部分reduce端做的事拿到map端来做。

其有三个方法:

  • createCombiner: 只有一个value的时候初始化的方法
  • mergeValue: 合并一个value到Aggregator中
  • mergeCombiners: 合并两个Aggregator

mapSideCombine flag

shuffle过程

shuffle 写数据

shuffle写数据发展历程: Hash Shuffle -> Sort Shuffle

Hash Shuffle

将不同的partition写到不同的文件中。每一个MapTask都会给每一个partition创建一个buffer,当buffer写满后就flush到磁盘中。这样总共有M*R个文件

带来的问题:

  • 生成文件数过多,对文件系统的压力太大
  • 运行时同时打开的文件数也很多,这个Task很可能OOM
Hash Shuffle写数据优化

每个partition映射成一个文件片段。

Screenshot 2022-08-02 at 4.25.25 PM.png

2.png 可以看到变化就是。不同MapTask中相同的partition可以共享一个buffer。但是每一个MapTask仍然为每一个partition都分配了一个buffer,只不过一次并行的MapTask变少了,上游的文件数变少了。

当CPU有C核时,最终会产生 C*R个文件。但是这个方法打开的文件数仍然为R个,仍然面临着OOM的问题。

Sort Shuffle

每一个MapTask不再给每一个partition一个buffer,而是一个MapTask的所有partition写到一个buffer里面。当内存满的时候,按照partition和Key进行排序,把相同的partition数据放在一起写入文件中。task完成后就会将所有的小文件进行归并合成一个data file(还有一个index file)。所以上游最终会产生 M*2个文件。

这里文件多就排序的思想时因为排序之后的数据可以使用索引来获得,这样的话,即使在一个文件中,也可以区分出不同partition,key的数据。就不用之前那样为了每一个数据都创建一个文件来保存。

Shuffle 写数据

对于Hash Shuffle或是 Map Shuffle,其下游读数据是相似的。每个reduce task分别获取所有map task生成的属于自己的片段。

Shuffle过程的触发流程

Collection Action -> SubmitJob -> GetDependencies -> Register Shuffle

3.png

在 collect Action 中会调用spark中DAG Scheduler的submit Job,DAG Scheduler会分析counts对象的RDD依赖关系,由于有reduceByKey操作,所以会创建ShuffledRDDShuffledRDD在DAG Scheduler调用getdependency的时候,会去创建shuffle dependency,从而向shuffle manager注册自己。后续DAG会生成map和reduce两个stage,这两个stage会分别提交给Executor,计算就真正开始了。

Register Shuffle 时做的事情

Register Shuffle注册shuffle时,会根据不同条件创建不同 shuffle handle。三种shuffle分别对应了三种shuffle handle。

  • 不支持mapside combine,即partition中的数据不要求是有序的。partition小于spark.shuffle.sort.bypassMergeThreshold(200)时,即使使用hash shuffle也并不会打开太多文件,就使用hash shuffle,创建对应的BypassMergeSortShuffleHandle
  • 不支持mapside combine, serializer支持relocation并且partitions小于2的24次方(Long Array 前24个bit是用来记录这个partition的),那么就使用Tungsten shuffle,并创建对应的SerializedShuffleHandle
  • 这些条件都不满足就会使用sort shuffle,并创建对应的SerializedShuffleHandle

Shuffle Handle 与 Shuffle Writer的对应关系

BypassMergeShuffleWriter

和Hash Shuffle的原理是一样的。为了防止打开的文件数量太多造成OOM,所以这里限制了最大不超过200。最后多了一步,将所有的partition file都merge(zero copying)成一个data file(还有一个index file)

UnsafeShuffleWriter
  • unsafe: 使用了堆外内存(没有Java对象模型的内存开销,没有垃圾回收的开销。)
  • 其将堆外内存分成了若干个内存页,record写入内存页中,页写满了会继续申请新的内存页,当无法申请内存页的时候就会触发spill。最后会将spill file merge成一个data file文件还有一个记录各个partition偏移量的index file
  • UnsafeShuffleWriter只按照partition进行排序,不排序record本身,所以不支持mapside combine。

4.jpeg

如上图,record序列化往堆外内存写的时候,还会在堆内有一个Long Array的元信息。spill排序的时候只发生在这个Long Array(前24个bit用来记录partition,所以使用这个writer要求partition数量小于2的24次方)上,按照Partition id, pn, offset这个顺序排序,不会移动Page中的数据。

SortShuffleWriter
  • 支持在map侧的combine。它会对partition做排序,然后对partition中的kv对做排序,类似于map reduce。不使用堆外内存是因为对k进行排序时仍然需要将数据再反序列化到内存中。
  • 需要combine的时候,record是放在PartitionedAppendOnlyMap中,本质上是一个HashTable。不需要combine的时候,record是放在PartitionedPairBuffer中,本质上是一个array。

Shuffle 读数据

spark底层是使用基于netty的网络通信架构 5.png

上面是读取数据的网络时序图。在做shuffle的时候,driver端的MapOutputTracker中记录了shuffle数据在什么地方。所有的reduce Task通过MapOutputTracker去获取数据所在的位置,因此数据就可以被分成本地和远程两种数据。

远程数据的处理方式

  • reduce Task发送openblock rpc请求通知服务端,然后等待接收服务端的响应。服务端会处理这个rpc请求,根据Block ID的列表找到读取的数据位置,准备好相应的数据,并生成一个stream ID和Chunk ID的列表返回给客户端。客户端后续就可以根据stream ID和Chunk ID来获取数据
  • 客户端接收到响应之后,如果指定了要将数据存储在文件会发送stream请求,否则会发送chunk请求。chunk请求的数据会保存在内存中。
  • 服务端在接收到对应的stream/chunk请求后会打开对应的文件(刚刚通过writer生成的data file和index file),通过stream manager返回数据。会按照请求的顺序返回数据。
  • 客户端在收到这些数据后,会先解析数据然后合并数据。reduce会一边拉取数据一遍去做聚合,生成一个迭代器。

Reader的实现 —— ShuffleBlockFetchIterator

5.png

  • 这个fetchRequest请求是随机加入队列的,是为了防止热点现象。可能存在所有的MapTask节点同时向某个节点发fetch请求
  • 为了防止内存溢出,每次发送出去的request请求的数量和block块的大小是有限制的。

防止OOM的一些参数

  • maxBytesInFlight
  • maxReqsInFlight
  • maxBlocksInFlightPerAddress
  • maxReqSizeShuffleToMem
  • maxAttemptsOnNettyOOM : 控制OOM次数

Read的一个重要特性 External Shuffle Service

为了解耦合数据计算和数据读取服务。spark使用了一个单独的服务来处理这些数据请求。在原始的设计中,哪个Executor生成了MapTask,哪个Executor就得等着,一直存活着,等其他的Task来获取这些数据。这样数据计算和数据读取就耦合来,想要解耦合就需要ESS。

6.png

ESS这个单独的服务运行在每个主机上,管理着这个节点所有Executor生成的shuffle数据,这样的话每一个Executor在写完之后就可以退出了。

  • 每一个Executor启动后都会向其同一个节点上的ESS来注册。这样ESS就知道每个Executor本地的Map任务产生的shuffle数据的位置。ESS与Executor是不同的进程。这个节点上的所有Executor都共享着一个ESS
  • 当reduce任务开始运行的时候,它会查询spark driver端的MapOutputTracker,获取输入的shufflewriter的位置。每个reduce任务会找到对应位置上的ESS来建立连接。然后来fetch数据
  • ESS在收到fetch数据的请求之后,就会读取index file和data file,把对应的数据返回给reduce端。

Shuffle读写数据都会用到的优化 —— Zero Copy

Zero Copy指的是,从一个存储区域到另一个存储区域的copy任务没有CPU参与。

Zero Copy通常用于网络文件传输,以减少CPU消耗和内存带宽占用,减少用户空间(用户可以操作的内存缓存区域)与CPU内核空间(CPU可以操作的内存缓存区域及寄存器)的拷贝过程,减少用户上下文(用户状态环境)与CPU内核上下文(CPU内核状态环境)间的切换,提高系统效率。

7.png

图为把一个文件读出,并通过网络发送出去的流程图。Zero Copy部分图文来自这里

发生4次内核态和用户态的切换(1、4、5、7),发生4次copy(3、4、5、6),其中有2次CPU copy(4、5), 2次DMA copy(3,6)。

操作系统的Zero copy

Linux系统对于 Zero Copy是通过sendfile系统调用实现的

8.jpg

发生2次空间切换(1、6),发生3次copy(3、4、5),其中有1次CPU参与(4)

DMA Gather Copy DMA 方式

由于该拷贝方式是由DMA完成,与系统无关,所以只要保证系统支持sendfile系统调用功能即可。

该方式中没有数据拷贝到socket buffer。取而代之的是只是将kernel buffer中的数据描述信息写到了socket buffer中。数据描述信息包含了两方面的信息:kernel buffer中数据的地址及偏移量

9.png

发生2次空间切换(1、6),发生2次copy(3、5),其中有0次CPU参与

比如说Hash Shuffle在merge文件的时候,就没必要把文件copy到用户态处理,所以这里用了zero copy,直接合并文件。在发送数据的时候,把文件的相关信息转给了网卡。

Netty Zero Copy

  • 可堆外内存,避免JVM堆内存到堆外内存的数据拷贝
  • CompositeByteBuf, Unpooled.wrappedBuffer, ByteBuf.slice可以合并,包装,切分数组,避免发生内存拷贝
  • Netty使用FileRegion实现文件传输,FileRegion底层封装了FileChannel#transferTo()方法,可以将文件缓冲区的数据直接传输到目标Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝。

常见问题

数据存储在本地磁盘,没有备份

一旦数据丢失,就需要重算数据。

IO并发:大量RPC请求

在读的时候是M*R级别的rpc请求,这个请求量很占用CPU。(前面的sorted shuffle优化只是解决打开文件数吗?所以rpc请求数量还是没变?)

IO吞吐:随机读,写放大(3X)

随机读:每个MapTask生成的文件假设是1GB,那每次读的数据量是 1GB / R(partition数量),当partition数量过大的时候,这个读取的数据量可能是小于1KB。尤其是对于HDD磁盘,1KB或者10KB就是随机读,会降低HDD吞吐。

写放大:当文件spill到磁盘后,你后来还要将其从磁盘中读出来,最后merge成一个完整的文件,相当于是又写了一遍。在读的时候也可能产生spill,也会产生写放大

GC频繁:影响NodeManager

因为RPC请求太多,每一个RPC请求都在Netty里会生成很多类似于Channel,request等的对象。这些对象是频繁创建,频繁回收的。会导致ESS响应频繁,在yarn模式下,ESS部署在NodeManager上,所以会影响NodeManager的稳定性。

Shuffle优化

避免shuffle,使用broadcast替代join

10.png

如果一个RDD对应的数据比较小,比如一个RDD数据有1TB,而另外一个只有500MB。这个时候就可以将第二个RDD broadcast出去,作为广播变量发给所有Executor,相当于直接将这个RDD的全量数据都发出去了。

然后在rdd1的map算子中,可以从rdd2 DataBroadcast中,获取rdd2的所有数据。然后进行遍历,如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的,那么就做一些map操作

以上操作仅在rdd2的数据量比较少如几百M或者1,2G的情况下,因为每个Executor的内存中,都会驻留一份rdd2的全量数据,否则会触发OOM。

这种方式除了能避免Shuffle之外, 在某些情况下还能够节省大量内存:
若有10个Executor, 每个Executor有100个task,每个副本30M

  • Shuffle占用空间:10 * 100 * 30M = 29.3G
  • 该方法占用空间:10 * 30M = 300M

使用map-side预聚合算子 map-side combine

也就是shuffle构造中的Aggregator

有些算子自带combine函数,有些不带

如果能预聚合,就先做一个预聚合。

shuffle参数优化

spark.default.parallelism && spark.sql.shuffle.partitions: 并发度过高的话会导致随机读。并发度过低的话,一个Task处理的数量就会太大,如果这个作业并发跑不起来,作业端到端的时间就会延长。

spark.hadoopRDD.ignoreEmptySplits:在最开始减少一些不必要的partition数量,如果文件是空的就不产生partition

spark.hadoop.mapreduce.input.fileinputformat.split.minsize
spark.sql.file.maxPartitionBytes
spark.sql.adaptive.enabled && spark.sql.adaptive.shuffle.targetPostShuffleInputSize

spark.reducer.maxSizeInFlight
spark.reducer.maxReqsInFlight spark.reducer.maxBlocksInFlightPerAddress

当数据实在过大的时候,可以调节reducer的OOM数据,让reducer读取的速度变慢,从而防止把磁盘打爆

shuffle倾斜优化

如果word count中某一个数据的数据量很多的时候,就会出现某一个ReduceTask分到的数据非常非常多。这样的话,作业端到端的运行时间就会变长。因为作业整体的运行时间取决于这个Task运行最大的时间

也可能会导致一些个别Task OOM导致作业失败。因为在给这些Task设置资源的时候,都是假定他们之间处理的数量是接近的。如果这个处理的数据量是有倾斜的,一部分的Task它对应的资源量可能就没有百分百满足。就会导致作业失败。

常见的倾斜处理优化

提高并行度

足够简单,但是只能缓解最大的那个Task倾斜,不能根治。

Spark AQE Skew Join

11.png

12.png

AQE根据shuffle文件统计数据自动检测倾斜数据,将那些倾斜数据的分区打散成小的子分区,然后各自进行join

实际参数调整带来优化的案例

13.png

spark.sql.adaptive.shuffle.targetPostShuffleInputSize: 64M -> 512M

通过优化InputSize,单个Task处理的数据量变多了,然后Aggrerator产生了作用,有效的降低了整体的shuffle数据量。

spark.sql.files.maxPartitionBytes: 1G -> 40G

通过参数调整,让M*R有了几个数量级的下降。增大了读取时候的 Chunk size,减缓了随机读的问题。减少了shuffle过程中IOPS,避免了长时间的 Blocked Time

Push Shuffle

为什么需要 push shuffle

Avg IO size太小,造成大量的随机IO,M*R次的读请求造成了大量的RPC请求,对CPU和磁盘的双重压力,严重影响性能。

优化的思路:能不能在读取shuffle数据之前,就将这些数据合并到一起。

Magnet

Magnet相当于维护了两份shuffle数据的副本

14.png

  • spark driver组件,协调整体的shuffle操作map任务的shuffle writer过程完成之后,增加了一个额外的操作push-merge, 将数据复制一份推到远程shuffle服务上。
  • magnet shuffle service相当于是一个强化版的ESS。将隶属于同一个shuffle partition的block,会在远程传输到magnet后被merge到同一个文件中
  • reduce任务从magnet shuffle service 接收合并好的shuffle数据

service在合并文件时候的细节

15.jpeg

magnet shuffle service 在合并数据的时候,会给每一个partition维护一个元数据信息。这份元数据的key由partition id, shuffle id和shuffle partition id混合而成。

magnet shuffle service在合并数据之前会检索这个元信息,根据这些元信息来帮助自己正确处理一些潜在的异常场景。

  • bitmap: 存储已merge的mapper id,防止重复merge
  • position offset: 如果本次block没有正常merge,可以恢复到上一个block的位置
  • currentMapId: 标识当前正在append的block,保证不同mapper的block能一次append

Cloud Shuffle Service

Magnet,如果文件丢失,整个stage的所有maptask都要重算。

16.png