Spark RDD详解
在Spark学习之路——2.核心组件、概念中我们已经对RDD进行了比较细致的介绍,但是对RDD在Saprk内部起到的作用、还有RDD和其他组件之间的关系没有明确描述,下面我们就以编程的视角,详细地了解一下RDD的设计和运行原理。
一、总述
RDD是Spark的数据抽象,一个RDD是一个只读的分布式数据集,可以通过转换操作在转换过程中对RDD进行各种变换。
一个复杂的Spark应用程序,就是通过一次次的RDD操作组合完成的。RDD包含两种类型操作,一种是转换操作,另一种是行动操作。
Spark采用了惰性机制,在代码中遇到转换操作时,不会立刻计算,而是记录转换的方式,当遇到行动操作时,才会触发从头到尾的计算操作。
在遇到行动操作时,就会生成一个作业,该作业会被划分为多个阶段,每个阶段包含多个任务,各个任务又会被分配到不同的节点上被并行计算。
二、RDD设计
1.设计背景
MapReduce框架会把中间结果写入HDFS中,这样会带来大量的I/O操作,降低了性能。在一些比较复杂的算法、机器学习领域,需要进行迭代运算,会不断地进行重用中间结果,显然,MapReduce不适合这种情景的数据处理。
RDD的出现就是为了解决这一问题,它提供了一种通用的数据抽象,我们不用管底层数据是什么样的结构,只需要将逻辑处理转化为一些列的transform操作,不同的RDD之间转换操作形成依赖关系,可以实现管道化操作,从而避免了中间结果的存储,提高了数据处理性能。
2.RDD设计内容
(1)RDD是一个分布式的对象集合。
提到分布式,我们可以想象到它是由分布在各个节点的数据片段组合而成,所以说,它本质上是一个只读的分区记录集合(这里的分区就是一个数据集片段)。只读意味着不能被修改(并非完全不可修改,只是有条件的),所以一个新的RDD的产生只有两种方式:①通过物理存储的数据读取创建RDD;②通过一个已有的RDD的转换(transform)操作得到新的RDD。
(2)RDD的操作类型分为两大类:
Action行动、Transformation转换。前者执行计算并得到指定形式的输出(这里接收RDD对象返回非RDD对象),而后者就是指定RDD之间的依赖关系(这里接收RDD对象,返回RDD对象)。
(3)RDD提供的转换接口很简单,都是类似于map、filter、groupBy、join等粗颗粒度的操作。因此RDD适合数据集中元素执行相同操作的批处理应用、并行计算,而不适合需要异步处理、细颗粒度操作的应用,比如说Web应用、增量式网页爬虫等。
3.RDD典型的执行过程
读取外部数据(或内存中数据)创建RDD(一个或多个)——RDD经过转换操作形成一系列的RDD——最后一个RDD经过Action操作进行处理,将结果输出(惰性执行)
可以通过小例子来进一步了解RDD的执行过程
表现为存在一个父RDD的一个分区对应一个子RDD的多个分区
典型的操作包括:groupByKey、SortByKey等,对于join操作,非协同划分就属于宽依赖
三、RDD特性
1.高效的容错性:
在RDD设计中,数据只读不可修改,如果修改必须通过父RDD转换到子RDD,因此在不同的RDD之间建立了“血缘关系”。对比于其他的分布式共享内存等都需要通过冗余的方式实现容错机制。所以说,RDD是一种天生就具有容错机制的特殊集合,只需要通过父子依赖关系重新计算得到丢失的分区(数据片段)来实现容错。无需回滚整个系统,减少了数据复制的开销,而且重新计算的过程可以并行进行(被管道化),实现了高效的容错机制。
2.中间结果持久化到内存,减少了读写磁盘的开销。
3.存放的数据可以是java对象,避免了不必要的对象序列化和反序列化。
四、RDD之间的依赖
1.总述
RDD中不同的操作会使得不同的RDD分区之间产生不同的依赖关系。DAG调度器根据RDD之间的依赖关系,把DAG图划分为若干个阶段。
RDD中的依赖关系分为窄依赖(Narrow Dependency)和宽依赖(Wide Dependency),二者的主要区别在于是否包含Shuffle的操作。
2.回顾MapReduce的shuffle,了解Spark的shuffle过程
(1)MapReduce
在MapReduce中我们已经学习过Shuffle的过程,总结而言,shuffle过程就是把Map阶段产生的中间结果分发到Reduce端,其中会产生大量的网络数据分发,产生大量的网络传输开销。
(2)Spark
在Spark中例如reduceByKey(func)操作,就包含了shuffle过程。
①首先,在Map端的shuffle write操作方面:每个Map任务(m)会根据Reduce任务的数量(r)创造出相应的桶(bucket),所以桶的数量为m×r,由于数量比较多,一般是多个桶写入一个文件的方式来存储,Map任务产生的结果会根据分区算法(算法可自定义,也可选择默认)填充入桶中,当Reduce任务启动时,他会根据自己的任务id和对应的map任务的id从远端或本地得到对应的桶,作为任务的输入数据。
②然后在Reduce阶段的Shuffle Fetch方面:Spark假定在大多数场景下,shuffle操作是不必要的,因此,Spark在Reduce端不使用归并和排序,而是使用Aggregator的机制。
Aggregator本质上是一个HashMap,里面的元素是<K,V>形式,以wordcount为例:他会从map端拉取每一个(key,value),如果HashMap中没有这个key,则直接插入,如果已经存在key,则在原来value基础上加上新的value值。这种来一个处理一个的方式避免了归并和排序操作。
③但是必须要注意的一点是:reduce任务具有的内存,必须足以存放处理的key、value值,否则会产生内存溢出的问题。在Spark给出的文档中会要求Reduce的个数尽可能多,但是Reduce多了,对应产生的桶(所需的文件)就多了,出现了冲突的地方。最终,为了减少内存的使用,只能将Aggregator的操作从内存移到磁盘进行。也就是说,虽然Spark是基于内存的计算框架,在shuffle过程还是要依靠于磁盘。
3.宽依赖和窄依赖
上面提到宽窄依赖主要区别于是否包含shuffle操作,窄依赖不包含shuffle操作,而宽依赖则包含shuffle操作。
(1)窄依赖(不包含shuffle):
表现为一个父RDD分区对应一个子RDD分区,或多个父RDD分区对应一个子RDD分区。
窄依赖常见的操作包括:map、filter、union等,对于join而言,对输入进行协同划分则属于窄依赖。
协同划分:多个父RDD的某一分区的所有"Key"落在子RDD的同一个分区内,不会产生同一个父RDD的某一分区,落在子RDD的两个分区的情况。
下面是窄依赖的一些图示:
(2)宽依赖(包含shuffle操作)
表现为存在一个父RDD的一个分区对应一个子RDD的多个分区
典型的操作包括:groupByKey、SortByKey等,对于join操作,非协同划分就属于宽依赖
下面对宽依赖的图示:
(3)数据恢复对比
相对而言,两种依赖中,窄依赖的失败恢复效率更高,它只需要根据父RDD分区重新计算丢失的分区就可以,不需要重新计算所有分区,而且可以并行地在不同节点进行重新计算,此外,Spark还提供了数据检查点和记录日志,用于持久化中间RDD,使得在恢复时不需要从最开始进行,Spark会对数据检查点开销和重新计算RDD分区的开销进行对比,从而自动选择最优的策略。
五、Spark作业的阶段划分
(1)划分概述
Spark根据DAG图中的RDD依赖关系,将一个作业分成多个阶段。
(2)窄依赖关系对于作业的优化是非常有利的
如果子RDD分区到父RDD分区是窄依赖的,就可以将两个fork/join(并行执行任务的框架)【把计算fork到每个RDD分区,完成计算后对各个分区得到的结果进行join操作】合并,如果连续的RDD操作都是窄依赖,则可以把多个fork/join合并为一个,通过这种操作不但减少了join操作,还无需保存很多个中间结果RDD,这样可以很大程度上提高性能。
这个合并过程也被称为:流水线(pipeline)优化。
(3)划分方式
①Spark分析RDD之间的依赖关系生成DAG
②再分析各个RDD的分区之间的依赖关系,再来决定如何划分阶段。
详细划分方法:
在DAG中进行反向解析,遇到宽依赖就断开(因为宽依赖涉及到shuffle操作,无法实现流水线处理),遇到窄依赖就把当前的RDD加入到当前的阶段中(因为窄依赖不会涉及到shuffle操作,可以实现流水线化操作)。
例如:下面图示表示了根据RDD分区之间的依赖关系来划分阶段
对此图进行说明,
从HDFS读入数据生成3个不同的RDD(即A,C,E),通过一些列操作将计算结果保存会HDFS中。在图中我们可以看出,在依赖图中,从RDD A到RDD B的转换和从RDD B、F到RDD G的转换,都是宽依赖,因此可以在宽依赖处断开,得到三个阶段,即阶段①,②,③。从图中我们可以看到,阶段②中RDD之间分区关系都为窄依赖,所以这些操作就可以变为流水线操作。(比如,分区7可以转化到分区9再到分区13,而不用管分区8是怎么向后运行的),这样的流水线执行大大提高了计算的效率。
由上面论述可知,当把一个DAG图划分为多个阶段后,每个阶段RDD分区之间没有shuffle的依赖关系的任务集合,每个任务集合都会被提交到任务调度器进行处理,然后任务调度器会把任务分发给Executor来执行。
六、RDD在Spark中的运行过程
这一部分在上一节也说明过,这里再来系统地复习一下:
(1)创建RDD对象;
(2)SparkContext负责计算RDD之间的依赖关系,构建DAG;
(3)负责将DAG分解为多个阶段,每个阶段包含多个任务,每个任务会被任务调度器分给各个工作节点(Worker Node)上的Executor执行。
参考:《Spark编程基础(Scala版)》