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

Spark 并行计算模型:RDD的更多相关文章

  1. Spark计算模型-RDD介绍

    在Spark集群背后,有一个非常重要的分布式数据架构,即弹性分布式数据集(Resilient Distributed DataSet,RDD),它是逻辑集中的实体,在集群中的多台集群上进行数据分区.通 ...

  2. Spark计算模型RDD

    RDD弹性分布式数据集 RDD概述 RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据抽象,它代表一个不可变.可分区.里面的元素可并行 ...

  3. Spark之编程模型RDD

    前言:Spark编程模型两个主要抽象,一个是弹性分布式数据集RDD,它是一种特殊集合,支持多种数据源,可支持并行计算,可缓存:另一个是两种共享变量,支持并行计算的广播变量和累加器. 1.RDD介绍 S ...

  4. Spark编程模型及RDD操作

    转载自:http://blog.csdn.net/liuwenbo0920/article/details/45243775 1. Spark中的基本概念 在Spark中,有下面的基本概念.Appli ...

  5. Spark编程模型(RDD编程模型)

    Spark编程模型(RDD编程模型) 下图给出了rdd 编程模型,并将下例中用 到的四个算子映射到四种算子类型.spark 程序工作在两个空间中:spark rdd空间和 scala原生数据空间.在原 ...

  6. Spark入门实战系列--3.Spark编程模型(上)--编程模型及SparkShell实战

    [注]该系列文章以及使用到安装包/测试数据 可以在<倾情大奉送--Spark入门实战系列>获取 .Spark编程模型 1.1 术语定义 l应用程序(Application): 基于Spar ...

  7. Spark:Spark 编程模型及快速入门

    http://blog.csdn.net/pipisorry/article/details/52366356 Spark编程模型 SparkContext类和SparkConf类 代码中初始化 我们 ...

  8. Spark的核心RDD(Resilient Distributed Datasets弹性分布式数据集)

    Spark的核心RDD (Resilient Distributed Datasets弹性分布式数据集)  原文链接:http://www.cnblogs.com/yjd_hycf_space/p/7 ...

  9. Spark学习之RDD

    RDD概述 什么是RDD RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据抽象,它代表一个不可变.可分区.里面的元素可并行计算的集合 ...

随机推荐

  1. 谷歌浏览器chrome应用商店无法打开的解决方法

    解决办法:谷歌访问助手 谷歌访问助手是一款免费的谷歌服务代理插件,不用配置就可以正常访问谷歌的大部分服务,而且速度也很快.下载地址:http://www.cnplugins.com/advsearch ...

  2. Mac中如何搭建Vue项目并利用VSCode开发

    (一)部署Node环境 (1)下载适合Mac环境的Node包,点击进入下载页面 (2)安装Node环境:找到下载好的Node包,这里是node-v12.14.1.pkg,我们双击它,会进入Node.j ...

  3. t-SNE and PCA

    1.t-SNE 知乎 t-分布领域嵌入算法 虽然主打非线性高维数据降维,但是很少用,因为 比较适合应用于可视化,测试模型的效果 保证在低维上数据的分布与原始特征空间分布的相似性高 因此用来查看分类器的 ...

  4. Normalizing flows

    probability VS likelihood: https://zhuanlan.zhihu.com/p/25768606 http://sdsy888.me/%E9%9A%8F%E7%AC%9 ...

  5. 理解Javascript的变量提升

    前言 本文2922字,阅读大约需要8分钟. 总括: 什么是变量提升,使用var,let,const,function,class声明的变量函数类在变量提升的时候都有什么区别. 参考文章:Hoistin ...

  6. 【Unity|C#】基础篇(18)——正则表达式(Regex类)

    [学习资料] <C#图解教程>:https://www.cnblogs.com/moonache/p/7687551.html 电子书下载:https://pan.baidu.com/s/ ...

  7. XSS进阶学习-转载

    在这篇帖子里面真的可以学到很多xss的知识,特别有过xss基础的看完这个贴子绝对有帮助: 就像里面的师傅所说,看了一篇精髓文章之后,自己xss的功力突飞猛进了. 所提到的帖子入口:https://mp ...

  8. C编译过程

    system()调用系统命令 C语言源代码——> 预编译(1.去掉注释:2.包含文件)——> gcc -o a.o a.c 编译(编译成二进制质量)——> 链接系统库函数——> ...

  9. HTML div标签

    看成一个 纯净的箱子吧.....啥属性都没有....默认宽度100% 高度0高度是 按DIV里的 内容而变高也可以在 CSS里 设置 宽高....DIV就是 典型的 标签.. P UL LI 等 标签 ...

  10. python面试的100题(19)

    61.如何在function里面设置一个全局变量 Python中有局部变量和全局变量,当局部变量名字和全局变量名字重复时,局部变量会覆盖掉全局变量. 如果要给全局变量在一个函数里赋值,必须使用glob ...