这是我参与「第四届青训营 」笔记创作活动的第六天
06 大数据 shuffle 原理与实践
今天学习的内容是Shuffle,首先通过MR框架引出Shuffle的概念,然后介绍了shuffle的算子,shuffle的过程,以及提出了shuffle的几种优化方案,最后介绍了PushShuffle,列举了一些公司的优化方案,重点讲解了magnet和CSS的优化思路,详细内容如下:
1 Shuffle概述
1.1MapReduce
Map阶段,是在单机上进行的针对一小块数据的计算过程
Shuffle阶段,在map阶段的基础上,进行数据移动,为后续的reduce阶段做准备。
reduce阶段,对移动后的数据进行处理,依然是在单机上处理一小份数据
为什么shuffle 对性能非常重要
- M*R次网络连接
- 大量的数据移动
- 数据丢失风险
- 可能存在大量的排序操作
- 大量的数据序列化、反序列化操作(消耗大量的cpu)
- 数据压缩
**总结思考:**本部分通过MR框架来引出Shuffle概念,数据shuffle 表示了不同分区数据交换的过程,shuffle的基本过程;
在大数据场景下,数据shuffle 表示了不同分区数据交换的过程,不同的shuffle策略性能差异较大。目前在各个引擎中shuffle都是优化的重点,在spark框架中,shuffle是支撑spark进行大规模复杂数据处理的基石。
2 Shuffle算子
2.1 Shuffle算子
- repartition:coalesce、repartition
- ByKey:groupByKey、reduceByKey、aggregateByKey、combineByKey、sortByKeysortBy
- Join:cogroup、join
- Distinct :distinct
算子应用
val text = sc.textFile("mytextfile.txt")
val counts = text
.flatMap(line => line.split(" "))
.map(word => (word,1))
.reduceByKey(_+_)
counts.collect
reduceByKey产生了Shuffle,它需要把相同的 key 都放到一起,然后由一台机器去处理。
2.2 Spark中对shuffle的抽象
- 窄依赖:父RDD的每个分片至多被子RDD中的一个分片所依赖
- 宽依赖:父RDD中的分片可能被子RDD中的多个分片所依赖
算子内部依赖数如下:
ShuffleDependency
- coGroupedRDD
- ogroup
- fullOuterJoin、rightOuterJoin、leftOuterJoin
- join
- ShuffledRDD
- combineByKeyWithClassTag
- combineByKey
- reduceByKey .
- Coalesce
- sortByKey
- sortBy
2.3 Shuffle Dependency 构造
- 创建会产生shuffle的RDD时,RDD会创建Shuffle Dependency来描述Shuffle相关的信息
- 构造函数
- A single key-value pair RDD, i.e. RDD[Product2[K, V]],
- Partitioner (available as partitioner property),
- Serializer,
- Optional key ordering (of Scala’s scala.math.Ordering type),
- Optional Aggregator,
- mapSideCombine flag which is disabled (i.e. false) by default.
Partitioner
- 用来将record映射到具体的partition的方法
- 接口
- numberPartitions
- getPartition
Aggregator
- 在map侧合并部分record的函数
- 接口
- createCombiner:只有一个value的时候初始化的方法
- mergeValue:合并一个value到Aggregator中
- mergeCombiners:合并两个Aggregator
**总结:**本部分介绍了Shuffle的3种算子,举了一个reduceByKey例子介绍Shuffle的产生,然后介绍了Spark中shuffle的依赖关系,然后讲解了Shuffle Dependency及其相关组件,其中重点介绍了Partitioner和Aggregator函数及其接口
3 Shuffle 过程
3.1 Shuffle-写数据
1 Hash Shuffle :写数据
-
每个partition会映射到一个独立的文件
-
**问题:**数据太多会导致生成的文件太多,同时也会打开太多的文件,面临OM的问题
-
**优化方案:**每个partition会映射到一个文件片段
-
**优化后问题:**并没有从根源解决问题,同样会打开R个文件也会面临OM的问题,所以慢慢的从Hash Shuffle过渡到Sort Shuffle
2 Sort shuffle :写数据
- 每个task生成一个包含所有partiton数据的文件
- 优点:打开的文件少、支持map-side combine
- 缺点:需要排序
TungstenSortShuffle
- 优点:更快的排序效率,更高的内存利用效率
- 缺点:不支持map-side combine
3.2 Shuffle -读数据
- 每个reduce task分别获取所有map task生成的属于自己的片段
- Hash Shuffle与Sort Shuffle虽然打开文件数目不一样,但逻辑基本一样
3.3 Shuffle过程的触发流程
Collect Action => SubmitJob => GetDependencies => RegisterShuffle
3.4 Shuffle Handle的创建
Register Shuffle 时做的最重要的事情是根据不同条件创建不同的shuffle Handle
- 由action算子触发DAG Scheduler进行shuffle register
- Shuffle Register会根据不同的条件决定注册不同的ShuffleHandle
具体的条件判断流程如下图
3.5 三种ShuffleHandle对应了三种不同的ShuffleWriter的实现
- BypassMergeSortShuffleWriter:HashShuffle
- UnsafeShuffleWriter:TunstonShuffle
- SortSHuffleWriter:SortShuffle
3.6 Writer 实现
-
BypassMergeShuffleWriter
- 不需要排序,节省时间
- 写操作的时候会打开大量文件。
- 类似于Hash Shuffle
-
UnsafeShuffleWriter
- 适用于数量较大的partitioner
- 只根据partition 排序 Long Array
- 数据不移动
-
SortShuffleWriter
- 支持combine
- 需要combine时,使用PartitionedAppendOnlyMap,本质是个HashTable
- 不需要combine时PartitionedPairBuffer本质是个array
3.7 Reader 实现
3.7.1 网络请求流程
使用netty作为网络框架提供网络服务,并接受reducetask的fetch请求
首先发起openBlocks请求获得streamId,然后再处理stream或者chunk请求
3.7.2 ShuffleBlockFetchIterator
- 区分local和remote节省网络消耗
- 防止OOM
- maxBytesInFlight//限制数获取的数据块的大小
- maxReqsInFlight//限制正在获取的这个数请求的数量
- maxBlocksInFlightPerAddress
- maxReqSizeShuffleToMem
- maxAttemptsOnNettyOOM//限制 OM 的次数的
3.7.3 External Shuffle Service
- ESS作为一个存在于每个节点上的agent为所有Shuffle Reader提供服务,从而优化了Spark作业的资源利用率,MapTask在运行结束后可以正常退出
- 为了解决Executor为了服务数据的fetch请求导致无法退出问题,我们在每个节点上部署一个External Shuffle Service,这样产生数据的Executor在不需要继续处理任务时,可以随意退出。
3.8 Shuffle优化
3.8.1避免shuffle
- 使用broadcast替代join
//传统的join操作会导致shuffle操作。
//因为两个RDD中,相同的key都需要通过网络拉取到一个节点上,由一个task进行join操作。
val rdd3 = rdd1.join(rdd2)
//Broadcast+map的join操作,不会导致shuffle操作。
//使用Broadcast将一个数据量较小的RDD作为广播变量。
val rdd2Data = rdd2.collect()
val rdd2DataBroadcast = sc.broadcast(rdd2Data)
//在rdd1.map算子中,可以从rdd2DataBroadcast中,获取rdd2的所有数据。
//然后进行遍历,如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的,那么就判定可以进行join。
//此时就可以根据自己需要的方式,将rdd1当前数据与rdd2中可以连接的数据,拼接在一起(String或Tuple)。
val rdd3 = rdd1.map(rdd2DataBroadcast...)
//注意,以上操作,建议仅仅在rdd2的数据量比较少(比如几百M,或者一两G)的情况下使用。
//因为每个Executor的内存中,都会驻留一份rdd2的全量数据。
- 使用可以map-side预聚合的算子
3.8.2 Shuffle参数优化
- spark.default.parallelism && spark.sql.shuffle.partitions
- spark.hadoopRDD.ignoreEmptySplits
- 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
3.8.3 Shuffle 倾斜优化
- 什么叫倾斜?有什么危害
倾斜影响:作业运行时间变长;Task OOM导致作业失败
- 解决倾斜方法举例
- 增大并发度//优点:足够简单 缺点:只缓解、不根治
- AQE(Spark AQE Skew Joir)//AQE根据shuffle文件统计数据自动检测倾斜数据,将那些倾斜的分区打散成小的子分区,然后各自进行join。
3.8.4 Zero Copy
DMA(Direct Memory Access):直接存储器存取,是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。 不使用zero copy => 使用sendfile => 使用sendfile +DMA gather copy
3.8.5 Netty Zero Copy
- 可堆外内存,避免JVM堆内存到堆外内存的数据拷贝。
- CompositeByteBuf 、Unpooled.wrappedBuffer、ByteBuf.slice,可以合并、包装、切分数组,避免发生内存拷贝
- Netty使用FileRegion实现文件传输,FileRegion底层封装了FileChannel#transferTo()方法,可以将文件缓冲区的数据直接传输到目标Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝
3.9 常见问题
- 数据存储在本地磁盘,没有备份
- IO并发:大量 RPC请求(M*R)
- IO吞吐:随机读、写放大(3X)
- GC频繁,影响NodeManager
4 Push Shuffle
-
上一部分所讲的shuffle过程存在哪些问题?
- 数据存储在本地磁盘,没有备份
- IO 并发:大量 RPC 请求(M*R)
- IO 吞吐:随机读、写放大(3X)
- GC 频繁,影响 NodeManager
-
为了优化该问题,有很多公司都做了思路相近的优化,push shuffle
4.1 Magnet主要流程
主要为边写边push的模式,在原有的shuffle基础上尝试push聚合数据,但并不强制完成,读取时优先读取push聚合的结果,对于没有来得及完成聚合或者聚合失败的情况,则fallback到原模式。
4.2 Cloud Shuffle Service架构
-
-
Zookeeper WorkerList [服务发现]
-
CSS Worker [Partitions / Disk | Hdfs]
-
Spark Driver [集成启动 CSS Master]
-
CSS Master [Shuffle 规划 / 统计]
-
CSS ShuffleClient [Write / Read]
-
Spark Executor [Mapper + Reducer]
-
Cloud Shuffle Service 读写流程
-
Cloud Shuffle Service 支持AQE
- 在聚合文件时主动将文件切分为若干块,当触发AQE时,按照已经切分好的文件块进行拆分。