这是我参与「第四届青训营 」笔记创作活动的第三天
Spark原理与实践
Spark简介
-
大数据技术栈 从下往上依次为:数据层——存储层——计算层——应用层
-
常见大数据处理链路 从左往右依次为:数据源——原始数据——数据处理(加工)——数据应用
-
Spark定义 官网译文:Apache Spark 是一个多语言引擎,用于单节点机器或者集群上执行数据工程、数据科学和机器学习。
-
Spark演变史
- Spark生态圈
- 统一引擎,支持多种分布式场景
- 多语言支持:SQL、Scala、Java、R、Python
- 可读写丰富数据源:内置数据源,Text,ORC/Parquet,JSON/CSV,JDBC;自定义数据源,Hbase/Mongo/ElasticSearch等
- 丰富灵活的API算子:Sparkcore,map/filter/flatMap/mapPartitions,repartition/groupBy/recudeBy/join/aggregate,foreach/foreachPartition/count/max/min; SparkSQL, select/filter/groupBy/agg/join/union/orderBy/...,自定义UDF和Hive UDF;
- 支持 K8S/YARN/Mesos资源调度
- 多种语言提交方式:spark-shell(常用spark-submit)、spark-sql、pyspark
Spark原理
spark-core
- 整体结构
2. RDD
定义:RDD(Resilient Distributed Dataset)分布式数据集,spark中最基本的数据抽象,它带包一个不可变、可分区、里面元素可并行计算的集合。RDD具有数据流模型的特点:自动容错、位置感知性调度和可伸缩性。
RDD五大基本特性
-
A list of partitions:分片(partition)数据集的基本组成单位。每一分片都会被一个计算任务处理,决定并行计算的粒度。用户在创建RDD时指定RDD分片个数,如果没指定,采用默认值。
-
A function for computing each split 一个计算每个分区函数。spark中RDD的计算以分片为单位的,每个RDD都会实现函数达到目的。computing函数会对迭代器进行复合,不需要保存每次计算结果。
-
A list of dependencies on other RDDs RDD之间的依赖关系。RDD的每次转换都会生成一个新的RDD,所以RDD之间会形成类似于流水线一样的前后依赖关系。在分区数据丢失时,spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。
-
Optionally,a Partition fo key-value RDDs RDD的分片函数,在spark中实现了两种类型的分片函数,一个时基于哈希的HashPartitioner,另一个是基于范围的RangePartitioner。Partitioner函数不但决定RDD本身的分片数量,也决定parent RDD Shuffle输出的分片数量。
-
optionally,a list of preferred locations to compute each split一个列表,存储存取每个Partition的优先位置。对于一个HDFS文件来说,这个列表保存的就是每个Partition所在快的位置。移动数据不移动计算理念,spark在进行任务调度时,尽可能将计算任务分配到所有处理数据块的存储位置。 RDD的弹性体现在那些方面?
-
自动进行内存和磁盘切换
-
基于lineage(血缘关系)的高效容错
-
task如果失败会特定次数重试
-
stage如果失败会自动进行特定次数的重试,而且只会计算失败的分片
-
checkpoint,每次对RDD操作会产生新的RDD,如果链条比较长,计算比较笨重,可以把数据放到磁盘中和persist 内存或者磁盘中的数据进行复用(检查点、持久化)
-
数据调度弹性:DAG TASK 和资源管理无关
-
数据分片和高度弹性repartion
RDD创建方式
-
内置RDD:ShuffleRDD/HadoopRDD/JDBCRDD /KafkaRDD/UnionRDD/MapPartitionsRDD/..
-
自定义 class CustomRDD(...) extends RDD{} 实现五要素对应的函数:RDD是一个分片的数据集合;RDD的函数针对每个分片进行计算;RDD之间是个依赖的集合;可选:key-value型RDD是根据哈希来分区的;可选:数据本地性优先计算。
RDD算子
- Tasnsform算子:生成一个新的RDD, map/fliter/flatMap/groupByKey/reduceByKey
- Action算子:c触发Job提交, collect/count/take/saveAsTextFile/...
RDD依赖
- RDD依赖:描述父子RDD之间的依赖关系
- 窄依赖:父RDD的每个partition至多对应一个子RDD分区,NarrowDependency,oneTooneDependency,RangeDepency,PruneDependecy
- 宽依赖:父RDD的每个partition都可能对应多个子RDD分区,ShufffleDepdency
- RDD的执行 job RDD action算子触发 ————> Stage:依据宽依赖划分 ————> Task:Stage内执行单个partition任务
调度(scheduler)
- ①:根据ShuffleDependcy切分Stage,并按照依赖顺序调度Stage,为每个Stage生成并提交TaskSet到TaskScheduler; ②:根据调度算法(FIFO/FAIR)对多个TaskSet进行调度,对于调度到的TaskSet,会将Task调度(locality)到相关Executor上面执行,Executor SchedulerBackend提供;③:Locality: PROCESS_LOCAL, NODE_LOCAL, ACK_LOCAL, ANY
内存管理
- 内存主要分成:(Excution Memory Storage Memory)Executor Memory,User Memory(用户元数据),Reserved Memory(预留内存)
- Executor内存主要有两类:Storage、Execution。Execution Memory:主要用于shuffles,joins,sorts,aggs等算子,Storage Memory:缓存分区数据和广播变量;
- UnifiedMemoryManager统一管理Storage/Excution内存;
- 当Storage空闲,Excution可以借用Storage的内存使用,可以减少spill等操作,Execution使用的内存不能被Storage驱逐;
- 当Execution空闲,Storage可以借用Execution的内存使用,当Execution需要内存时,可以驱逐被Storage借用的内存,直到spark.memory.storageFraction边界;
SparkSQL原理分析
- sparkSQL
- 常见优化器:Catalyst、Adaptive Query Execution、Runtime Filter、DataSource、Codegen
- 执行流程:DataFrame/SQL Query->Analysis->Logical Optimization->Physical Planning
2. Catalyst优化器
- Batch执行策略:Once->只执行依次;FixedPoint->重复执行,知道plan不在改变,或者执行达到固定次数
- 其他:transformDown和transformUp、PushDownPredicates、TableStat从ANALYZE TABLE(采集表)获取对应的算子stat对应的Estimation进行估算、JoinReorder(join优化)等
- Adaptive Query Execution(AQE)
- 流程:每个Task结束会发送MapStatus信息给Driver,Task的MapStatus包含当前Task Shuffle产生的每个Partition的size统计信息。Driver获取到执行完的Stages的MapStatus信息之后,按照MapStatus中的partition大小信息识别匹配一些优化场景,然后对后续未执行的Plan进行优化。
- 目前支持的优化场景:Partition合并,优化shuffle读取,减少reduce task个数; SMJ->BHJ; Skew Join优化;
- Partition合并
- SMJ->BHJ
AQE运行过程中动态获取准确的Join的leftChild、rightChild的实际大小,将SMJ转换为BHJ
- AQE-Optimizing SKew Joins
AQE根据MapStatus信息自动检测是否有倾斜,将打的partition拆分成多个Task进行Join
- Runtime Filter
Runtime Filter减少大表的扫描,Shuffle数据量以参加join的数据量,所以对整个集群IO/网络/CPU有比较大的节省(根据数据量可以直接全表筛选或者统计信息筛选)
- Codegen
- Expression 将表达式中大量虚函数调用亚平到一个函数内部,类似于手写代码
select a*(a+1) from t
- WholeStageCodegen 将同一个Stage中的多个算子压平到一个函数内部进行执行 TableScan——>Fileter——> (一个SQL包含多个Stage的WholeStageCodegen)
select (a+1)*a from t where a=1
- 业界挑战和实践
- Shuffle的稳定性:Uber,LinkedIn,Alibaba,Tencent,Bytedance
- SQL执行性能:Photon(C++实现向量化执行引擎)、OAP/gazelle_plugin
- 参数推荐/作业诊断:Spark参数很多,资源类/Shuffle/Join/Agg/...调参难度大;参数不合理的作业,对资源利用率/Shuffle稳定性/性能影响大;线上作业失败/运行慢,用户排查难度大;自动参数推荐/作业诊断
Shuffle
Shuffle概述
- map阶段、shuffle阶段、reduce阶段
- map阶段,在单机上进行的针对一小块数据的计算过程;
- shuffle阶段,在map阶段的基础上,进行数据移动,为后续的reduce阶段做准备;
- reduce阶段,对移动后的数据进行处理,依然是在单机上处理小部分数据
- shuffle性能重要性
- M*R次网络连接
- 大量的数据移动
- 数据丢失风险
- 可能存在大量的排序操作
- 大量的数据序列化、反序列化操作
- 数据压缩
- 总结:在大数据场景下,数据shuffle表示了不同分区数据交换的过程,不同的shuffle策略性能差异较大。目前在各个引擎中shuffle都是优化的重点,在spark框架中,shuffle是支持spark进行大规模复杂数据处理的基石。
Shuffle算子
-
Shuffle算子分类:spark中会发生shuffle的算子大概可以分成4类:
-
Shuffle应用
val text = sc.textFile("mytextfile.txt")
val counts = text
.flatMap(line => line.split(" "))
.map(word => (word,1))
.reduceByKey(_+_)
counts.collect
- shuffle抽象-宽依赖,窄依赖
- 窄依赖:父RDD的每个分片至少被子RDD中的一个分片所依赖
- 宽依赖:父RDD的分片可能被子RDD的多个分片所依赖
- Shuffle Dependenecy构造
- paritioner 两个接口:numberPartitions,getPartition;经典实现:HashPartitioner
abstract class Partitioner extends Serializabe{
def numPartition:Int
def getPartition(key: Any): Int
}
class HashPartitioner(partitions: Int)extends Partitioner{
require(paritions >=0, s"Number of partitions ($paritions) cannot be negative")
def numPartitions: Int = partitions
def getPartition(key: Any): Int = key match{
case null => 0
case _ => Uils.nonNegtiveMod(key.hashCode, numPartitions)
}
}
- Aggregator createCombiner:只有一个value的时候初始化的方法;mergeValue:合并一个value到Aggregator中;mergeCombiners:合并两个Aggregator
Shuffle过程
- Spark Shuffle历程
- Spark0.8及以前Hash Based Shuffle
- Spark0.8.1为Hash Based Shuffle引入File Consolidation机制
- Spark0.9引入ExternalAppendOnlyMap
- Spark1.1引入Sort Based Shuffle, 但默认仍为Hash Based Shuffle
- Spark1.2默认的Shuffle方式改成Sort Based Shuffle
- Spark1.4引入Tungsten-Sort Based Shuffle
- Spark1.6 Tungsten-Sort Based Shuffle并入Sort Based Shuffle
- Spaek2.0 Hash Based Shuffle退出历史舞台
- Hash Shuffle V1(未优化) 相当于传统的MapReduce,在Map Task过程按照Hash的方式重组Partition的数据,不进行排序。每个Map Task为每个Reduce任务生成一个文件,通常会产生大量的文件(即对应为M×R个中间文件,其中M表示Map Task个数,R表示Reduce Task个数),伴随大量的随机磁盘I/O操作与大量的内存开销。总结下这里存在的两个严重问题:
-
产生大量文件,占用文件描述符,同时引入DiskObjectWriter带来的Writer Handler的缓存也非常消耗内存;
-
如果在Reduce Task时需要合并操作的话,会把数据放在一个HashMap中进行合并,如果数据量较大,很容易引发OOM;
-
Hash Shuffle V2(优化) 针对Hash Shuffle V1存在的问题,Spark 做了改进,引入了 File Consolidation 机制。一个 Executor 上所有的 Map Task 生成的分区文件只有一份,即将所有的 Map Task 相同的分区文件合并,这样每个 Executor 上最多只生成 N 个分区文件。
这样就减少了文件数,但是假如下游 Stage 的分区数 N 很大,还是会在每个 Executor 上生成 N 个文件,同样,如果一个 Executor 上有 K 个 Core,还是会开 K×N 个 Writer Handler,所以这里仍然容易导致OOM。 -
SortShuffle普通运行模式 为了更好地解决上面的问题,Spark 参考了 MapReduce 中 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 过程需要对记录进行排序,所以在性能上有所损失。
- SortShuffle ByPass模式
task会为每个下游task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。
bypass机制与普通SortShuffleManager运行机制的不同在于:第一,磁盘写机制不同; 第二,不会进行排序。
触发bypass条件:shuffle map task的数量小于spark.shuffle.sort.bypassMergeThreshold参数的值(默认200)或者不是聚合类的shuffle算子(比如groupByKey)
Shuffle Read
- Shuffle Read
- 在什么时候获取数据,Parent Stage 中的一个 ShuffleMapTask 执行完还是等全部 ShuffleMapTasks 执行完?
当 Parent Stage 的所有 ShuffleMapTasks 结束后再 fetch。 - 边获取边处理还是一次性获取完再处理?
因为 Spark 不要求 Shuffle 后的数据全局有序,因此没必要等到全部数据 shuffle 完成后再处理,所以是边 fetch 边处理。 - 获取来的数据存放到哪里?
刚获取来的 FileSegment 存放在 softBuffer 缓冲区,经过处理后的数据放在内存 + 磁盘上。
内存使用的是AppendOnlyMap ,类似 Java 的HashMap,内存+磁盘使用的是ExternalAppendOnlyMap,如果内存空间不足时,ExternalAppendOnlyMap可以将 records 进行 sort 后 spill(溢出)到磁盘上,等到需要它们的时候再进行归并 - 怎么获得数据的存放位置?
通过请求 Driver 端的 MapOutputTrackerMaster 询问 ShuffleMapTask 输出的数据位置
- Spark Shuffle调优 主要是调整缓冲区大小,拉取次数重试,重试次数与等待时间,内存比例分配,是否进行排序操作等等\
-
spark.shuffle.file.buffer 参数说明:该参数用于设置shuffle write task的BufferedOutputStream的buffer缓冲大小(默认是32K)。将数据写到磁盘文件之前,会先写入buffer缓冲中,待缓冲写满之后,才会溢写到磁盘。
调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如64k),从而减少shuffle write过程中溢写磁盘文件的次数,也就可以减少磁盘IO次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升。 -
spark.reducer.maxSizeInFlight: 参数说明:该参数用于设置shuffle read task的buffer缓冲大小,而这个buffer缓冲决定了每次能够拉取多少数据。
调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如96m),从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升。 -
spark.shuffle.io.maxRetries and spark.shuffle.io.retryWait spark.shuffle.io.retryWait:huffle read task从shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。(默认是3次)
spark.shuffle.io.retryWait:该参数代表了每次重试拉取数据的等待间隔。(默认为5s) 调优建议:一般的调优都是将重试次数调高,不调整时间间隔。 -
spark.shuffle.memoryFraction: 参数说明:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例。
-
spark.shuffle.manager 参数说明:该参数用于设置shufflemanager的类型(默认为sort)。Spark1.5x以后有三个可选项:
Hash:spark1.x版本的默认值,HashShuffleManager
Sort:spark2.x版本的默认值,普通机制,当shuffle read task 的数量小于等spark.shuffle.sort. bypassMergeThreshold参数,自动开启bypass 机制
tungsten-sort:目的是优化内存和CPU的使用,进一步提升spark的性能。由于使用了堆外内存,而它基于 JDK Sun Unsafe API,故 Tungsten-Sort Based Shuffle 也被称为 Unsafe Shuffle。
它的做法是将数据记录用二进制的方式存储,直接在序列化的二进制数据上 Sort 而不是在 Java 对象上,这样一方面可以减少内存的使用和 GC 的开销,另一方面避免 Shuffle 过程中频繁的序列化以及反序列化。在排序过程中,它提供 cache-efficient sorter,使用一个 8 bytes 的指针,把排序转化成了一个指针数组的排序,极大的优化了排序性能。 -
spark.shuffle.sort.bypassMergeThreshold 参数说明:当ShuffleManager为SortShuffleManager时,如果shuffle read task的数量小于这个阈值(默认是200),则shuffle write过程中不会进行排序操作。
调优建议:当你使用SortShuffleManager时,如果的确不需要排序操作,那么建议将这个参数调大一些 -
spark.shuffle.consolidateFiles: 参数说明:如果使用HashShuffleManager,该参数有效。如果设置为true,那么就会开启consolidate机制,也就是开启优化后的HashShuffleManager。
调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%。
- Shuffle倾斜优化
Shuffle倾斜,不同reduce上的数据不均衡。可能会导致作业运行时间长,Task OOM导致作业失败。
-
提高并行度 优点:足够简单,缺点:只缓解,不根治
-
Spark AQE Skew Join AQE根据shuffle文件统计数据自动检测倾斜数据,将那些倾斜数据的分区打散成小的子分区,然后各自进行join。
- 参数优化 具体情况具体分析调整参数
Shuffle环节
Shuffle触发过程:
Collect Action -> SubmitJob -> GerDependencies -> RegisterShuffle
Register Shuffle时最重要就是根据不同条件创建不同shuffle Handle,然后Map Task进行相应的 Shuffle Write, Reduce Task进行Shuffle read操作。Handle、Writer、Reader可以说是Shuffle三大重要环节。
- Handler
根据条件判断确定Handler类型然后根据Handler类型进行不同类型的Writer
判断条件:
- 不支持mapside combine && parititons<spark.shuffle.sort.bypassMergeThreshold(默认200)
- 不支持 mapside combine && serializer支持relocation&&partition小于2^24
Hanler和Writer对应关系:
BypassMergeSortShuffleHandle -> BypassMergeSortShuffleWriter
SerializedShuffleHandle -> UnsafeShuffleWriter BaseShuffleHandle -> SortShuffleWriter
- Writer
-
BypassMergeShuffleWriter 不需要排序,节省时间;写操作的时候会打开大量文件;类似于Hash Shuffle;
-
UnsafeShuffleWriter 使用类似内存页存储序列化数据,数据写入后不再反序列化
-
SortShuffleWriter 支持combine,需要combine时,使用PartitionedAppendOnlyMap,本质是个HashTable;不需要combine时PartitionedPairBuffer本质是个array
- Reader
- 网络时序图
使用基于netty的网络通信框架
位置信息记录在MapOutputTracker(shuffle元数据信息,Drive中)中
主要发送两种类型的请求:OpenBlocks请求,Chunk请求或Stream请求
-
ShuffleBlockFetchIterator 区分local和remote节省网络消耗
防止OOM: maxBytensInFlight, maxReqsInFlight, maxBlocksInFlightPerAddress, maxReqSizeShuffleToMem, maxAttemptsOnNettyOOM -
External Shuffle Service ESS作为一个存在于每个节点上的agent为所有Shuffle Reader提供服务,从而优化了Spark作业的资源利用率,MapTask在运行结束后正常退出。
- 整体流程
涉及到shuffle 过程,前一个stage 的 ShuffleMapTask 进行 shuffle write, 把数据存储在 blockManager(Driver和Executor端都有) 上面, 并且把数据位置元信息上报到 driver 的 mapOutTrack 组件中, 下一个stage 根据数据位置元信息, 进行 shuffle read, 拉取上个stage 的输出数据。
扩展:Push Shuffle
-
为什么需要Push Shuffle Avg IO size太小,造成大量的随机IO,严重影响磁盘的吞吐;
M×R次读请求,造成大量的网络连接,影响稳定性 Psuh Shuffle的实现:
Facebook: cosco
LinkedIn: magnet
Uber: Zeus
Alibaba: RSS
Tencent: FireStorm
Bytednce: CSS
Spark3.2: push based shuffle -
Magnet 原理
- Spark driver组件,协调整体的shuffle操作
- map任务的shuffle writer过程完成后,增加一个额外的操作push-merge,将数据复制一份推导远程shuffle服务器上
- magnet shuffle service 是一个强化版的ESS。将隶属于同一个Shuffle partition的block,会在远程传输到magnet后被merge到一个文件中
- reduce任务从magnet shuffle service 接收合并好的shuffle数据
容错
- bitmap: 存储已merge的mapper id,防止重复merge
- position offset: 如果本次block没有正常merge,可以恢复到上一个block的位置
- currentMapId: 标识当前正在append的block,保证不同mapper的block能依次append
可靠性
- 如果Map task输出的Block没成功Push到magnet上,并且反复重试依赖失败,则reduce task支持从ESS上拉取原始block数据;
- 如果magnet的block因为重复或者冲突等原因,没有正常完成merge的过程,则reduce task直接拉取未完成merge的block;
- 如果reduce拉取已经merge好的block失败,则会直接拉取merge前的原始block;
- 本质上,magnet中维护了两份shuffle数据的副本
- Cloud Shuffle Service 思想
架构
- Zookeeper WorList服务发现
- CSS Worker [Partition /Disk |HDFS]
- Spark Driver 集成启动CSSMaster
- CSS Master Shuffle 规划/统计
- CSS ShuffleClient Writer Reader
- Spark Executor Mapper+Reducer
写数据流程
读数据流程
AQE
一个Partition会最终对应多个Epoch file, 每个Epoch目前设置是512MB