Apache Spark之RDD详解(章节二)

作者:jiangzz 电话:15652034180 微信:jiangzz_wx 微信公众账号:jiangzz_wy

RDD概述

Spark计算中一个重要的概念就是可以跨越多个节点的可伸缩分布式数据集 RDD(resilient distributed
dataset) Spark的内存计算的核心就是RDD的并行计算。RDD可以理解是一个弹性的,分布式、不可变的、带有分区的数据集合,所谓的Spark的批处理,实际上就是正对RDD的集合操作,RDD有以下特点:

  • 包含一些列分区
  • 每个分区都有自己的计算的function
  • RDD之间存在着一些列的依赖
  • 对于Key-Value的RDD用户可以定指定分区策略(可选)
  • 针对于存储在HDFS上的文件,系统可以计算最优位置,计算每个切片。

例如字符统计案例,如下图所示


字符统计

可以将任何一个Spark任务理解成以下三个步骤

1) 创建RDD对象,本案例中创建的HadoopRDD。
2)对RDD做Transformation转换。
3)对ReduceByKey的RDD做Action操作,将分布式计算的结果下载到Driver节点。

通过上诉的代码中不难发现,Spark的整个任务的计算无外乎围绕RDD的三种类型操作RDD创建RDD转换RDD Action.通常习惯性的将flatMap/map/reduceByKey称为RDD的转换算子,collect触发任务执行,因此被人们称为动作算子。在Spark中所有的Transform算子都是lazy执行的,只有在Action算子的时候,Spark才会真正的运行任务,也就是说只有遇到Action算子的时候,SparkContext才会对任务做DAG状态拆分,系统才会计算每个状态下任务的TaskSet,继而TaskSchedual才会将任务提交给Executors执行。现将以上字符统计计算流程描述如下:

任务State拆分

在图中不难发现textFile创建HadoopRDD然后,并且指定了HadoopRDD的分区数,如果不指定系统默认会按照HDFS上的Block的数目计算分区,该参数不能小于Block的数目。然后使用可flatMapmap算子对分区数据做转换,不难看出Spark将textFile->flatMap->map规划为了一个state0,在执行到reduceByKey转换的的时候将开始又划分出state1在执行到collect动作算子的时候,Spark任务提交,并且内部通过DAGSchedule计算出了state0和state1两个状态。

RDD容错

在理解DAGSchedule如何做状态划分的前提是需要大家了解一个专业术语lineage通常被人们称为RDD的血统。在了解什么是RDD的血统之前,先来看看程序猿进化过程。

程序猿进化过程

上图中描述了一个程序猿起源变化的过程,我们可以近似的理解类似于RDD的转换也是一样的,Spark的计算本质就是对RDD做各种转换,因为RDD是一个不可变只读的集合,因此每次的转换都需要上一次的RDD作为本次转换的输入,因此RDD的lineage描述的是RDD间的相互依赖关系。为了保证RDD中数据的健壮性,RDD数据集通过所谓的血统关系(Lineage)记住了它是如何从其它RDD中演变过来的。Spark将RDD之间的关系归类为宽依赖窄依赖。Spark会根据Lineage存储的RDD的依赖关系对RDD计算做故障容错,目前Saprk的容错策略更具RDD依赖关系重新计算对RDD做Cache对RDD做Checkpoint手段完成RDD计算的故障容错。

宽依赖|窄依赖

RDD在Lineage依赖方面分为两种Narrow DependenciesWide Dependencies用来解决数据容错的高效性。Narrow Dependencies是指父RDD的每一个分区最多被一个子RDD的分区所用,表现为一个父RDD的分区对应于一个子RDD的分区或多个父RDD的分区对应于子RDD的一个分区,也就是说一个父RDD的一个分区不可能对应一个子RDD的多个分区。Wide Dependencies父RDD的一个分区对应一个子RDD的多个分区。

宽窄依赖定义

对于Wide Dependencies这种计算的输入和输出在不同的节点上,一般需要夸节点做Shuffle,因此如果是RDD在做宽依赖恢复的时候需要多个节点重新计算成本较高。相对于Narrow Dependencies RDD间的计算是在同一个Task当中实现的是线程内部的的计算,因此在RDD分区数据丢失的的时候,也非常容易恢复。

Sate划分
Spark任务阶段的划分是按照RDD的lineage关系逆向生成的这么一个过程,Spark任务提交的流程大致如下图所示:

任务提交源码流程脉络

这里可以分析一下DAGScheduel中对State拆分的逻辑代码片段如下所示

  private[scheduler] def handleJobSubmitted(jobId: Int,
      finalRDD: RDD[_],
      func: (TaskContext, Iterator[_]) => _,
      partitions: Array[Int],
      callSite: CallSite,
      listener: JobListener,
      properties: Properties) {
     
   var finalStage: ResultStage = null
    try {
      // New stage creation may throw an exception if, for example, jobs are run on a
      // HadoopRDD whose underlying HDFS files have been deleted.
      finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite)
    } catch {
      //省略....
     }
     //省略...
    submitStage(finalStage)
  }

也就是说DAGScheduel在handleJobSubmitted方法中首先更具最后一个RDD创建了ResultStage对象,然后调用了submitStage(finalStage)方法,state追溯实际上是在该方法中实现的,现在看一下submitStage方法的实现。

状态拆分和任务计算

接下来关注的是DAGSchedule是如何计算出当前state的父state,用户可以关注getMissingParentStages方法.
计算宽窄依赖

通过以上代码的分析,不难看出Spark在对RDD计算的时候,是通过Final RDD反推出所有的依赖RDD,这样就得到了FinalRDD的lineage的依赖关系。同时,在任务执行的时候,系统按照Sate为单位计算任务,讲一个任务拆分成若干个TaskSet提交该TaskSet实际上存储的Seq[ShuffleMapTask]的一个集合。

RDD缓存

缓存是一种RDD计算容错的一种手段,程序在RDD数据丢失的时候,可以通过缓存快速计算当前RDD的值,而不需要反推出所有的RDD重新计算,因此Spark在需要对某个RDD多次使用的时候,为了提高程序的执行效率用户可以考虑使用RDD的cache。如下测试:

val conf = new SparkConf()
    .setAppName("word-count")
    .setMaster("local[2]")
val sc = new SparkContext(conf)
val value: RDD[String] = sc.textFile("file:///D:/demo/words/")
   .cache()
value.count()

var begin=System.currentTimeMillis()
value.count()
var end=System.currentTimeMillis()
println("耗时:"+ (end-begin))//耗时:253

//失效缓存
value.unpersist()
begin=System.currentTimeMillis()
value.count()
end=System.currentTimeMillis()
println("不使用缓存耗时:"+ (end-begin))//2029
sc.stop()

除了调用cache之外,Spark提供了更细粒度的RDD缓存方案,用户可以更具集群的内存状态选择合适的缓存策略。用户可以使用persist方法指定缓存级别。

RDD#persist(StorageLevel.MEMORY_ONLY)

目前Spark支持的缓存方案如下:

object StorageLevel {
  val NONE = new StorageLevel(false, false, false, false)
  val DISK_ONLY = new StorageLevel(true, false, false, false)
  val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2)
  val MEMORY_ONLY = new StorageLevel(false, true, false, true)
  val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2)
  val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false)
  val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2)
  val MEMORY_AND_DISK = new StorageLevel(true, true, false, true)
  val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2)
  val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false)
  val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2)
  val OFF_HEAP = new StorageLevel(true, true, true, false, 1)
...

那如何选择呢?

默认情况下,性能最高的当然是MEMORY_ONLY,但前提是你的内存必须足够足够大,可以绰绰有余地存放下整个RDD的所有数据。因为不进行序列化与反序列化操作,就避免了这部分的性能开销;对这个RDD的后续算子操作,都是基于纯内存中的数据的操作,不需要从磁盘文件中读取数据,性能也很高;而且不需要复制一份数据副本,并远程传送到其他节点上。但是这里必须要注意的是,在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果RDD中数据比较多时(比如几十亿),直接用这种持久化级别,会导致JVM的OOM内存溢出异常。

如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果RDD中的数据量过多的话,还是可能会导致OOM内存溢出的异常。

不要泄漏到磁盘,除非你在内存中计算需要很大的花费,或者可以过滤大量数据,保存部分相对重要的在内存中。否则存储在磁盘中计算速度会很慢,性能急剧降低。

后缀为_2的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性,否则不建议使用。

Check Point 机制

除了使用缓存机制可以有效的保证RDD的故障恢复,但是如果缓存失效还是会在导致系统重新计算RDD的结果,所以对于一些RDD的lineage较长的场景,计算比较耗时,用户可以尝试使用checkpoint机制存储RDD的计算结果,该种机制和缓存最大的不同在于,使用checkpoint之后被checkpoint的RDD数据直接持久化在文件系统中,一般推荐将结果写在hdfs中,这种checpoint并不会自动清空。

val conf = new SparkConf()
.setAppName("word-count")
.setMaster("local[2]")
val sc = new SparkContext(conf)
sc.setCheckpointDir("file:///D:/checkpoints")
val value: RDD[String] = sc.textFile("file:///D:/demo/words/")
value.checkpoint()
var begin=System.currentTimeMillis()
value.count()
var end=System.currentTimeMillis()
println("耗时:"+ (end-begin))

//失效缓存
value.unpersist()
begin=System.currentTimeMillis()
value.count()
end=System.currentTimeMillis()
println("不使用缓存耗时:"+ (end-begin))//2029
sc.stop()

注意checkpoint在计算的过程中先是对RDD做mark,在任务执行结束后,再对mark的RDD实行checkpoint,也就是要重新计算被Mark之后的rdd的依赖和结果,因此为了避免Mark RDD重复计算,推荐使用策略

val conf = new SparkConf()
.setAppName("word-count")
.setMaster("local[2]")
val sc = new SparkContext(conf)
sc.setCheckpointDir("file:///D:/checkpoints")
val value: RDD[String] = sc.textFile("file:///D:/demo/words/")
.cache()
value.checkpoint()
var begin=System.currentTimeMillis()
value.count()
var end=System.currentTimeMillis()
println("耗时:"+ (end-begin))

//失效缓存
value.unpersist()
begin=System.currentTimeMillis()
value.count()
end=System.currentTimeMillis()
println("不使用缓存耗时:"+ (end-begin))//2029
sc.stop()

更多精彩内容关注

微信公众账号

你可能感兴趣的:(Apache Spark之RDD详解(章节二))