Spark Core基础概念
RDD概念
RDD是Spark的计算模型 RDD(Resilient Distributed Dataset)叫做弹性的分布式数据集合,是[Spark]中最基本的数据抽象,它代表一个不可变、只读的,被分区的数据集。
通俗点来讲,可以将 RDD 理解为一个分布式对象集合,本质上是一个只读的分区记录集合。每个 RDD 可以分成多个分区,每个分区就是一个数据集片段。一个 RDD 的不同分区可以保存到集群中的不同结点上,从而可以在集群中的不同结点上进行并行计算。
下图 展示了 RDD 的分区及分区与工作结点(Worker Node)的分布关系。
RDD 具有容错机制,并且只读不能修改,可以执行确定的转换操作创建新的 RDD。具体来讲,RDD 具有以下几个属性。
- 只读: 不能修改,只能通过转换操作生成新的 RDD。
- 分布式: 可以分布在多台机器上进行并行处理。
- 弹性: 计算过程中内存不够时它会和磁盘进行数据交换。
- 基于内存:可以全部或部分缓存在内存中,在多次计算间重用。
RDD 基本操作
RDD提供了一组丰富的操作以支持常见的数据运算分别为
- “转换算子”(Transformation)
转换操作(比如map、filter、groupBy、join等)接受RDD并返回RDD
- “行动算子”(Action)
行动操作(比如count、collect等)接受RDD但是返回非RDD(即输出一个值或结果),用于执行计算并指定输出的形式。
需要说明的是,RDD采用了惰性调用(lazy模式),即在RDD的执行过程中(如下图所示),真正的计算发生在RDD的“行动”操作,对于“行动”之前的所有“转换”操作,Spark只是记录下“转换”操作应用的一些基础数据集以及RDD生成的轨迹,即相互之间的依赖关系,而不会触发真正的计算。
RDD 血缘
RDD 的最重要的特性之一就是血缘关系(Lineage ),它描述了一个 RDD 是如何从父 RDD 计算得来的。如果某个 RDD 丢失了,则可以根据血缘关系,从父 RDD 计算得来。
下图RDD 执行过程的实例。系统从输入中逻辑上生成A和C两个RDD,经过一系列“转换”操作,逻辑上生成了F(也是一个RDD),之所以说是逻辑上,是因为这时候计算并没有发生,Spark只是记录了RDD之间的生成和依赖关系。当F要进行输出时,也就是当F进行“行动”操作的时候,Spark才会根据RDD的依赖关系生成DAG,并从起点开始真正的计算。
RDD 血缘依赖
根据不同的转换操作,RDD 血缘关系的依赖分为窄依赖和宽依赖。
- 窄依赖是指父 RDD 的每个分区都只被子 RDD 的一个分区所使用。
- 宽依赖是指父 RDD 的每个分区都被多个子 RDD 的分区所依赖。
由于宽依赖会带来“洗牌”,所以不同的 Stage 是不能并行计算的,后面 Stage 的 RDD 的计算需要等待前面 Stage 的 RDD 的所有分区全部计算完毕以后才能进行。 spark的DAGScheduler根据宽依赖将DAG划分为多个stage。
RDD 窄依赖
窄依赖判定
- 子 RDD 的每个分区依赖于常数个父分区(即与数据规模无关)。
- 输入输出一对一的算子,且结果 RDD 的分区结构不变,如 map、flatMap。
- 输入输出一对一的算子,但结果 RDD 的分区结构发生了变化,如 union。
- 从输入中选择部分元素的算子,如 filter、distinct、subtract、sample。
RDD 宽依赖
- 子 RDD 的每个分区依赖于所有父 RDD 分区。
- 对单个 RDD 基于 Key 进行重组和 reduce,如 groupByKey、reduceByKey。
- 对两个 RDD 基于 Key 进行 join 和重组,如 join。
PS: join 操作有两种情况,如果 join 操作中使用的每个 Partition 仅仅和固定个 Partition 进行 join,则该 join 操作是窄依赖,其他情况下的 join 操作是宽依赖。
所以窄依赖不仅包含一对一的窄依赖,还包含一对固定个数的窄依赖,例如与维度小表进行join。
Spark的这种血缘依赖关系设计,使其具有了天生的容错性,大大加快了Spark的执行速度。
因为,RDD数据集通过“血缘关系”记住了它是如何从其它RDD中演变过来的,血缘关系记录的是粗颗粒度的转换操作行为,当这个RDD的部分分区数据丢失时,它可以通过血缘关系获取足够的信息来重新运算和恢复丢失的数据分区,由此带来了性能的提升。
相对而言,在两种依赖关系中,窄依赖的失败恢复更为高效,它只需要根据父RDD分区重新计算丢失的分区即可(不需要重新计算所有分区),而且可以并行地在不同节点进行重新计算。
而对于宽依赖而言,单个节点失效通常意味着重新计算过程会涉及多个父RDD分区,开销较大。此外,Spark还提供了数据检查点和记录日志,用于持久化中间RDD,从而使得在进行失败恢复时不需要追溯到最开始的阶段。
在进行故障恢复时,Spark会对数据检查点开销和重新计算RDD分区的开销进行比较,从而自动选择最优的恢复策略。
阶段的划分
Spark里的几个概念。一个Spark应用程序包括Job、Stage以及Task三个概念:
- job:以 action 方法为界,一个 action 触发一个 job
- stage:它是 job 的子集,以 RDD 宽依赖为界,遇到宽依赖即划分 stage
- task:它是 stage 的子集,以分区数来衡量,分区数多少,task 就有多少
当在程序中遇到一个action算子的时候,就会提交一个job,执行前面的一系列操作。
首先 Spark通过分析各个RDD的依赖关系生成了DAG,再通过分析各个RDD中的分区之间的依赖关系来决定如何划分阶段。
具体划分方法是:在DAG中进行反向解析,遇到宽依赖就断开,遇到窄依赖就把当前的RDD加入到当前的阶段中;将窄依赖尽量划分在同一个阶段中。
例如,如下图所示,假设从HDFS中读入数据生成3个不同的RDD(即A、C和E),通过一系列转换操作后再将计算结果保存回HDFS。对DAG进行解析时,在依赖图中进行反向解析,由于从RDD A到RDD B的转换以及从RDD B和F到RDD G的转换,都属于宽依赖,因此,在宽依赖处断开后可以得到三个阶段,即阶段1、阶段2和阶段3。可以看出,在阶段2中,从map到union都是窄依赖,这两步操作可以形成一个流水线操作,比如,分区7通过map操作生成的分区9,可以不用等待分区8到分区9这个转换操作的计算结束,而是继续进行union操作,转换得到分区13,这样流水线执行大大提高了计算的效率。
备注:流水线机制优化:在窄依赖的情况下,Spark可以将多个转换操作(如map、filter等)合并成一个任务连续执行,而不需要在每个操作之间写入磁盘或进行网络传输。这类似于在生产线上,一个产品可以从一个加工步骤直接进入下一个步骤,而不需要回到仓库中等待。这种优化减少了I/O开销,提高了执行效率。
下图是spark从job到task的调度示意图
缓存和检查点
缓存
- 为什么要用缓存
读取数据源计算完成之后,Spark会将内存中的数据清除,这样处理的好处是避免了OOM问题(内存溢出)
当再从从hdfs中读取数据,计算时,如果文件数据量很大,这个过程就会很耗性能。
RDD 可以使用 persist() 方法或 cache() 方法进行持久化或称为缓存。下图是存储级别示意图:
检查点
检查点(本质是通过将RDD写入Disk做检查点)是为了通过血缘(lineage)做容错的辅助,血缘(lineage)过长会造成容错成本过高,这样就不如在中间阶段做检查点容错,如果之后有节点出现问题而丢失分区,从做检查点的RDD开始重做血缘(lineage),就会减少开销。检查点通过将数据写入到HDFS文件系统实现了RDD的检查点功能。
适合使用检查点机制的场景:
- DAG中的Lineage过长,如果重算,则开销太大。
- 在宽依赖上做Checkpoint获得的收益更大。
检查点与缓存区别
缓存把 RDD 计算中间结果存放在内存中,但是RDD 的依赖链也不能丢掉, 当某个 executor 所在的服务器宕机或故障,上面cache 的RDD就会丢掉, 需要通过依赖链重新计算出来。
checkpoint 是把 RDD 保存在 HDFS中, 是多副本可靠存储,所以依赖链就可以丢掉了,就斩断了依赖链, 是通过数据复制冗余实现的高容错。
共享变量
在默认情况下,当Spark在集群的多个不同节点的多个任务上并行运行一个函数时,它会把函数中涉及到的每个变量,在每个任务上都生成一个副本。
但是,有时候,需要在多个任务之间共享变量,或者在任务(Task)和任务控制节点(Driver Program)之间共享变量。
为了满足这种需求,Spark提供了两种类型的变量:广播变量(broadcast variables)和累加器(accumulators):
广播变量(Broadcast)
集群Driver会将使用到的变量复制到工作节点上且仅复制一份,所有Task共享这份拷贝。这种方式一方面解决共享场景需求,其次也能够减少网络传输与内存负载。
累加器(Accumulator)
累加器是仅仅被相关操作累加的变量,通常可以被用来实现计数器(count)和求和(sum)。
多个Task能够引用同一份变量并执行累加操作。
Task只能对累加器(Accumulator)进行累加操作,不能读取它的值。只有Driver程序可以读取累加器(Accumulator)的值。