从形式上看,RDD是一个只读的、基于分区的数据集合。RDD只能通过一个稳定存储或其他RDD上执行一个稳定性操作来创建,这个稳定性操作可以称之为转换。
RDD在任何时候都不需要物化(即将结果写入稳定的存储器中),并且RDD能够很好的支持容错,因为一个RDD有足够的信息(Lineage)描述着它如何从存储器或者其他RDD转换生成的。因此,如果RDD的某个分区数据丢失,那么它可以从物理存储计算出相应的RDD分区。
最后,用户也可以控制RDD的其他两个方面:持久化和分区。用户可以选择重用哪个RDD,并为其指定存储策略(StorageLevel);也可以让RDD中的数据根据记录的Key分布到集群的多个机器。这个对于位置优化是有用的,比如可以保证两个要join的数据集都使用相同的哈希分区方式,==可以降低相应的网络开销。==
RDDs不太适用于通过异步细粒度更新来共享状态的应用,比如针对Web应用或者增量网络爬虫的存储系统。对于这些应用,那些传统的更新日志和数据检查点的系统会更有效,如数据库、RAWCloud等。
而RDD的目标是为批量分析提供一个高效的编程模型,这些异步应用仍然交给定制系统来处理。
最初的RDD是通过SparkContext提供的parallelize
、createRDD
或textFile
等方法读取数据源来创建的,而之后像MapPartitionsRDD或UnionRDD是通过对应的map
或union
这样的转换操作得到,每个transformation操作会返回(new)一个或多个包含不同类型 T 的 RDD[T](不过某些 transformation部实现比较复杂,会包含多个子 transformation,因而会生成多个 RDD,这就是实际 RDD 个数比我们想象的要多一些 的原因)。
spark.default.parallelism
属性,则将分区数设置为此值,否则,将分区数设置为上游RDDs中最大分区数。compute
,用于对分区进行计算;compute
函数会对迭代器进行复合,不需要保存每次计算结果;RDD每次的转换都会生成新的RDD,所以RDD之间的关系会形成类似于流水线一样的前后依赖关系(linerage)。某个Final RDD的部分分区丢失后,可以根据这种依赖关系重新计算丢失的分区数据。
RDDx依赖的parent RDD的个数由不同的转换操作决定,例如二元转换操作x = a.join(b)
,RDD x就会同时依赖于RDD a和RDD b。
而具体的依赖关系可以细分为完全依赖和部分依赖,详细说明如下:
完全依赖
一个子RDD中的分区可以依赖于父RDD分区中一个或多个完整分区。
例如,map操作产生的子RDD分区与父RDD分区之间是一对一的关系;对于cartesian操作产生的子RDD分区与父RDD分区之间是多对多的关系。
部分依赖
父RDD的一个partition中的部分数据与RDD x的一个partition相关,而另一部分数据与RDD x中的另一个partition有关。
例如,groupByKey操作产生的ShuffledRDD中的每个分区依赖于父RDD的所有分区中的部分元素。
下图展示了完全依赖和部分依赖:
在Spark中,完全依赖是NarrowDependency(黑色箭头),部分依赖是ShuffleDependency(红色箭头),而NarrowDependency又可以细分为[1:1]OneToOneDependency、[N:1]NarrowDependency和[N:N]NarrowDependency,还有特殊的RangeDependency (只在 UnionRDD 中使用)。
需要注意的是,对于[N:N]NarrowDependency很少见,最后生成的依赖图和ShuffleDependency没什么两样。只是对于父RDD来说,有一部分是完全依赖,有一部分是部分依赖,但是箭头数没有少。所以也只有[1:1]OneToOneDependency和[N:1]NarrowDependency两种情况。
getPreferredLocations
的实现中,都会优先选择父RDD中对应分区的preferedLocation,其次才选择自己设置的优先位置。逻辑执行图也叫做数据依赖图,是对Job作业中数据流的描述,典型的 Job 逻辑执行图如上所示,经过以下四个步骤才能得到最终执行结果:
f(Array[result])
,例如foreach(Array[result])
,count(Array[result])
,sum(Array[result])
(深粉色)。上面给出了一个复杂的数据依赖图,那么如何划分Stage,以确定task的类型和个数呢?以下提出了3个方法,并对每种方法进行推导论证,讨论其合理性。
最小相关性划分就是将前后关联的RDD划分成一个Stage,每个箭头生成一个Task。对于两个RDD组合成一个RDD的情况,则将这三个RDD划分成一个Stage。
虽然这么做特简单,但是有一个很大的缺陷就是大量的中间数据需要存储。对于一个Task来说,其执行结果要么存储到内存,要么存储到磁盘中,或者两者皆可。因此,这样做会消耗大量的空间。
仔细看下逻辑执行图就可以发现,在每个RDD内部,每个partition之间都是相互独立的,他们之间的数据不会相互干扰。因此,可以将整个逻辑执行图划分成一个Stage,为最后一个final RDD中的每个partition分配一个Task。
具体Task的划分方法就是:所有连续的粗箭头组成第一个Task,该task结束后会将CoGroupedRDD中第二和第三个partition的数据存起来。而第二个task(细实线)只需要计算两步,第三个task(细实线)也只需要计算两步,最后得到结果。
这么做问题就来了,也就是说第一个Task太大,碰到ShuffleDependency之后,不得不计算shuffle依赖的RDD的所有partition,并且都需要在一个Task里完成。另外,还需要巧妙的算法来判断哪个RDD中的哪个partition需要cache,并且需要大量的存储空间。
当然,上面的方案也折射出一个重要的思想——流水线思想,即数据需要的时候再计算,并且数据会流到到计算的位置。
在第二种方法中,主要的问题就是碰到ShuffleDependency后无法进行流水线操作。因此,一个改进的思路就是在ShuffleDependency处断开,那就剩下NarrowDependency,而NarrowDependency由于Partition的确定性,Partition的转换处理可以在一个线程中执行完成,则可以进行流水线操作。
一个归纳出的具体算法就是:从后往前推算,遇到NarrowDependency就将其划入Stage中,遇到ShuffleDependency就断开,每个Stage中的Task数目由该Stage中最后一个 finalRDD 的partition个数决定。按照这个思想,将上面的逻辑图分割成如下:
一个连续的粗箭头表示一个task,如果Stage要产生result,那么这个task就是ResultTask(相当于MapReduce的reducer),否则都是ShuffleMapTask(表示该Stage的结果需要Shuffle到下一个Stage,相当于MapReduce的mapper)。
另外,算法中提到,NarrowDependency 链可以进行流水线操作,而ComplexJob仅仅展示了OneToOneDependency和RangeDependency,对于普通的NarrowDependency如何进行流水线操作?
经过以上算法划分后:
图中的粗箭头展示了第一个ResultTask,由于该Stage的task直接输出result,所以包含了6个ResultTask。与OneToOneDependency不同的是,这里的每个ResultTask需要计算3个RDD,读取两个Data Block,整个读取和计算会在一个task中完成。这个图说明:不管是 1:1 还是 N:1 的 NarrowDependency,只要是 NarrowDependency 链,就可以进行 流水线操作,生成的 task 个数与该 stage 最后一个 RDD 的 partition 个数相同。
ShuffleMapTask或ResultTask在执行runTask
的时候,runTask
最终会调用RDD的iterator
方法,
final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
if (storageLevel != StorageLevel.NONE) {
(split, context)
} else {
computeOrReadCheckpoint(split, context)
}
}
iterator
方法是所有RDD进行计算的入口方法,其调用方法链如下:
在执行iterator方法时,会首先判断该RDD是否有缓存。如果有则从本地获取;如果本地没有,则从远程获取;如果远程也没有,则进行第二步
如果缓存丢失或者没有缓存,则调用computeOrReadCheckpoint方法,该方法首先会判断该RDD是否存在检查点,如果存在检查点并且检查点数据已经具体化,那么该RDD的依赖就会变为对应的CheckpointRDD,而CheckpointRDD的compute实现中会调用readCheckpointFile
方法,从本地或HDFS上读取Checkpoint数据
如果没有检查点或者检查点数据不存在,那么开始建立计算链
整个计算链是根据数据依赖关系从后往前建立的,Result产生的地方就是计算所在的位置,要确定计算所需要的数据,则需要从后往前找,需要哪个partition就计算哪个partition;如果partition里面没有数据,则继续往前找,形成一个计算链。这样往前找的结果就是,需要首先计算出每个 stage 最左边的 RDD 中的某些 partition。
而遇到ShuffleDependency后就建立Stage,在单个Stage中,每个RDD中的compute
调用parentRDD.iterator()
将parent RDD中的一个个records fetch过来。另外,实际计算出的数据是从前往后流动,而且计算出的第一个record1流动到不能再流动后,再计算下一个record2,所以并不是要求当前RDD的partition中所有records计算完成后整体向后移动。
Spark最重要的能力就是在操作中将数据集持久化到内存中。当持久化一个RDD时,包含该RDD分区的节点都会进行持久化,在未来将其重用于该数据集上的其他操作上,并且更加快速(通常超过10倍),缓存也是迭代算法和快速交互使用的关键工具。
通常可以调用的persis()
或cache()
方法进行持久化操作,该操作会在第一次结束操作(action)执行时被计算,RDD则会被保存到节点的内存中。Spark的缓存是支持容错的——如果任何RDD的分区丢失,它将使用最初创建它的转换操作(transformations)自动重新计算。
在进行计算的时候,首先会查询BlockManager是否存在对应的Block信息,如果存在则直接返回,否则代表该RDD是需要计算的。该RDD不存在原因有几个,一个是没有计算过、计算过并存储到内存中,但由于后期内存紧张被清理掉了。而再次计算后,会根据用户定义的存储级别,再次将该RDD分区对应的Block写入BlockManager中。这样,下次就可以不经过计算而直接读取到该RDD计算的结果了。
RDD可以在第一次计算结束后,将计算结果缓存,这样在下次使用时能够极大的提升计算速度。但是如果缓存丢失了,则需要重新计算。但是,对于计算特别复杂或者计算特别耗时的job,那么缓存丢失后,对整个job将造成极大的影响。为了避免缓存丢失重新计算带来的开销,Spark又引入了检查点机制。
检查点会在计算完成后,重新建立一个Job来计算,将计算的结果写入一个新创建的目录中,这个目录可以是本地目录或者在HDFS上;接着会创建一个CheckpointRDD替代原始RDD的parentRDD,并将原始RDD的所有依赖清除,意味着RDD转换的计算链等信息都被清除。
答案就在RDD.dependencies
的实现中,它会首先判断当前RDD是否已经被Checkpoint过,如果有,那么RDD的依赖就变成对应的CheckpointRDD了。而CheckpointRDD的compute
的实现就是从HDFS中读取数据