Spark并行计算模型:RDD

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

Spark并⾏计算模型:RDD
Spark 允许⽤户为driver(或主节点)编写运⾏在计算集群上,并⾏处理数据的程序。

在Spark中,它使⽤RDDs代表⼤型的数据集,RDDs 是⼀组不可变的分布式的对象的集合,存储在executors中(或从节点)。

组成RDDs的对象称为partitions,并可能(但是也不是必须的)在分布式系统中不同的节点上进⾏计算。

Spark cluster manager根据Spark application设置的参数配置,处理在集群中启动与分布Spark executors,⽤于计算,如下图:
Spark 并不会⽴即执⾏driver 程序中的每个RDD 变换,⽽是懒惰执⾏:仅在最后的RDD数据需要被计算时(⼀般是在写出到存储系统,或是收集⼀个聚合数据给driver时)才触发计算RDD变换。

Spark可以将⼀个RDD加载到executor节点的内存中(在整个Spark 应⽤的⽣命周期),以在进⾏迭代计算时,达到更快的访问速度。

因为RDDs是不可变的,由Spark实现,所以在转换⼀个RDD时,返回的是⼀个新的RDD,⽽不是已经存在的那个RDD。

Spark的这些性质(惰性计算,内存存储,以及RDD不可变性)提供了它易于使⽤、容错、可扩展、以及⾼效运⾏的特点。

惰性计算
许多其他系统,对in-memory 存储的⽀持,基于的是:对可变(mutable)对象的细粒度更新。

例如:对内存中存储的某个条⽬的更新。

⽽在Spark中,RDDs是完全惰性的。

直到⼀个action被调⽤之前,Spark不会开始计算partition。

这⾥的action是⼀个Spark操作,除了返回⼀个RDD以外,还会触发对分区的计算,或是可能返回⼀些输出到⾮Spark系统中(如outside of the Spark executors)。

例如,将数据发送回driver(使⽤类似count或collect 操作),或是将数据写⼊到外部存储系统(例如copyToHadoop)。

Actions会触发scheduler,scheduler基于RDD transformations之间的依赖关系,构建⼀个有向⽆环图(DAG)。

换句话说,Spark在执⾏⼀个action时,是从后向前定义的执⾏步骤,以产⽣最终分布式数据集(每个分区)中的对象。

通过这些步骤(称为 execution plan),scheduler对每个stage 计算它的missing partitions,直到它计算出最终的结果。

这⾥需要注意的是:所有的RDD变换都是100% 惰性的。

sortByKey 需要计算RDD以决定数据的范围,所以它同时包含了⼀个变换与⼀个action。

惰性计算的性能与可⽤性优势
惰性计算允许Spark结合多个不需要与driver进⾏交互的操作(称为1对1依赖变换),以避免多次数据传输。

例如,假设⼀个Spark 程序在同样的RDD上调⽤⼀个map和filter函数。

Spark可以将这两个指令发送给每个executor。

然后Spark可以在每个partition上执⾏map与filter,这些操作仅需要访问数据仅⼀次即可,⽽不是需要发送两次指令(map与filter),也不需要访问两次partition数据。

这个理论上可以减少⼀半的计算复杂度。

Spark的惰性执⾏不仅更⾼效。

对⽐⼀个不同的计算框架(例如MapReduce),Spark上可以更简单的实现同样的计算逻辑。

在MapReduce 框架中,开发者需要做⼀些开发⼯作以合并他们的mapping 操作。

但是在Spark中,它的惰性执⾏策略可以让我们以更少的代码实现相同的逻辑:我们可以将窄依赖链(chain)起来,并让Spark执⾏引擎完成合并它们的⼯作。

考虑最经典的wordcount例⼦,在官⽅提供的例⼦中,即使最简单的实现都包含了50⾏Java代码。

⽽在Spark的实现中,仅需要15⾏Java代码,或是5⾏Scala 代码:
def simpleWordCount(rdd: RDD[String]):RDD[(String, Int)]={
val words = rdd.flatMap(_.split(" "))
val wordPairs = words.map((_, 1))
val wordCounts = wordPairs.reduceByKey(_ + _)
wordCounts
}
使⽤Spark实现 word count的另⼀个优点是:它易于修改更新。

假设我们需要修改函数,将⼀些“stop words”与标点符号从每个⽂档中剔除,然后在进⾏word count 计算。

在MapReduce中,这需要增加⼀个filter的逻辑到mapper中,以避免传输两次数据并处理。

⽽在Spark中,仅需要简单地加⼀个filter步骤在map步骤前⾯即可。

例如:
def withStopWordsFiltered(rdd : RDD[String], illegalTokens : Array[Char],
stopWords : Set[String]): RDD[(String, Int)] = {
val seperator = illegalTokens ++ Array[Char](' ')
val tokens: RDD[String] = rdd.flatMap(_.split(seperator).map(_.trim.toLowerCase))
val words = tokens.filter(token => !stopWords.contains(token) && (token.length > 0))
val wordPairs = words.map((_, 1))
val wordCounts = wordPairs.reduceByKey(_ + _)
wordCounts
}
惰性执⾏与容错
Spark是有容错性的,也就是说,在遇到主机或是⽹络故障时,Spark不会失败、丢失数据、或是返回错误的结果。

Spark这个独特的容错⽅法的实现,得益于:数据的每个partition都包含了重新计算此partition需要的所有信息。

⼤部分分布式计算中,提供容错性的⽅式是:对可变的(mutable)对象(RDD为immutable 对象),⽇志记录下更新操作,或是在机器之间创建数据副本。

⽽在Spark中,它并不需要维护对每个RDD的更新⽇志,或是⽇志记录实际发⽣的中间过程。

因为RDD它⾃⾝包含了⽤于复制它每个partition所需的所有信息。

所以,如果⼀个partition丢失,RDD有⾜够的有关它⾎统的信息,⽤于重新计算。

并且计算过程可以被并⾏执⾏,以快速恢复。

当某个Worker节点上的Task失败时,可以利⽤DAG重新调度计算这些失败的Task(执⾏成功的Task可以从CheckPoint(检查点)中读取,⽽不⽤重新计算)。

惰性计算与DEBUGGING
由于惰性计算,所以Spark 程序仅会在执⾏action时才报错,即使程序逻辑在RDD变换时就有问题了。

并且此时Stack trace也仅会提⽰在action时报的错。

所以此时debug 程序时会稍有困难。

Immutability 与 RDD 接⼝
Spark定义了每个RDD类型都需要实现的RDD接⼝与其属性。

在⼀个RDD上执⾏变换时,不会修改原有RDD,⽽是返回⼀个新的RDD,新的RDD中的属性被重新定义。

RDDs可由三种⽅式创建:(1)从⼀个已存在的RDD变换得到;(2)从⼀个SparkContext,它是应⽤到Spark的⼀个API gateway;(3)转换⼀个DataFrame或Dataset(从SparkSession创建)
SparkContext表⽰的是⼀个Spark集群与⼀个正在运⾏的Spark application之间的连接。

在Spark内部,RDD有5个主要属性:
1. ⼀组组成RDD的partitions
2. 计算每个split的函数
3. 依赖的其他RDDs
4. (可选)对key-value RDDs的Partitioner(例如,某个RDD是哈希分区的)
5. (可选)⼀组计算每个split的最佳位置(例如,⼀个HDFS⽂件的各个数据块位置)
对于⼀个客户端⽤户来说,很少会⽤到这些属性,不过掌握它们可以对Spark机制有⼀个更好的理解。

这些属性对应于下⾯五个提供给⽤户的⽅法:
1. partitions:
final def partitions: Array[Partition] = {
checkpointRDD.map(_.partitions).getOrElse {
if (partitions_ == null) {
partitions_ = getPartitions
partitions_.zipWithIndex.foreach { case (partition, index) =>
require(partition.index == index,
s"partitions($index).partition == ${partition.index}, but it should equal $index")
}
}
partitions_
}
}
返回这个RDD的partitions数组,会考虑到RDD是否有被做检查点(checkpoint)。

partitions⽅法查找分区数组的优先级为:从CheckPoint 查找 -> 读取partitions_ 属性 -> 调⽤getPartitions ⽅法获取。

getPartitions 由⼦类实现,且此⽅法仅会被调⽤⼀次,所以实现时若是有较为消耗时间的计算,也是可以被接受的。

2. iterator:
final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
if (storageLevel != StorageLevel.NONE) {
getOrCompute(split, context)
} else {
computeOrReadCheckpoint(split, context)
}
}
private[spark] def computeOrReadCheckpoint(split: Partition, context: TaskContext): Iterator[T] =
{
if (isCheckpointedAndMaterialized) {
firstParent[T].iterator(split, context)
} else {
compute(split, context)
}
}
RDD的内部⽅法,⽤于对RDD的分区进⾏计算。

如果有cache,先读cache,否则执⾏计算。

⼀般不被⽤户直接调⽤。

⽽是在Spark计算actions时被调⽤。

3. dependencies:
final def dependencies: Seq[Dependency[_]] = {
checkpointRDD.map(r => List(new OneToOneDependency(r))).getOrElse {
if (dependencies_ == null) {
dependencies_ = getDependencies
}
dependencies_
}
}
获取此RDD的依赖列表,会将RDD是否有checkpoint(检查点)考虑在内。

RDD的依赖列表可以让scheduler知道当前RDD如何依赖于其他RDDs。

从代码来看,dependencies⽅法的执⾏步骤为:(1)从checkpoint获取RDD信息,并将这些信息封装为OneToOneDependency 列表。

如果从checkpoint中获取到了依赖,则返回RDD依赖。

否则进⼊第⼆步;(2)如果dependencies_ 为null,则调⽤getDependencies 获取当前RDD的依赖,并赋值给dependencies_,最后返回dependencies_。

在依赖关系中,主要有两种依赖关系:宽依赖与窄依赖。

会在之后讨论。

4. partitioner:
/** Optionally overridden by subclasses to specify how they are partitioned. */
@transient val partitioner: Option[Partitioner] = None
返回⼀个Scala使⽤的partitioner 对象。

此对象定义⼀个key-value pair 的RDD中的元素如何根据key做partition,⽤于将每个key映射到⼀个partition ID,从 0 到 numPartitions - 1。

对于所有不是元组类型(⾮key/value数据)的RDD来说,此⽅法永远返回None。

5. preferredLocations:
final def preferredLocations(split: Partition): Seq[String] = {
checkpointRDD.map(_.getPreferredLocations(split)).getOrElse {
getPreferredLocations(split)
}
}
返回⼀个partition的位置信息(⽤于data locality)。

具体地讲,这个函数返回⼀系列String,表⽰的是split(Partition)存储在些节点中。

若是⼀个RDD表⽰的是⼀个HDFS⽂件,则preferredLocations 的结果中,每个String对应的是⼀个存储partition的⼀个datanode节点名。

RDD上的函数:Transformations 与 Actions
在RDDs中定义了两种函数类型:actions与transformations。

Actions返回的不是⼀个RDD,⽽是执⾏⼀个操作(例如写⼊外部存储);transformations 返回的是⼀个新的RDD。

每个Spark 程序必须包含⼀个action,因为它会触发Spark程序的计算,将结果信息返回给driver或是向外部存储写⼊数据。

Persist 调⽤也会触发程序执⾏,但是⼀般不会被标注为Spark job 的结束。

向driver返回数据的actions包括:
collect,count,collectAsMap,sample,reduce以及take。

这⾥需要注意的是,尽量使⽤take,count以及reduce等操作,以免返回给driver的数据过多,造成内存溢出(例如使⽤collect,sample)。

向外部存储写⼊数据的actions包括saveAsTextFile,saveAsSequenceFile,以及saveAsObjectFile。

⼤部分写⼊Hadoop 的actions仅适⽤于有key/value 对的 RDDs中,它们定义在PairRDDFunctions类(通过隐式转换为元组类型的RDDs提供⽅法)以及NewHadoopRDD 类(它是从Hadoop中创建RDD的实现)中。

⼀些saving 函数,例如saveAsTextFile 与 saveAsObjectFile,在所有RDDs中都可以使⽤,它们在实现时,都是隐式地添加了⼀个Null key到每个record 中(在saving 阶段会被忽略掉),例如 saveAsTextFile 代码:
def saveAsTextFile(path: String): Unit = withScope {
val nullWritableClassTag = implicitly[ClassTag[NullWritable]]
val textClassTag = implicitly[ClassTag[Text]]
val r = this.mapPartitions { iter =>
val text = new Text()
iter.map { x =>
text.set(x.toString)
(NullWritable.get(), text)
}
}
RDD.rddToPairRDDFunctions(r)(nullWritableClassTag, textClassTag, null)
.saveAsHadoopFile[TextOutputFormat[NullWritable, Text]](path)
}
从代码可以看出,在保存⽂件时,为每条记录增加了⼀个Null key,OutputFormat使⽤的是Hadoop中的TextOutputFormat。

宽依赖与窄依赖
窄依赖,简单的说就是:⼦RDD的所依赖的⽗RDD之间是⼀对⼀或是⼀对多的。

窄依赖需要满⾜的条件:
1. ⽗⼦RDD之间的依赖关系是可以在设计阶段即确定的
2. 与⽗RDD中的records的值⽆关
3. 每个⽗RDD⾄多仅有⼀个⼦RDD
明确的说,在窄变换中的partition,要么是仅基于⼀个⽗partition(如map操作),要么是基于⽗partitions的⼀个特定⼦集(在design阶段即可知道依赖关系,如coalesce操作)。

所以窄变换可以在数据的⼀个⼦集上执⾏,⽽不需要依赖其他partition的信息。

常见的窄依赖操作有:map,filter,mapPartitions,flatMap等,如下图所⽰:
右边的图是⼀个coalesce的例⼦,它也是⼀个窄依赖。

所以就算⼀个⼦partition依赖于多个⽗partition,它也可以是⼀个窄依赖,只要依赖的⽗RDD是明确的,且与partition中数据的值⽆关。

与之相反的是宽依赖,宽依赖⽆法仅在任意⾏上执⾏,⽽是需要将数据以特定的⽅式进⾏分区(例如根据key的值将数据分区)。

例如sort⽅法,records需要被分区,同样范围的key被分区到同⼀个partition中。

宽依赖的变换包括sort,reduceByKey,groupByKey,join,以及任何调⽤rePartition的函数。

下⾯是宽依赖的⼀个⽰例图:
宽依赖中的依赖关系,直到数据被计算前,都是未知的。

相对于coalesce操作,数据需要根据key-value的值决定分到哪个区中。

任何触发shuffle的操作(如groupByKey,reduceByKey,sort,以及sortByKey)均符合此模式。

但是join操作会有些复杂,因为根据两个⽗RDDs被分区的⽅式,它们可以是窄依赖或是宽依赖。

在某些特定例⼦中,例如,当Spark已经知道了数据以某种⽅式分区,宽依赖的操作不会产⽣⼀个shuffle。

如果⼀个操作需要执⾏⼀个shuffle,Spark会加⼊⼀个ShuffledDependency 对象到RDD的dependency 列表中。

⼀般来说,shuffle操作是昂贵的,特别是在⼤量数据被移动到⼀个新的partition时。

这点也是可以⽤于在程序中进⾏优化的,通过减少shuffle数量以及shuflle数据的传输,可以提升Spark程序的性能。

Spark Job
由于Spark使⽤的是惰性计算,所以直到driver程序调⽤⼀个action之前,Spark 应⽤基本上不会做任何事情。

对每个action,Spark Scheduler会构造⼀个execution graph 并启动⼀个Spark job。

每个Spark job 包含⼀个或多个 stages ,stages即为计算出最终RDD时数据需要的transformation步骤。

每个stage包含⼀组tasks,它们代表每个并⾏计算,并执⾏在executors上。

下图是Spark应⽤的⼀个组成部分⽰意图,其中每个stage对应⼀个宽依赖:
DAG
Spark的high-level调度层,使⽤RDD的依赖关系,为每个Spark job 构造⼀个stages的有向⽆环图。

在Spark API 中,它被称为DAG Scheduler。

你可能有注意到,在很多情况下的报错,如连接集群、配置参数、或是launch⼀个Spark job,最终都会显⽰为DAG Scheduler 错误。

因为Spark job的执⾏是由DAG处理的。

DAG为每个job构建⼀个stage图,决定每个task执⾏的位置,并将信息传递给TaskScheduler。

TaskScheduler负责在集群上执⾏tasks。

TaskScheduler在partition之间创建⼀个依赖关系图。

Jobs
Job是Spark执⾏的的层次关系图中的最⾼元素。

每个Spark job对应⼀个action,⽽每个action由driver程序调⽤。

spark 执⾏图(execution graph)的边界基于的是RDD变换中partitions之间的依赖。

所以,如果⼀个操作返回的不是⼀个RDD,⽽是另外的返回(如写⼊外部存储等),则此RDD不会有⼦RDD。

也就是说,在图论中,这个RDD就是⼀个DAG中的⼀个叶⼦节点。

若是调⽤了⼀个action,则action不会⽣成⼦RDD,也就是说,不会有新的RDD加⼊到DAG图中。

所以此时application会launch⼀个job,包含了所有计算出最后⼀个RDD所需的所有transformation信息,开始执⾏计算。

这⾥需要区分的是 job 与stages的概念。

⼀个job是由action触发的,如collect,take,foreach等。

并不是由宽依赖区分的,宽依赖区分的是stage,⼀个job包含多个stage。

Stages
⼀个job是由调⽤⼀个action后定义的。

这个action可能包含⼀个或多个transformations,宽依赖的transformation将job划分为不同的stages。

每个stage对应于⼀个shuffle dependency,shuffle dependency 由宽依赖创建。

从更⾼的视⾓来看,⼀个stage可以认为是⼀组计算(tasks)组成,每个计算都可以在⼀个executor上运⾏,且不需要与其他executors或是driver通信。

也就是说,当workers之间需要做⽹络通信时(例如shuffle),即标志着⼀个新的stage开始。

这些创建了stage边界的dependencies(依赖)称为ShuffleDependencies。

Shuffle是由宽依赖产⽣的,例如sort或groupByKey,它们需要将数据在partition中重新分布。

多个窄依赖的transformations可以被组合到⼀个stage中。

在我们之前介绍过的word count 例⼦中(使⽤stop words 做filter,并做单词计数),Spark可以将flatMap,map以及filter 步骤(steps)结合到⼀个stage中,因为它们中没有需要shuffle的transformation。

所以每个executor都可以连续地应⽤flatMap,map以及filter 步骤在⼀个数据分区中。

⼀般来说,设计程序时,尽量使⽤更少的shuffles。

Tasks
⼀个stage由多个task组成。

Task是执⾏任务的最⼩单元,每个task代表⼀个本地计算。

⼀个stage中的所有task都是在对应的每个数据分⽚
上执⾏相同的代码。

⼀个task不能在多个executor上执⾏,⽽⼀个executor上可以执⾏多个tasks。

每个stage中的tasks数⽬,对应于那个stage输出的RDD的partition数。

下⾯是⼀个展⽰stage边界的例⼦:
def simpleSparkProgram(rdd : RDD[Double]): Long ={
//stage1
rdd.filter(_< 1000.0)
.map(x => (x, x) )
//stage2
.groupByKey()
.map{ case(value, groups) => (groups.sum, value)}
//stage 3
.sortByKey()
.count()
}
在driver中执⾏此程序时,对应的流程图如下:
蓝⾊框代表的是shuffle 操作(groupByKey与sortByKey)定义的边界。

每个stage包含多个并⾏执⾏的tasks,每个task对应于RDD transformation结果(红⾊的长⽅形框)中的每个partition。

在task并⾏中,如果任务的partitions数⽬(也就是需要并⾏的tasks数据)超出了当前可⽤的executor slots数⽬,则不会⼀次并⾏就执⾏完⼀个stage的所有tasks。

所以可能需要两轮或是多轮运⾏,才能跑完⼀个stage的所有tasks。

但是,在开始下⼀个stage的计算之前,前⼀个stage所有tasks必须先全部执⾏完成。

这些tasks的分发与执⾏由TaskScheduler完成,它根据scheduler使⽤的策略(如FIFO或fair scheduler)执⾏相应的调度。

References:
Vasiliki Kalavri, Fabian Hueske. Stream Processing With Apache Flink. 2019。

相关文档
最新文档