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个主要属性:
- 一组组成RDD的partitions
- 计算每个split的函数
- 依赖的其他RDDs
- (可选)对key-value RDDs的Partitioner(例如,某个RDD是哈希分区的)
- (可选)一组计算每个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之间是一对一或是一对多的。
窄依赖需要满足的条件:
- 父子RDD之间的依赖关系是可以在设计阶段即确定的
- 与父RDD中的records的值无关
- 每个父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