一. RDD设计背景
程序运行阶段会涉及很多迭代算法, 这些场景的共同之处是, 不同计算阶段会重用中间结果, 即一个阶段的输出作为下一个阶段的输入. MapReduce在处理这种情况的时候更多的是把中间结果写入到HDFS中, 这种操作会带来大量的数据复制, 磁盘IO和序列化开销. RDD就是为了满足这种需求出现的, 它提供一个抽象的数据架构, 不必担心底层数据的分布式特性, 只需将具体的应用逻辑表达为一系列转换处理, 不同的RDD之间的转换操作形成依赖关系, 可以实现管道化, 从而避免了中间结果的存储, 大大降低了数据复制, 磁盘IO和序列化开销.
二. RDD概念
一个RDD是一个分布式对象集合, 本质上是一个只读的分区记录集合. 一个RDD可以分成多个分区, 每个分区可以在不同的集群节点上被保存, 从而实现并行处理.
RDD提供一种高度受限的共享内存模型, 即RDD是只读的记录分区集合, 不能直接修改, 只能基于稳定的物理存储中的数据集来创建RDD, 或者通过其他RDD转换来得到新的RDD.
RDD的数据运算有两种类型, 分别是 "行动" 和 "转换". 前者用于执行计算并指定输出形式, 后者指定RDD之间的相互依赖关系.
"行动" 和 "转换" 类型的区别是转换操作(比如map、filter、groupBy、join等)接受RDD并返回RDD, 而行动操作(比如count、collect等)接受RDD但是返回非RDD(即输出一个值或结果).
RDD提供的转换接口都非常简单,都是类似map、filter、groupBy、join等粗粒度的数据转换操作,而不是针对某个数据项的细粒度修改。因此,RDD比较适合对于数据集中元素执行相同操作的批处理式应用,而不适合用于需要异步、细粒度状态的应用,比如Web应用系统、增量式的网页爬虫等。
三. RDD执行实例
RDD典型的执行过程如下:
- RDD读入外部数据源(或者内存中的集合)进行创建
- RDD经过一系列的“转换”操作,每一次都会产生不同的RDD,供给下一个“转换”使用
- 最后一个RDD经“行动”操作进行处理,并输出到外部数据源(或者变成Scala集合或标量)
需要说明的是,RDD采用了惰性调用,即在RDD的执行过程中(如图9-8所示),真正的计算发生在RDD的“行动”操作,对于“行动”之前的所有“转换”操作,Spark只是记录下“转换”操作应用的一些基础数据集以及RDD生成的轨迹,即相互之间的依赖关系,而不会触发真正的计算。
如下图所示,在输入中逻辑上生成A和C两个RDD, 经过一系列"转换操作", 逻辑上生成"F"这个RDD, 之所以说是逻辑上, 是因为这个时候计算并没有发生. Spark只是记录了RDD之间的依赖关系. 当F要进行输出时, 就会执行"行动操作". Spark才会根据RDD的依赖关系生成DAG, 并从起点开始真正的计算.
上述这一系列操作称为"血缘关系", 正是因为"血缘关系"RDD的系列操作才得以实现"管道化". 这样一个操作得到的结果不需要保存为中间数据, 而是直接管道流入到下一个操作进行处理. 同时, 这种通过血缘关系把一系列操作进行管道化连接的设计方式, 也使得管道中每次操作的计算变得相对简单.
例如, 一个spark应用程序, 基本是基于RDD的一系列计算操作, 具体如下所示:
fileRDD = sc.textFile('hdfs://localhost:9000/test.txt')
def contains(line):
return 'hello world' in line
filterRDD = fileRDD.filter(contains)
filterRDD.cache()
filterRDD.count()
第1行代码从HDFS文件中读取数据创建一个RDD.
第2,3行代码定义一个过滤函数.
第4行代码吧fileRDD进行转换操作得到一个新的RDD, 即filterRDD.
第5行代码表示filterRDD进行持久化, 把它保存到内存或磁盘中(这里采用cache接口把数据集保存在内存中), 方便后续重复使用. 当数据被反复访问时(比如查询一些热点数据,或者运行迭代算法),这是非常有用的,而且通过cache()可以缓存非常大的数据集,支持跨越几十甚至上百个节点.
第6行代码中的count()是一个行动操作,用于计算一个RDD集合中包含的元素个数.
所有程序执行的过程如下:
- 创建这个Spark程序的执行上下文,即创建SparkContext对象
- 从外部数据源(即HDFS文件)中读取数据创建fileRDD对象
- 构建起fileRDD和filterRDD之间的依赖关系,形成DAG图,这时候并没有发生真正的计算,只是记录转换的轨迹
- 执行到第6行代码时,count()是一个行动类型的操作,触发真正的计算,开始实际执行从fileRDD到filterRDD的转换操作,并把结果持久化到内存中,最后计算出filterRDD中包含的元素个数
四. RDD特性
(1) 高效的容错机制. 现有的分布式计算框架, 为了实现容错, 往往会在集群节点之间进行数据复制或记录日志, 也就是在节点之间发生大量的数据传输, 这会带来很大的开销. 在RDD的设计中, 数据只读, 不可修改, 如果需要修改数据, 必须从父RDD转换到子RDD, 由此在不同的RDD之间建立了血缘关系. 所以RDD是一种天生具有容错机制的特殊集合, 不需要冗余数据来实现容错. 只需要通过RDD的父子依赖关系重新计算得到丢失分区来实现容错.
(2) 中间结果持久化到内存. 数据在内存中的多个RDD之间进行传递, 不需要"落盘", 避免不必要的读写磁盘开销.
(3) 存放的数据可以是Java对象, 避免了不必要的对象序列化和反序列化开销.
五. RDD之间的依赖关系
RDD中不同的操作会使得不同RDD中的分区会产生不同的依赖。RDD中的依赖关系分为窄依赖(Narrow Dependency)与宽依赖(Wide Dependency).
窄依赖表现为一个父RDD的分区对应于一个子RDD的分区,或多个父RDD的分区对应于一个子RDD的分区. 比如下图中RDD1是RDD2的父RDD,RDD2是子RDD,RDD1的分区1,对应于RDD2的一个分区(即分区4);再比如,RDD6和RDD7都是RDD8的父RDD,RDD6中的分区(分区15)和RDD7中的分区(分区18),两者都对应于RDD8中的一个分区(分区21).
宽依赖则表现为存在一个父RDD的一个分区对应一个子RDD的多个分区。比如图9-10(b)中,RDD9是RDD12的父RDD,RDD9中的分区24对应了RDD12中的两个分区(即分区27和分区28).
总体而言,如果父RDD的一个分区只被一个子RDD的一个分区所使用就是窄依赖,否则就是宽依赖。窄依赖典型的操作包括map、filter、union等,宽依赖典型的操作包括groupByKey、sortByKey等。对于连接(join)操作,可以分为两种情况。
(1) 对输入进行协同划分, 属于窄依赖. 所谓协同划分是指是指多个父RDD的某一分区的所有“键(key)”,落在子RDD的同一个分区内,不会产生同一个父RDD的某一分区,落在子RDD的两个分区的情况.
(2) 对输入做非协同划分,属于宽依赖.
对于窄依赖的RDD,可以以流水线的方式计算所有父分区,不会造成网络之间的数据混合。对于宽依赖的RDD,则通常伴随着Shuffle操作,即首先需要计算好所有父分区数据,然后在节点之间进行Shuffle。
窄依赖和宽依赖的区别:
Spark的这种依赖关系设计,使其具有了天生的容错性,大大加快了Spark的执行速度。因为,RDD数据集通过“血缘关系”记住了它是如何从其它RDD中演变过来的,血缘关系记录的是粗颗粒度的转换操作行为,当这个RDD的部分分区数据丢失时,它可以通过血缘关系获取足够的信息来重新运算和恢复丢失的数据分区,由此带来了性能的提升。
相对而言,在两种依赖关系中,窄依赖的失败恢复更为高效,它只需要根据父RDD分区重新计算丢失的分区即可(不需要重新计算所有分区),而且可以并行地在不同节点进行重新计算。
对于宽依赖而言,单个节点失效通常意味着重新计算过程会涉及多个父RDD分区,开销较大。
此外,Spark还提供了数据检查点和记录日志,用于持久化中间RDD,从而使得在进行失败恢复时不需要追溯到最开始的阶段。在进行故障恢复时,Spark会对数据检查点开销和重新计算RDD分区的开销进行比较,从而自动选择最优的恢复策略。
六. 阶段划分
Spark通过分析各个RDD的依赖关系生成了DAG,再通过分析各个RDD中的分区之间的依赖关系来决定如何划分阶段,具体划分方法是:在DAG中进行反向解析,遇到宽依赖就断开,遇到窄依赖就把当前的RDD加入到当前的阶段中;将窄依赖尽量划分在同一个阶段中,可以实现流水线计算.
如下图所示, 假设从HDFS中读入数据生成3个不同的RDD(即A C 和 E), 通过一系列转换操作后再将计算结果保存到HDFS. 对DAG进行解析时, 在依赖图中进行反向解析, 由于从RDD A到RDD B的转换以及从RDD B和F 到 RDD G的转换, 都属于宽依赖, 因此,在宽依赖处断开后可以得到三个阶段,即阶段1、阶段2和阶段3。可以看出,在阶段2中,从map到union都是窄依赖,这两步操作可以形成一个流水线操作,比如,分区7通过map操作生成的分区9,可以不用等待分区8到分区9这个转换操作的计算结束,而是继续进行union操作,转换得到分区13,这样流水线执行大大提高了计算的效率。
由上述论述可知,把一个DAG图划分成多个“阶段”以后,每个阶段都代表了一组关联的、相互之间没有Shuffle依赖关系的任务组成的任务集合。每个任务集合会被提交给任务调度器(TaskScheduler)进行处理,由任务调度器将任务分发给Executor运行。
在Spark中,一个应用(Application)由一个任务控制节点(Driver)和若干个作业(Job)构成,一个作业由多个阶段(Stage)构成,一个阶段由多个任务(Task)组成.
七. RDD运行过程
通过上述对RDD概念、依赖关系和阶段划分的介绍,结合之前介绍的Spark运行基本流程,这里再总结一下RDD在Spark架构中的运行过程:
- (1) 创建RDD对象
- (2) SparkContext负责计算RDD之间的依赖关系,构建DAG
- (3) DAGScheduler负责把DAG图分解成多个阶段,每个阶段中包含了多个任务,每个任务会被任务调度器分发给各个工作节点(Worker Node)上的Executor去执行