说明:本文参考郭景瞻的《图解Spark:核心技术与案例实战》
一、RDD的实现
1、作业调度
当对RDD执行“转换操作”时,调度器(DGAScheduler)会根据RDD的血统来构建由若干调度阶段(State)组成的有向无环图(DAG),每个调度阶段包含尽可能多的连续“窄依赖”转换。调度器按照有向无环图顺序进行计算,并最终得到目标RDD。
调度器(TaskScheduler)向各节点分配任务采用延时调度机制并根据数据存储位置来确定(数据本地性:移动计算而非移动数据)。若一个任务需要处理的某个分区刚好存储在某个节点的内存中,则该任务会分配给该节点;如果在内存中不包含该分区,调度器会找到包含该RDD的最佳位置,并把任务分配给所在节点。
2、内存管理
Spark从大的方向上提供了两种持久化RDD的存储策略:一是存在内存中;二是存储在磁盘中。
对于内存使用LRU回收算法来进行管理,当计算得到一个新的RDD分区,但没有足够空间来存储时,系统会从最近最少使用的RDD中回收其一个分区的空间。除非该RDD是新分区对应的RDD,这种情况下Spark会将旧的分区继续保留在内存中,防止同一个RDD的分区被循环调入和调出。这点很关键,因为大部分的操作会在一个RDD的所有分区上进行,那么很有可能已经存在内存中的分区将再次被使用。
3、检查点支持(Checkpoint)
虽然血统可以用于错误后RDD的恢复,但是对于很长的血统的RDD来说,这样的恢复耗时比较长,因此需要通过检查点操作保存到外部存储中。
Spark提供为RDD设置检查点操作的API,可以让用户自行决定需要为哪些数据设置检查点操作。另外由于RDD的只读特性,使得不需要关心数据一致性问题。
二、RDD编程接口
Spark中提供了通用接口来抽象每个RDD,这些接口包括:
(1)分区信息:它们是数据集的最小分片。
(2)依赖关系:指向其父RDD。
(3)函数:基于父RDD计算方法。
(4)划分策略和数据位置的元数据。
1、RDD分区(partitions)
一个RDD划分成很多的分区(partition)分布在集群的节点中,分区的多少涉及对这个RDD进行并行计算的粒度。在RDD操作中用户可以使用partitions方法获取RDD划分的分区数,当然用户也可以设定分区数目。如果没有指定将使用默认值,而默认数值是该程序所分配到CPU核数,如果是从HDFS文件创建,默认为文件的block数(有一点我们必须要注意,当我们显示的设置分区数时,分区数不允许小于HDFS文件的block数)。
// 使用textFile方法获取指定路径的文件,未设置分区数
val rdd = sc.textFile("/app/spark/workcount.txt")
// 使用partitions方法获取分区数,假设默认的分区数为2,那么将返回2
val partitionSize = rdd.partitions.size
// 显示地设置RDD为6个分区
rdd = sc.textFile("/app/spark/wordcount.txt", 6)
// 获取分区数,此时返回6
partitionSize = rdd.partitions.size
2、RDD首选位置(preferredlocations)
在Spark形成任务有向无环图(DAG)时,会尽可能地把计算分配到靠近数据的位置,减少数据网络传输。当RDD产生的时候存在首选位置,如HadoopRDD分区的首选位置就是HDFS块所在的节点。当RDD分区被缓存,则计算应该发送到缓存分区所在的节点进行,再不然回溯RDD的血统,一直找到具有首选位置属性的父RDD,并据此决定子RDD的位置。
3、RDD依赖关系
在RDD中将依赖关系分成了两种类型:窄依赖(Narrow Dependencies)和宽依赖(Wide Dependencies)。其中窄依赖是指每个父RDD的分区都至多被一个子RDD的分区使用,而宽依赖是多个子RDD的分区依赖一个父RDD分区。
这两种依赖的区别从两个方面来说比较有用。第一:窄依赖允许在单个集群节点上流水线式执行,这个节点可以计算所有父级分区。相反,宽依赖需要所有的父RDD数据可用,并且数据已经通过Shuffle完成。第二:在窄依赖中,节点事变后的恢复更加高效,因为只有丢失的父级分区需要重新计算,并且这些丢失的父级分区可以并行地在不同节点上重新计算。相反,在宽依赖的继承关系中,单个失败的节点可能导致一个RDD的所有先祖RDD中的一些分区丢失,导致计算的重新执行。