RDD依赖与DAG原理
Spark根据计算逻辑中的RDD的转换与动作生成RDD的依赖关系,同时这个计算链也形成了逻辑上的DAG
RDD的转换
以WordCount为例,分析一下RDD的转换细节
val lines = sc.textFile("/data/words.txt")
val count = lines.flatMap(line => line.split(" ")).map(word => (word,1)).reduceByKey(_+_)
count.collect
1、首先从HDFS中读取文件,产生一个HadoopRDD,然后进行RDD转换,转换结果为MapPartitionsRDD。也就是说,lines实际上是一个MapPartitionsRDD,其父RDD是HadoopRDD。如下图所示。
2、flatMap操作将lines中的所有行,以空格切分,然后返回一个单词列表,以每个单词为元素的列表保存到新的MapPartitionsRDD。
3、将第二行生成的MapPartitionsRDD再次经过map操作将每个单词word转化为(word,1)的二元组,返回一个新的MapPartitionsRDD包含这些元祖。
4、reduceByKey操作生成一个ShuffledRDD。
5、collect动作将提交Job开始执行,到此Application结束。
可以使用“count.toDebugString”进行上述RDD转换过程的验证,如下所示。
可以得出结论,WordCount代码中RDD经过了如下转换:
HadoopRDD -> MapPartitionsRDD -> MapPartitionsRDD -> MapPartitionsRDD -> SHuffledRDD
除了根RDD HadoopRDD,其他RDD都有父RDD,表示该RDD从哪里转换而来。称为RDD间的依赖。
RDD的依赖关系
以RDD分区的角度来看,根据子RDD依赖父RDD的分区的不同,将这种关系划分为两种:窄依赖和宽依赖。
1、窄依赖
窄依赖指的是每一个父RDD的分区最多被子RDD的一个分区使用。
2、宽依赖
宽依赖指的是多个子RDD的分区会依赖同一个父RDD的分区。换句话说,一个父RDD的分区会被多个子RDD所使用。
对于map和filter形式的转换来说,它们只是将各个分区的数据根据转换的规则进行转化,在处理某一个分区时,不关注其他分区,可以简单的认为只是将各个分区中的数据从一个形式转换到另一个形式。故他们是窄依赖。
对于union,只是将多个RDD合并为一个,父RDD的分区不会有任务的变化,可以认为只是把父RDD的分区进行简单赋制与合并。故为窄依赖。
对于join,如果每个分区仅仅和已知的、特定的分区进行join,那么这个依赖关系是窄依赖。对于这种有规则的数据的join,并不会引入昂贵的shuffle。对于窄依赖,由于RDD每个分区依赖固定数量的父RDD的分区,因此可以通过一个计算任务来处理这些分区,并且这些分区相互独立,这些计算任务也就可以并行执行了。
对于groupByKey,子RDD的所有分区会依赖父RDD的所有分区,子RDD的分区是父RDD所有分区shuffle的结果,因此这两个RDD是不能通过一个计算任务来完成的,故为宽依赖。同样,对于需要父RDD的所有方法呢去进行join的转换,也需要shuffle,这类join的依赖为宽依赖。
可以根据源码加深理解。Dependency源码如下:
abstract class Dependency[T] extends Serializable {
def rdd:RDD[T]
}
其中,rdd便是依赖的父RDD。Dependency又有两个子类:NarrowDependency和ShuffleDependency,分别表示窄依赖与宽依赖。
对于窄依赖的实现又有两个:
1、OneToOneDependency:一对一依赖,如map、filter操作。
2、RangeDependency:范围依赖,如union操作。union操作返回UnionRDD,UnionRDD是把多个RDD合并为一个RDD,即每个父RDD的分区相对顺序保持不变,只不过每个父RDD在UnionRDD中的分区起始位置不同。源码如下:
class RangeDependency[T](rdd: RDD[T], inStart: Int, outStart: Int, length: Int) extends NarrowDependency[T](rdd) {
override def getParents(partitionId: Int): List[Int] = {
if (partitionId >= outStart && partitioned < outStart + length) {
List(partitionId - outStart + inStart)
} else {
Nil
}
}
}
其中,inStart是父RDD中分区的起始位置,outStart是在UnionRDD中的起始位置,length是父RDD中分区的数量。
而宽依赖的实现只有一种:ShuffleDependency。由ShuffledRDD的getDependecies方法创建ShuffleDependency。如下图所示。
override def getDependencies: Seq[Dependency[_]] = {
val serializer = userSpecifiedSerializer.getOrElse {
val serializerManager = SparkEnv.get.serializerManager
if (mapSideCombine) {
serializerManager.getSerializer(implicitly[ClassTag[K]],implicitly[ClassTag[C]])
} else {
serializerManager.getSerializer(implicitly[ClassTag[K]],implicitly[ClassTag[V]])
}
}
List(new ShuffleDependency(prev, part, serializer, keyOrdering, aggregator, mapSideCombine))
ShuffleDependency构造器参数分别表示:父RDD、用于Shuffle输出的分区器、序列化器、key排序比较器、map/reduce端的聚合操作、map端的部分合并操作。
DAG的生成
原始的RDD(s) 通过一系列转换就形成了DAG。RDD之间的依赖关系,包含了RDD由哪些父RDD转换而来和它依赖了父RDD的哪些分区,此两点是DAG的重要属性。
DAG可以认为这些RDD之间形成了Lineage(血统)。Lineage能够保证一个RDD被计算前,他所依赖的父RDD都已经完成了计算,同时也实现了RDD的容错性。即如果一个RDD的部分或者全部的计算结果丢失,那么只需重新计算这部分丢失的数据。
DAG的阶段划分
Spark是如何根据DAG生成计算任务呢?
阶段划分
根据依赖关系将DAG划分为不同的阶段(Stage)。对于窄依赖,由于分区依赖关系的确定性,分区的转换处理可以在同一个线程里完成,窄依赖被划分到同一个执行阶段;对于宽依赖,由于Shuffle的存在,只能在父RDD Shuffle处理完成后,才能开始接下来的计算。因此宽依赖就是阶段划分的依据,具体化分规则:从后往前,遇到宽依赖切割为新的Stage。每个Stage由一组并行地Task组成。如下图所示。
阶段划分从RDD G开始中,G依赖于B和F,先处理B还是F是随机的。假设先处理B,由于G和B是窄依赖,可以划分在一个Stage(Stage3)中。再处理F,G和F是宽依赖,所以F和G划分到不同的Stage中,F在Stage2,G在Stage3。然后处理B的依赖A,发现是宽依赖,所以将A再划分到Stage1中。再看F的依赖D、E,属于窄依赖,合并到Stage2,至此,阶段划分结束。
可以总结一个规律:从最后一个RDD往前找,遇到Shuffle便增加一个阶段,否则加入到现有阶段中。
Stage划分原理小结:
1、根据最后一个RDD创建ResultStage,并压入栈中(push(stage.rdd))。
2、弹出栈顶RDD,获取所有父RDD。判断父RDD是否宽依赖,如果是,则新创建一个ShuffleMapStage,否则将父RDD入栈并认为该父RDD属于当前Stage。
3、重复第二步直到栈空。
任务调度
任务调度又分为两个主要模块:DAGScheduler和TaskScheduler。它们负责将用户提交的计算任务按照DAG划分为不同的阶段并且将不同阶段的计算任务提交到集群进行最终的计算,整个过程如下图所示:
具体涉及三个主要类:
DAGScheduler:前面图示中的源码即是它。负责分析用户提交的应用,并根据计算任务的依赖关系建立DAG,且将DAG划分为不同的Stage,每个Stage可并发执行一组Task。
TaskScheduler:DAGScheduler将划分完成的Task(一组任务TaskSet)提交到TaskScheduler,TaskScheduler通过Cluster Manager在集群中的某个Worker的Executor上启动任务。
SchedulerBackend:每个TaskScheduler对应一个SchedulerBackend,作用是分配当前可用的资源,具体就是向当前等待分配计算资源的Task分配计算资源(Executor),并在分配的Executor上启动Task,完成计算的调度过程。
Spark中的任务分为两种:ShuffleMapTask和ResultTask。
1、ShuffleMapTask:任务所在Stage不是最后一个Stage,即ShuffleMapState。对于非最后的Stage,会根据每个Stage的分区数量来生成ShuffleMapTask。ShuffleMapTask会根据下游Task的分区数量和Shuffle策略来生成一系列文件。
2、ResultTask:任务所在Stage是最后一个Stage,即ResultStage。对于最后一个Stage,会根据生成结果的分区来生成与分区数量相同的ResultTask,然后ResultTask将计算结果汇报到Driver端。
Spark Shuffle原理
为什么需要Shuffle?
因为需要将具有某种共同特征的一类数据汇聚到一个节点上进行计算。
什么是Shuffle?
与MapReduce的Shuffle类似,即在分区之间重新分配数据,将数据打乱重新汇聚到不同节点的过程。还是以WordCount为例。
对一个分区上进行map和flatMap可以如同流水线一样只在同一台机器上进行,不存在多个节点之间的数据移动,而reduceByKey这样的操作,需要将相同的key做聚合操作。上图中Stage1中按key做hash分配三个分区做reduce操作,对于Stage2中任意一个分区而言,其输入可能存在与上游Stage1中每一个分区中,因此需要从上游的每一个分区所在的机器上拉取数据,这个过程称为Shuffle。
Spark Shuffle分为Write和Read两个过程。在Spark中负责shuffle过程的执行、计算、处理的组件主要是ShuffleManager,其实一个trait,负责管理本地以及远程的block数据的shuffle操作。
主要方法解释:
registerShuffle:注册ShuffleDependency(宽依赖),同时获取一个ShuffleHandle并将其传递给任务。
getWriter:返回ShuffleWriter用于Shuffle Write过程。对一个分区返回一个ShuffleWriter,并由executors上的ShuffleMapTask任务调用。
getReader:返回ShuffleReader用于Shuffle Read过程。
在早期版本中,ShuffleManager的实现者是HashShuffleManager,而新版本中只有SortShuffleManager。前者存在的问题:会产生大量的磁盘文件,进而有大量的磁盘IO操作,比较影响性能。SortShuffleManager相对来说,有了一定的改进。主要就在于,每个Task在Shuffle Write操作时,虽然也会产生较大的磁盘文件,但最后会将所有的临时文件合并成一个磁盘文件,因此每个Task就只有一个磁盘文件。在下一个Stage的Shuffle Read Task拉取自己数据的时候,只要根据索引拉取每个磁盘文件中的部分数据即可。
Shuffle Write
Shuffle Write操作发生在ShuffleMapTask#runTask中
Shuffle Write的处理逻辑会放到该ShuffleMapStage的最后,因为rdd.iterator() 将调用compute方法,并且递归调用父RDD的compute() 方法。
因为Spark以Shuffle发生与否来划分Stage,所以该Stage的final RDD每输出一个record就将其分区并持久化。
ShuffleWriter有三种实现:
BypassMergeSortShuffleWriter
UnsafeShuffleWriter
SortShuffleWriter
三种Writer又分别对应不同的Handle:
BypassMergeSortShuffleWriter -> BypassMergeSortShuffleHandle
UnsafeShuffleWriter -> SerializedShuffleHandle
SortShuffleWriter -> BaseShuffleHandle
根据ShuffleHandle来决定使用不同的ShuffleWrite,在构建ShuffleDependency时会构建ShuffleHandle。创建时在registerShuffle方法中,有着对ShuffleHandle使用的条件约束。
1)如果分区小于spark.shuffle.sort.bypassMergeThreshold(默认200),且map端没有聚合操作,使用BypassMergeSortShuffleHandle,否则进入下一个条件。
2)如果map端没有聚合操作,且Serializer支持重定位(即使用KryoSerializer),且分区数目小于16777216(最大分区号)时使用SerializedShuffleHandle。否则进入下一条件。
3)以上条件都不满足时使用BaseShuffleHandle。对应的ShuffleWrite是SortShuffleWriter,这种形式的支持map端聚合操作,同时支持排序。 这种是最通用的Writer。 HashShuffleWriter的主要弊端是产生的临时文件太多,那么SortShuffleWriter使相同的ShuffleMapTask公用一个输出文件,然后创建一个索引文件对这个文件进行索引。
Shuffle Read
Shuffle Read操作发生在ShuffleRDD#compute方法中,意味着Shuffle Read可以发生在ShuffleMapTask和ResultTask两种任务中。
每个Stage的上边界,要么需要从外部存储读取数据,要么需要读取上一个Stage的输出,而下边界要么需要写入本地文件系统(Shuffle),以供下一个Stage读取,要么是最后一个Stage,需要输出结果。
除了需要从外部存储读取数据和RDD已经持久化(Cache、Checkpoint),一般Task都是从ShuffledRDD的Shuffle Read开始的。
ShuffleManager#getReader实例化一个BlockStoreShuffleReader。
BlockStoreShuffleReader#read首先实例化了ShuffleBlockFetcherIterator对象。
图中“mapOutputTracker.getMapSizeByExecutorId”返回存储数据位置的元数据。
blocksByAddress指出了数据是来自哪个节点的哪些block,并且block的数据大小是多少。
从代码可以看出:
1)首先分区是本地还是远程blocks,返回远程请求FetchRequest加入到fetchRequests队列中。
2)从fetchRequests取出远程请求,并使用sendRequest方法发送请求,获取远程数据。
3)获取本地blocks
1、本地读取
fetchLocalBlocks() 负责本地blocks的获取,在上图的splitLocalRemoteBlocks中,已经将本地的blocks列表存入了localBlocks。
2、远程读取
调用fetchUpToMaxBytes() 来获取远程数据。
从fetchrequests中取出FetchRequest,并调用了sendRequest() 方法。sendRequest() 向远程节点发起读取block的请求。
RDD优化
RDD持久化
之所以Spark迭代速度非常快的原因之一便是不同的操作会在内存持久化一个数据集。当持久化一个RDD后,每一个节点都计算的分区结果保存在内存中,使得该数据集可以在后续其他操作中可以重用,可以说缓存是构建Spark迭代计算和交互式查询的关键。
通过cache() 和persist() 进行持久化,cache() 的本质还是persist() 。
可选存储级别如下表所示。
存储级别 | 描述 |
---|---|
MEMORY_ONLY | 默认的级别,将RDD作为非序列化的对象存储JVM中。如果RDD不能被内存装下,一些分区将不会被缓存,并且在需要的时候被重新计算。 |
MEMORY_AND_DISK | 将RDD作为非序列化的对象存储在JVM中。如果RDD不能被与内存装下,超出的分区将被保存在硬盘上,并且在需要的时候被读取。 |
MEMORY_ONLY_SER | 将RDD作为序列化的对象进行存储(每一分区占用一个字节数组)。通常来说,这比将对象反序列化的空间利用率更高,尤其当使用fast serializer,但在读取时会比较占用CPU |
MEMORY_AND_DISK_SER | 与MEMORY_ONLY_SER相似,但是把超出内存的分区存储在硬盘上而不是在每次需要的时候重新计算。 |
DISK_ONLY | 只将RDD分区存储在硬盘上 |
DISK_ONLY_2、…等带2的 | 与上述的存储级别一样,但是将每一个分区都复制到两个集群节点上。 |
1、选择存储级别
默认情况下,性能最高的是MEMORY_ONLY,但前提是内存必须足够大,可以绰绰有余的存放下整个RDD的所有数据。在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果RDD中数据比较多时(比如几十亿),直接用这种持久化级别,会导致JVM的OOM内存溢出异常。
如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数而已,大大减少了对象数量,并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果RDD中数据量过多的话,还是可能会导致OOM内存溢出的异常。
如果纯内存的级别都无法使用,那么建议使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略,因为既然到了这一步,就说明RDD的数据量很大,内存无法完全放下。序列化后的数据比较少,可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中,内存缓存不下才会写入磁盘。
通常不建议使用DISK_ONLY和后缀为_2的级别:因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有RDD。后缀为_2的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性,否则不建议使用。
2、使用RDD缓存的时机
每个RDD的compute执行时,将判断缓存的存储级别。如果指定过存储级别则读取