Spark逻辑执行计划

一、Spark逻辑处理流程概览

Spark在运行应用之前,首先需要将应用程序转化为逻辑处理流程(Logical plan)。这一章我们将详细讨论这个转化过程。为了解释一些概念,我们假设Spark已经为一个典型应用生成了逻辑处理流程,如图3.1所示。图3.1表示了从数据源开始经过了哪些步骤得到最终结果,还有中间数据及其依赖关系。

这个典型的逻辑处理流程主要包含四部分。

  1. 数据源(Data blocks):数据源表示的是原始数据,数据可以存放在本地文件系统和分布式文件系统中,如HDFS、分布式Key-Value数据库(HBase)等。如果在单机测试时,数据源还可以是内存数据结构,如list(1, 2, 3, 4, 5);对于流式处理来说,数据源还可以是网络流等。这里我们只讨论批式处理,所以限定数据源是静态数据。

image-20230911164245832

  1. 数据模型:确定了数据源后,我们需要对数据进行操作处理。首要问题是如何对输入/输出、中间数据进行抽象表示,使得程序能够识别处理。Hadoop MapReduce框架将输入/输出、中间结果抽象为 <K, V> record,这样map() / reduce() 按照<K, V> record形式读取数据并处理数据,最后输出为 <K, V> record形式。这种数据表示的优点是简单易操作,缺点是过于细粒度,没有对这些 <K, V> record进行更高层的抽象,导致只能使用map(K, V)这样的固定形式去处理数据,而无法使用类似面向对象程序的灵活数据处理方式,如records.operation()

    Spark人知道了这个缺点,将输入/输出、中间数据抽象表示为统一的数据模型(数据结构),命名为RDD(Resilient Distributed Datasets)。每个输入/输出、中间结果数据可以是一个具体的实例化的RDD,如ParallelCollectionRDD等。RDD中可以包含各种类型的数据,可以是普通的Int、Double,也可以是<K, V> record等。RDD与普通数据结构(如ArrayList)的主要区别有以下两点:

    • RDD只是一个逻辑概念,在内存中并不会真正地为某个RDD分配存储空间(除非该RDD需要被缓存)。**RDD中的数据只会在计算中产生,而且在计算完成后就会消失,**而ArrayList等数据结构常驻内存。
    • RDD可以包含多个数据分区不同数据分区可以由不同的任务(task)在不同的节点进行处理
  2. 数据操作:定义了数据模型后,我们可以对RDD进行各种数据操作,Spark将这些数据操作分为两种:transformation() 操作和action() 操作两者的区别是action()操作一般是对数据结果进行后处理(post-processing),产生输出结果,而且会触发Spark提交job真正执行处理任务。在普通C++/Java程序中,我们既可以对ArrayList上的数据进行统计分析再生成新的ArrayList,也可以对ArrayList中的数据进行修改,如ArrayList[i] = ArrayList[i] + 1。然而,在Spark中,因为数据操作一般是单向操作,通过流水线执行(pipline,后面介绍),还需要进行错误容忍等,所以RDD被设计成一个不可变类型,可以类比成一个不能修改其中元素的ArrayList。一直使用transformation()操作可以不断生成新的RDD,如 rdd1 = rdd0.map(func0),rdd2 = rdd1.fliter(func1)等;而action操作可以用来计算最后的结果,如rdd1.count()操作可以统计rdd1中包含的元素个数。

  3. 计算结果处理:由于RDD实际上是分布在不同机器上的,所以大数据应用的结果计算分为两种方式**:一种方式是直接将计算结果存放到分布式文件系统中**,如rdd.save(“hdfs://flie_location”),这种方式一般不需要在Driver端进行集中计算;另一种方式是需要在Driver端进行集中计算,如统计RDD中元素数目,需要先使用多个task统计每个RDD分区中的元素数目,然后将他们汇集到Driver端进行加和计算。例如,在图3.1中,每个分区进行action()操作得到部分计算结果result,然后将这些result发送到Driver端后对其进行执行f()函数,得到最终结果。

二、Spark逻辑处理流程生成方法

我们在写程序时会想到类似图3.1的逻辑处理流程。然而,Spark实际生成的逻辑处理流程图往往比我们头脑中想象的更加复杂。例如,会多出几个RDD,每个RDD会有不同的分区个数,RDD之间的数据依赖关系不同,等等。对于Spark来说,需要有一套通用的方法,其能够将应用程序自动转化为确定性的逻辑处理流程,也就是RDD及其之间的数据依赖关系。因此,需要解决以下3个问题。

  1. 根据应用程序如何产生RDD?产生什么样的RDD?
  2. 如何建立RDD之间的数据依赖关系?
  3. 如何计算RDD中的数据?

2.1 根据应用程序如何产生RDD?产生什么样的RDD?

  1. 如何产生RDD

    • 从数据源创建RDD,如1.1所讲,可以从内存、本地文件系统、分布式文件系统、网络流等中创建RDD

    • RDD之间的transform()会产生新的RDD,如1.3所讲,RDD是不可变的,在RDD上进行的任何修改操作,都会生成一个新的RDD,所以我们可以在transfrom()上创建RDD

  2. 产生什么样的RDD

    • Spark实际产生的RDD类型和个数与具体的transform()计算逻辑有关,需要依据其具体的算子进行确定

2.2 如何建立RDD之间的数据依赖关系?

我们已经知道transform()操作会形成新的RDD,那么接下来的问题就是如何建立RDD之间的数据依赖关系?数据依赖关系包含两方面:

  • 一方面是RDD之间的依赖关系,如一些transfrom()会对多个RDD进行操作,则需要建立这些RDD之间的关系
  • 另一方面是RDD本身具有分区特性,需要建立RDD自身分区之间的关联关系

具体地,我们需要解决以下3个问题:

  1. **如何建立RDD之间的数据依赖关系?**例如,生成的RDD是依赖于一个parentRDD,还是多个parentRDD?
  2. 新生成的RDD应该包含多少个分区?
  3. **新生成的RDD与其parent RDD中的分区间是什么依赖关系?**是依赖parent RDD中的一个分区还是多个分区呢?

2.2.1 如何建立RDD之间的数据依赖关系

第一个问题可以很自然的解决,对于一元操作,如rdd2 = rdd1.transformation() 可以确定rdd2只依赖rdd1,所以关联关系是"rdd1 => rdd2"。对于二元操作,如rdd3 = rdd1.join(rdd2),可以确定rdd3同时依赖rdd1和rdd2,关联关系是 “(rdd1, rdd2) => rdd3”。二元以上的操作可以类比二元操作。

2.2.2 新生成的RDD应该包含多少个分区

第二个问题是如何确定新生成RDD分区的个数?在Spark中,新生成的RDD的分区个数由用户和parent RDD共同确定,对于一些transformation,如join()操作,我们可以指定其生成的分区个数,如果个数不指定,则一般取其parent RDD的分区个数的最大值。还有一些操作,如map(),其生成的RDD的分区个数与数据源的分区个数相同

2.2.3 新生成的RDD与parent RDD中的分区间是什么依赖关系

这个问题比较复杂,分区之间的依赖关系既与transformation()的语义有关,也与RDD的分区个数有关。例如,在执行rdd2 = rdd1.map()时,map()对rdd1的每个分区中的每个元素进行计算,可以得到新的元素,类似一一映射,所以并不需要改变分区的个数,即rdd2的每个分区唯一依赖rdd1中对应的一个分区。

理论上,分区之间的数据依赖关系可以灵活自动移,如一对一映射、多对一映射、多对多映射或者任意映射等。但实际上,常见的数据操作的数据依赖关系具有一定的规律,Spark通过总结这些数据操作的数据依赖关系,将其分为两大类:窄依赖和宽依赖。

窄依赖:**一个RDD的分区最多对应一个子RDD的分区。**换句话说,一个子RDD的分区可以在设计时就确定地依赖于父RDD的的一组分区(这里说的确定性的一组分区,指分区间依赖关系是和数据无关的,能从逻辑上就确定,如coalesce)。窄依赖的transformation()可以在任意数据集上进行而不需要依赖其他分区的信息。

如下图,一个子RDD的分区只依赖一个父RDD的分区,如map、filter、mapPartitions、flatMap,右图是一个子RDD的分区依赖多个父RDD的分区,如coalesce

image-20230913114339196

宽依赖:**一个父RDD的分区对应多个子RDD的分区。**换句话说,一个子RDD的分区需要依赖于父RDD的所有分区,而且还不一定是所有分区中的全部数据,可能只是部分数据。宽依赖的transformation()要想执行,需要等到父RDD的所有分区全部就绪后子RDD才能执行,因为只有父RDD的所有分区计算完成,子RDD才知道它要依赖的数据。如sort、reduceByKey、groupByKey等(join稍微复杂一些,可能是宽依赖,也可能是窄依赖,取决于两个父RDD的分区器是否相同)。如下图

image-20230913114617990

2.3 如何计算RDD中的数据?

在上面的两个小节中,我们理解了如何生成RDD,以及建立RDD之间的数据依赖关系,但还有一个问题是,如何计算RDD中的数据?RDD中的每个分区包含n条数据,我们需要计算其中的每条数据,那么是怎么计算这些数据呢?

在确定了数据依赖关系后,相当于我们知道了child RDD中每个分区的输入数据是什么,那么只需要使用transformation(func)处理这些输入数据,将生成的数据推送到child RDD中对应的分区即可。在普通程序中,我们得到输入数据后,可以写任意的控制逻辑程序进行处理。例如,输入一个数组,我们可以对数组进行向前迭代、向后迭代或者逻辑循环等。然而,Spark中的大多数transformation()类似数学中的映射函数,具有固定的计算方式(控制流),如map(func)操作需要每读入一个record,就进行处理,然后输出一个record。reduceByKey(func)操作中func对中间结果和下一个record进行聚合计算并输出结果。

至此,我们就了解了逻辑处理流程的生成过程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spark 执行计划是指一系列的逻辑和物理转换,将 Spark 代码转换为可以在集群上执行的任务。Spark执行计划使用了许多优化技术,包括投影和过滤操作的下推、左外连接的优化、广播变量的优化等等。Spark执行计划主要分为以下两个阶段: 1. 逻辑执行计划Spark 将用户代码转换为一系列的逻辑操作,这些操作构成了逻辑执行计划逻辑执行计划是基于 RDD 抽象的,它描述了 RDD 之间的依赖关系和转换操作。 2. 物理执行计划Spark逻辑执行计划转换为一系列的物理操作,这些操作构成了物理执行计划。物理执行计划是基于具体的执行引擎的,它描述了如何将逻辑操作映射到实际的节点和任务上。 在执行计划的生成过程Spark 使用了许多优化技术,包括: 1. 延迟计算:Spark 采用了延迟计算的策略,即只有在需要计算结果时才会触发计算操作。这种策略可以避免不必要的计算,提高计算效率。 2. 任务划分:Spark 将大的数据集划分成小的分区,每个分区分配一个任务进行处理。这种策略可以实现并行计算,提高计算效率。 3. 数据共享:Spark 可以使用广播变量和累加器等机制实现数据共享,避免重复计算,提高计算效率。 4. 优化器:Spark 使用了一个优化器来对执行计划进行优化,包括选择最优的执行计划、下推操作等。 5. 缓存机制:Spark 可以使用缓存机制来避免重复计算,提高计算效率。 总之,Spark执行计划是一个非常重要的概念,它决定了 Spark 代码在集群上的执行方式和效率。Spark执行计划采用了许多优化技术,可以帮助用户快速、高效地处理大规模数据集。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值