Spark RDD知识点汇总

  • 什么是RDD
    • RDD的优点
    • 不适合RDDs的应用
  • 如何创建RDD
  • RDD的属性
    • 分区-Partition
    • 分区器-Partitioner
    • 分区处理函数-compute
    • 依赖关系-Dependency
    • 优先位置列表-preferedLocation
  • 什么是逻辑执行图
  • 如何划分Stage
    • 按RDD之间最小相关性划分
    • 将整个逻辑执行图看成一个Stage
    • 合理的划分算法
  • RDD的计算
    • RDD计算的起点
    • 计算链的建立
  • 对容错的支持
    • 缓存
    • checkpoint
      • 为什么引入检查点
      • 什么是检查点
      • Spark是如何确定一个RDD是否被checkpoint了而正确的读取数据呢
  • 参考资料

什么是RDD

从形式上看,RDD是一个只读的、基于分区的数据集合。RDD只能通过一个稳定存储其他RDD上执行一个稳定性操作来创建,这个稳定性操作可以称之为转换

RDD在任何时候都不需要物化(即将结果写入稳定的存储器中),并且RDD能够很好的支持容错,因为一个RDD有足够的信息(Lineage)描述着它如何从存储器或者其他RDD转换生成的。因此,如果RDD的某个分区数据丢失,那么它可以从物理存储计算出相应的RDD分区。

最后,用户也可以控制RDD的其他两个方面:持久化分区。用户可以选择重用哪个RDD,并为其指定存储策略(StorageLevel);也可以让RDD中的数据根据记录的Key分布到集群的多个机器。这个对于位置优化是有用的,比如可以保证两个要join的数据集都使用相同的哈希分区方式,==可以降低相应的网络开销。==

RDD的优点

  1. RDD适合对数据集中所有的元素进行相同的操作的批处理类应用;
  2. RDD支持容错,它能够高效地记住每一次变换,不需要对大量的数据做日志记录。当出现失败的时候,可以使用lineage恢复数据,并且RDDs的分区中只有丢失的那部分需要重新计算,而该计算可在多个节点上并发完成,不必回滚程序。
  3. 由于RDD的不可变性,可以向MapReduce那样使用后备任务代替运行缓慢的任务来减少慢节点(stragglers)的影响。
  4. 在RDDs上的批量操作过程中,任务的执行可以根据数据的所处位置来进行优化,从而提高性能(移动计算而不是移动数据)。
  5. 当内存不够使用内,RDD的性能下降也是平稳的,对于不能载入内存的数据可以存储在磁盘上,其性能也会与当前其他数据并行系统相当。

不适合RDDs的应用

RDDs不太适用于通过异步细粒度更新来共享状态的应用,比如针对Web应用或者增量网络爬虫的存储系统。对于这些应用,那些传统的更新日志和数据检查点的系统会更有效,如数据库、RAWCloud等。

而RDD的目标是为批量分析提供一个高效的编程模型,这些异步应用仍然交给定制系统来处理。

如何创建RDD

最初的RDD是通过SparkContext提供的parallelizecreateRDDtextFile等方法读取数据源来创建的,而之后像MapPartitionsRDD或UnionRDD是通过对应的mapunion这样的转换操作得到,每个transformation操作会返回(new)一个或多个包含不同类型 T 的 RDD[T](不过某些 transformation部实现比较复杂,会包含多个子 transformation,因而会生成多个 RDD,这就是实际 RDD 个数比我们想象的要多一些 的原因)。

RDD的属性

分区-Partition

  1. 一个分区通常与一个计算任务关联,分区的个数决定了并行的粒度;
  2. 分区的个数可以在创建RDD的时候进行设置。如果没有设置,默认情况下由节点的cores个数决定;
  3. 每个Partition最终会被逻辑映射为BlockManager中的一个Block,而这个Block会被下一个Task(ShuffleMapTask/ResultTask)使用进行计算;

分区器-Partitioner

  1. 只有键值对RDD,才会有Partitioner。其他非键值对的RDD的Partitioner为None;
  2. 它定义了键值对RDD中的元素如何被键分区,能够将每个键映射到对应的分区ID,从0到“numPartitions- 1”上;
  3. Partitioner不但决定了RDD本身的分区个数,也决定了parent RDD shuffle输出的分区个数。
  4. 在分区器的选择上,默认情况下,如果有一组RDDs(父RDD)已经有了Partitioner,则从中选择一个分区数较大的Partitioner;否则,使用默认的HashPartitioner。
  5. 对于HashPartitioner分区数的设置,如果配置了spark.default.parallelism属性,则将分区数设置为此值,否则,将分区数设置为上游RDDs中最大分区数。

分区处理函数-compute

  1. 每个RDD都会实现compute,用于对分区进行计算;
  2. compute函数会对迭代器进行复合,不需要保存每次计算结果;
  3. 该方法负责接收parent RDDs或者data block流入的records并进行计算,然后输出加工后的records。

依赖关系-Dependency

RDD每次的转换都会生成新的RDD,所以RDD之间的关系会形成类似于流水线一样的前后依赖关系(linerage)。某个Final RDD的部分分区丢失后,可以根据这种依赖关系重新计算丢失的分区数据。

RDDx依赖的parent RDD的个数由不同的转换操作决定,例如二元转换操作x = a.join(b),RDD x就会同时依赖于RDD a和RDD b。

而具体的依赖关系可以细分为完全依赖部分依赖,详细说明如下:

  1. 完全依赖

    一个子RDD中的分区可以依赖于父RDD分区中一个或多个完整分区。

    例如,map操作产生的子RDD分区与父RDD分区之间是一对一的关系;对于cartesian操作产生的子RDD分区与父RDD分区之间是多对多的关系。

  2. 部分依赖

    父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两种情况。

优先位置列表-preferedLocation

  1. 对于一个HDFS文件来说,这个列表保存的就是每个Partition所在的块位置。
  2. Spark在进行任务调度的时候,会尽可能地将计算任务分配到所要处理数据块的存储位置(移动计算而不是移动数据)。
  3. 每个子RDDgetPreferredLocations的实现中,都会优先选择父RDD中对应分区的preferedLocation,其次才选择自己设置的优先位置。

什么是逻辑执行图

逻辑执行图也叫做数据依赖图,是对Job作业中数据流的描述,典型的 Job 逻辑执行图如上所示,经过以下四个步骤才能得到最终执行结果:

  1. 从数据源读取数据创建最初的RDD(紫色部分);
  2. 对RDD进行一系列的transformation操作后,会生成一些中间RDD以及对RDD之间依赖关系的描述,最后RDD会转化为一个finalRDD(浅绿色部分);
  3. 对finalRDD执行action操作,对每个partition分区计算后产生结果result(紫灰色部分);
  4. 收集每个分区的result放到一个Array中并后返回到Driver端,在Driver端对Array[result]进行最终的计算f(Array[result]),例如foreach(Array[result])count(Array[result])sum(Array[result])(深粉色)。

如何划分Stage

Spark RDD知识点汇总_第1张图片

上面给出了一个复杂的数据依赖图,那么如何划分Stage,以确定task的类型和个数呢?以下提出了3个方法,并对每种方法进行推导论证,讨论其合理性。

按RDD之间最小相关性划分

最小相关性划分就是将前后关联的RDD划分成一个Stage,每个箭头生成一个Task。对于两个RDD组合成一个RDD的情况,则将这三个RDD划分成一个Stage。

虽然这么做特简单,但是有一个很大的缺陷就是大量的中间数据需要存储。对于一个Task来说,其执行结果要么存储到内存,要么存储到磁盘中,或者两者皆可。因此,这样做会消耗大量的空间。

将整个逻辑执行图看成一个Stage

仔细看下逻辑执行图就可以发现,在每个RDD内部,每个partition之间都是相互独立的,他们之间的数据不会相互干扰。因此,可以将整个逻辑执行图划分成一个Stage,为最后一个final RDD中的每个partition分配一个Task。

Spark RDD知识点汇总_第2张图片

具体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个数决定。按照这个思想,将上面的逻辑图分割成如下:

Spark RDD知识点汇总_第3张图片

一个连续的粗箭头表示一个task,如果Stage要产生result,那么这个task就是ResultTask(相当于MapReduce的reducer),否则都是ShuffleMapTask(表示该Stage的结果需要Shuffle到下一个Stage,相当于MapReduce的mapper)。

另外,算法中提到,NarrowDependency 链可以进行流水线操作,而ComplexJob仅仅展示了OneToOneDependency和RangeDependency,对于普通的NarrowDependency如何进行流水线操作?

Spark RDD知识点汇总_第4张图片

经过以上算法划分后:

Spark RDD知识点汇总_第5张图片

图中的粗箭头展示了第一个ResultTask,由于该Stage的task直接输出result,所以包含了6个ResultTask。与OneToOneDependency不同的是,这里的每个ResultTask需要计算3个RDD,读取两个Data Block,整个读取和计算会在一个task中完成。这个图说明:不管是 1:1 还是 N:1 的 NarrowDependency,只要是 NarrowDependency 链,就可以进行 流水线操作,生成的 task 个数与该 stage 最后一个 RDD 的 partition 个数相同。

RDD的计算

RDD计算的起点

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进行计算的入口方法,其调用方法链如下:

  1. 在执行iterator方法时,会首先判断该RDD是否有缓存。如果有则从本地获取;如果本地没有,则从远程获取;如果远程也没有,则进行第二步

  2. 如果缓存丢失或者没有缓存,则调用computeOrReadCheckpoint方法,该方法首先会判断该RDD是否存在检查点,如果存在检查点并且检查点数据已经具体化,那么该RDD的依赖就会变为对应的CheckpointRDD,而CheckpointRDD的compute实现中会调用readCheckpointFile方法,从本地或HDFS上读取Checkpoint数据

  3. 如果没有检查点或者检查点数据不存在,那么开始建立计算链

    整个计算链是根据数据依赖关系从后往前建立的,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计算的结果了。

checkpoint

为什么引入检查点

RDD可以在第一次计算结束后,将计算结果缓存,这样在下次使用时能够极大的提升计算速度。但是如果缓存丢失了,则需要重新计算。但是,对于计算特别复杂或者计算特别耗时的job,那么缓存丢失后,对整个job将造成极大的影响。为了避免缓存丢失重新计算带来的开销,Spark又引入了检查点机制

什么是检查点

检查点会在计算完成后,重新建立一个Job来计算,将计算的结果写入一个新创建的目录中,这个目录可以是本地目录或者在HDFS上;接着会创建一个CheckpointRDD替代原始RDD的parentRDD,并将原始RDD的所有依赖清除,意味着RDD转换的计算链等信息都被清除。

Spark是如何确定一个RDD是否被checkpoint了,而正确的读取数据呢?

答案就在RDD.dependencies的实现中,它会首先判断当前RDD是否已经被Checkpoint过,如果有,那么RDD的依赖就变成对应的CheckpointRDD了。而CheckpointRDD的compute的实现就是从HDFS中读取数据

参考资料

  1. Jerry Lead总结的Spark文章

你可能感兴趣的:(Spark)