Spark (二) 架构详解
博客专区 > bigsloth 的博客 > 博客详情
Spark (二) 架构详解
bigsloth 发表于1年前
Spark (二) 架构详解
  • 发表于 1年前
  • 阅读 288
  • 收藏 1
  • 点赞 0
  • 评论 0

腾讯云 新注册用户 域名抢购1元起>>>   

Spark主要模块包括调度与任务分配、I/O模块、通信控制模块、容错模块
以及Shuffle模块。Spark按照应用、作业、Stage和Task几个层次分别进行调度,采用了经
典的FIFO和FAIR等调度算法。在Spark的I/O中,将数据以块为单位进行管理,需要处理的块
可以存储在本机内存、磁盘或者集群中的其他机器中。集群中的通信对于命令和状态的传递
极为重要,Spark通过AKKA框架进行集群消息通信。分布式系统中的容错十分重要,Spark
通过Lineage(血统)和Checkpoint机制进行容错性保证。

Spark应用提交后经历了一系列的转换,最后成为Task在每个节点上执行。Spark应用转
换:RDD的Action算子触发Job的提交,提交到Spark中的Job生成RDD DAG,由
DAGScheduler转化为Stage DAG,每个Stage中产生相应的Task集合,TaskScheduler将任
务分发到Executor执行。每个任务对应相应的一个数据块,使用用户定义的函数处理数据
块。

RDD的块管理通过BlockManger完成,BlockManager将数据抽象为数据块,在内存或者磁盘进行存储,如果数据不在本节点,则还可以通过远端节点复制到本机进行计算。

 

调度

调度可以分为4个级别,Application调度、Job调度、Stage的调度、Task的调度与分发

应用调度:区分Standalone和mesos、yarn的不同方式调度,通过FIFO调度方式。一个executor在一个时间段内只能分配给一个应用使用。

Job调度:Spark应用程序内部,用户通过不同线程提交的Job可以并行运行,这里所说的Job就
是Spark Action(如count、collect等)算子触发的整个RDD DAG为一个Job,在实现上,算
子中的本质是调用SparkContext中的runJob提交了Job。FIFO或FAIR(多用户轮询,单用户内部也是FIFO)方式,支持权重。

Stage调度:由DAGScheduler完成。RDD的有向无环图DAG切分出了Stage的有向无环图DAG。Stage的DAG通过最后执行的Stage为根进行广度优先遍历,遍历到最开始执行的Stage执行,如果提交的Stage仍有未完成的父母Stage,则Stage需要等待其父Stage执行完才能执行。同时DAGScheduler中还维持了几个重要的Key-Value集合结构,用来记录Stage的状态,这样能够避免过早执行和重复提交Stage。waitingStages中记录仍有未执行的父母Stage,防止过早执行。runningStages中保存正在执行的Stage,防止重复执行。
failedStages中保存执行失败的Stage,需要重新执行,这里的设计是出于容错的考虑。每个Stage对应的一个TaskSetManager通过Stage回溯到最源头缺失的Stage提交到调度池pool中,在调度池中,这些TaskSetMananger又会根据Job ID排序,先提交的Job的TaskSetManager优先调度,然后一个Job内的TaskSetManager ID小的先调度,并且如果有未执行完的父母Stage的TaskSetManager,则是不会提交到调度池中。

Task调度:整体的Task分发由TaskSchedulerImpl来实现,但是Task的调度(本质上是Task在哪个分区执行)逻辑由TaskSetManager完成。这个类监控整个任务的生命周期,当任务失败时(如执行时间超过一定的阈值),重新调度,也会通过delay scheduling进行基于位置感知(locality-aware)的任务调度。TaskSchedulerImpl类有几个主要接口:接口resourceOffer,作用为判断任务集合是否需要在一个节点上运行。接口statusUpdate,其主要作用为更新任务状态。

 

序列化与压缩

Spark的通信由Actor消息、Java NIO,Netty的OIO协同

可以使用的序列化方式包括java原生、Kyro

Spark支持几种压缩算法:Snappy和LZF,底层分别采用了两个第三方库实现,同时可以自定义其他压缩库对Spark进行扩展。Snappy提供了更高的压缩速度,LZF提供了更高的压缩比。由参数spark.io.compression.codec配置

spark.broadcast.compress参数配置广播变量是否压缩。

spark.rdd.compress参数配置是否压缩一个已经序列化的RDD

 

块管理

RDD在逻辑上是按照Partition分块的,可以将RDD看成是一个分区作为数据项的分布式数组。这也是Spark在极力做到的一点,让编写分布式程序像编写单机程序一样简单。而物理上存储RDD是以Block为单位的,一个Partition对应一个Block,用Partition的ID通过元数据的映射到物理上的Block,而这个物理上的Block可以存储在内存,也可以存储在某个节点的Spark的硬盘临时目录,等等。整体的I/O管理分为以下两个层次。
1)通信层:I/O模块也是采用Master-Slave结构来实现通信层的架构,Master和Slave之间传输控制信息、状态信息。
2)存储层:Spark的块数据需要存储到内存或者磁盘,有可能还需传输到远端机器,这些是由存储层完成的。

(1)管理和接口
BlockManager:当其他模块要和storage模块进行交互时,storage模块提供了统一的操作类BlockManager,外部类与storage模块打交道都需要调用BlockManager相应接口来实现。

(2)通信层
·BlockManagerMasterActor:从主节点创建,从节点通过这个Actor的引用向主节点传递消息和状态。
·BlockManagerSlaveActor:在从节点创建,主节点通过这个Actor的引用向从节点传递命令,控制从节点的块读写。
·BlockManagerMaster:对Actor通信进行管理。
(3)数据读写层
·DiskStore:提供Block在磁盘上以文件形式读写的功能。
·MemoryStore:提供Block在内存中的Block读写功能。
·ConnectionManager:提供本地机器和远端节点进行网络传输Block的功能。
·BlockManagerWorker:对远端数据的异步传输进行管理。

整体的数据存储通信仍相当于Master-Slave模型,节点之间传递消息和状态,Master节点负责总体控制,Slave节点接收命令、汇报状态。(补充介绍:Actor和ref是AKKA中两个不同的Actor引用。)
BlockManager的创建对于Master和Slave来说有所不同。
(1)Master端
BlockManagerMaster对象拥有BlockManagerMasterActor的actor引用以及所有BlockManagerSlaveActor的ref引用。
(2)Slave端
对于Slave,BlockManagerMaster对象拥有BlockManagerMasterActor对象的ref的引用和自身BlockManagerSlaveActor的actor的引用。BlockManagerMasterActor在ref和Actor之间通信,BlockManagerSlaveActor在ref和Actor之间通信。BlockManager在内部封装BlockManagerMaster,并通过BlockManagerMaster进行通信。Spark在各节点创建各自的BlockManager,通过BlockManager对storage模块进行操作。BlockManager对象在SparkEnv中创建,SparkEnv相当于线程的上线下文变量,在
SparkEnv中也会创建很多的管理组件。

 

读写流程

数据写入的简要流程,读取流程和写入流程类似。数据写入流程主要分为以下几个步骤。
1)RDD调用compute()方法进行指定分区的写入。
2)CacheManager中调用BlockManater判断数据是否已经写入,如果未写则写入。
3)BlockManager中数据与其他节点同步。
4)BlockManager根据存储级别写入指定的存储层。
5)BlockManager向主节点汇报存储状态。

 

容错

一般来说,分布式数据集的容错性有两种方式:数据检查点和记录数据的更新。面向大规模数据分析,数据检查点操作成本很高,需要通过数据中心的网络连接在机器之间复制庞大的数据集,而网络带宽往往比内存带宽低得多,同时还需要消耗更多的存储资源。因此,Spark选择记录更新的方式。但是,如果更新粒度太
细太多,那么记录更新成本也不低。因此,RDD只支持粗粒度转换,即在大量记录上执行的单个操作。将创建RDD的一系列Lineage(即血统)记录下来,以便恢复丢失的分区。Lineage本质上很类似于数据库中的重做日志(Redo Log),只不过这个重做日志粒度很大,是对全局数据做同样的重做进而恢复数据。

Lineage

相比其他系统的细颗粒度的内存数据更新级别的备份或者LOG机制,RDD的Lineage记录的是粗颗粒度的特定数据Transformation操作(如filter、map、join等)行为。当这个RDD的部分分区数据丢失时,它可以通过Lineage获取足够的信息来重新运算和恢复丢失的数据分区。因为这种粗颗粒的数据模型,限制了Spark的运用场合,所以Spark并不适用于所有高性能要求的场景,但同时相比细颗粒度的数据模型,也带来了性能的提升。

RDD在Lineage依赖方面分为两种:Narrow Dependencies与Shuffle Dependencies,用来解决数据容错的高效性。Narrow Dependencies是指父RDD的每一个分区最多被一个子RDD的分区所用,表现为一个父RDD的分区对应于一个子RDD的分区或多个父RDD的分区对应于一个子RDD的分区,也就是说一个父RDD的一个分区不可能对应一个子RDD的多个分区。Shuffle Dependencies是指子RDD的分区依赖于父RDD的多个分区或所有分区,即存在一个父RDD的一个分区对应一个子RDD的多个分区。
本质理解:根据父RDD分区是对应1个还是多个子RDD分区来区分NarrowDependency(父分区对应一个子分区)和Shuffle Dependency(父分区对应多个子分区)。如果对应多个,则当容错重算分区时,因为父分区数据只有一部分是需要重算子分区的,其余数据重算就造成了冗余计算。

如果一个节点死机了,而且运算Narrow Dependency,则只要把丢失的父RDD分区重算即可,不依赖于其他节点。而Shuffle Dependency需要父RDD的所有分区都存在,重算就很昂贵了。可以这样理解开销的经济与否:在Narrow Dependency中,在子RDD的分区丢失、重算父RDD分区时,父RDD相应分区的所有数据都是子RDD分区的数据,并不存在冗余计算。在Shuffle Dependency情况下,丢失一个子RDD分区重算的每个父RDD的每个分区的所有数据并不是都给丢失的子RDD分区用的,会有一部分数据相当于对应的是
未丢失的子RDD分区中需要的数据,这样就会产生冗余计算开销,这也是ShuffleDependency开销更大的原因。因此如果使用Checkpoint算子来做检查点,不仅要考虑Lineage是否足够长,也要考虑是否有宽依赖,对Shuffle Dependency加Checkpoint是最物有所值的

CheckPoint

以下两种情况下,RDD需要加检查点。
1)DAG中的Lineage过长,如果重算,则开销太大。
2)在Shuffle Dependency上做Checkpoint(检查点)获得的收益更大

由于RDD是只读的,所以Spark的RDD计算中一致性不是主要关心的内容,内存相对容易管理

传统做检查点有两种方式:通过冗余数据和日志记录更新操作。在RDD中的doCheckPoint方法相当于通过冗余数据来缓存数据,而之前介绍的血统就是通过相当粗粒度的记录更新操作来实现容错的

可以通过SparkContext.setCheckPointDir()设置检查点数据的存储路径,进而将数据存储备份,然后Spark删除所有已经做检查点的RDD的祖先RDD依赖。这个操作需要在所有需要对这个RDD所做的操作完成之后再做,因为数据会写入持久化存储造成I/O开销。官方建议,做检查点的RDD最好是在内存中已经缓存的RDD,否则保存这个RDD在持久化的文件中需要重新计算,产生I/O开销

 

Shuffle

Spark中的Shuffle更像是洗牌的逆过程,把一组无规则的数据尽量转换成一组具有一定规则的数据,Spark中的Shuffle和MapReduce中的Shuffle思想相同。Shuffle分为两个阶段:Shuffle Write和Shuffle Fetch阶段(Shuffle Fetch中包含聚集Aggregate),在Spark中,整个Job转化为一个有向无环图(DAG)来执行,从图中可以看出在整个DAG中是在每个Stage的承接阶段做Shuffle过程。

首先从最上端的Stage2、Stage3执行,每个Stage对每个分区执行变换(transformation)的流水线式的函数操作,执行到每个Stage最后阶段进行Shuffle Write,将数据重新根据下一个Stage分区数分成相应的Bucket,并将Bucket最后写入磁盘。这个过程就是Shuffle Write阶段。
执行完Stage2、Stage3之后,Stage1去存储有Shuffle数据节点的磁盘Fetch需要的数据,将数据Fetch到本地后进行用户定义的聚集函数操作。这个阶段叫ShuffleFetch,Shuffle Fetch包含聚集阶段。这样一轮一轮的Stage之间就完成了Shuffle操作

Shuffle Write

Spark的每个Stage中是通过执行任务来进行运算的,而Spark中只分为两种任务,ShuffleMapTask和ResultTask。其中ResultTask就是最底层的Stage,也是整个任务执行的最后阶段将数据输出到Spark执行空间Stage,除了这个阶段执行ResultTask,其余阶段都执行ShuffleMapTask。因此主要的Shuffle Write逻辑存在这种任务的代码中.

Spark支持两种类型的Shuffle:Shuffle和优化的Consolidate Shuffle

Shuffle

Shuffle的整体流程,假定该Shuffle中有3个Mapper和2个Reducer,这样会产生3×2=6个Bucket,也就是会产生6个Shuffle文件。因此,产生的Shuffle文件个数为M×R,M是Map任务个数,R是Reduce任务数。

Consolidate Shuffle

Consolidation Shuffle的流程图。其中每一个Bucket并非对应一个文件,而是对应文件中的一个segment,同时Consolidation Shuffle所产生的Shuffle文件数量与SparkCore的个数也具有相关性。在上面的图例中,Job的4个Mapper分为两批运行,在第一批2个Mapper运行时,申请4个Bucket,产生4个Shuffle文件;在第二批Mapper运行时,由于只有一个Mapper,申请的4个bucket并不会再产生4个新的文件,而是追加写到之前的其中两个文件后面,这样一共只有4个shuffle文件,而在文件内部这有6个不同的segment。因此,从理论上讲Shuffle Consolidation所产生的shuffle文件数量为C×R,其中C是Spark集群的Core
Number,R是Reducer的个数特殊情况是当M=C时,Consolidation Shuffle所产生的文件数和之前的实现相同。
Consolidation Shuffle显著减少了shuffle文件的数量,解决了文件数量过多的问题,但是Writer Handler的Buffer开销过大依然没有减少,若要减少Writer Handler的Buffer开销,只能减少Reducer的数量,但是这又会引入新的问题。

 

ShuffleFetch

Shuffle write阶段写到各个节点的数据,Reducer端的节点通过拉取数据进而获取需要的数据,在Spark中这个叫Fetch。这就需要Shuffle Fetcher将所需的数据拉过来。这里的fetch包括本地和远端,因为shuffle数据有可能一部分存储在本地。Spark使用两套框架实现Shuffle Fetcher:NIO通过Socket连接去fetch数据;OIO通过Netty去Fetch数据,分别对应的类是BasicBlockFetcherIterator和NettyBlockFetcherIterator。
Spark的团队最终还是想用一个NIO的通信层来解决问题,但是经过性能测试,在一些特定情况下,如集群CPU核数很多地进行大规模Shuffle时,NIO性能表现不如OIO,所以Spark开发团队目前选择让二者共存。

Shuffle Fetch和聚集Aggregate的操作过程是边Fetch数据边处理,而不是一次性Fetch完再处理。通过Aggregate的数据结构,AppandOnlyMap(一个Spark封装的哈希表)。Shuffle Fetch得到一条Key-Value对,直接将其放进AppandOnlyMap中。如果该HashMap已经存在相应的Key,那么直接处理用户自定义聚集函数,合并聚集数据。

Shuffle Aggregator

Spark的聚集方式分为两种:不需要外排和需要外排的。不需要外排的聚集,在内存中的AppendOnlyMap中对数据进行聚集,而需要外排的聚集,先在内存做聚集,当内存数据达到阈值时,将数据排序后写入磁盘,由于磁盘的每部分数据只是整体的部分数据,最后再将磁盘数据全部进行合并和聚集。实现上,分别采用了不同自定义容器收集聚集。Aggregator采用封装好的数据容器存储Key-Value,本质上是一个哈希表来存储。

AppendOnlyMap不需要外排的聚集。容器本质上可以理解为一个HashMap。需要外排的聚集的原因是,如果是Reduce型的操作,则数据不断被计算合并,数据量不会暴增。考虑一下如果是groupByKey这样的操作,Reducer需要得到Key对应的所有Value。Spark需要将Key-Value全部存放在Hashmap中,并将Value合并成一个数组。为了能够存放所有数据,必须确保每一个分区足够小,内存能够容纳这个分区。因此官方建议涉及这类操作时,尽量增加分区数量,也就是增加Mapper和Reducer的数量。增加Mapper和Reducer的数量可以减小分区的大小,使得内存可以容纳这个分区。Bucket的数量由Mapper和Reducer的数量决定,Task越多,Bucket增加得越多,由此带来Writer所需的Buffer缓存也会更多。增加Task数量,又会带来缓冲开销更大的问题,正是这个原因,Spark提供了外排方案。

 

Spark SQL

Spark SQL与传统DBMS的查询优化器+执行器的架构较为类似,只不过其执行器是在分布式环境中实现,并采用Spark作为执行引擎。Spark SQL的查询优化是Catalyst,其基于Scala语言开发,可以灵活利用Scala原生的语言特性方便地扩展功能,奠定了Spark SQL的发展空间。Catalyst将SQL翻译成最终的执行计划,并在这个过程中进行查询优化。这里和传统不太一样的地方就在于,SQL经过查询优化器最终转换为可执行的查询计划,传统DB就可以执行这个查询计划了。而Spark SQL最后执行还是会在Spark内将执行计划转换为Spark的有向无环图DAG再执行。

核心的Catalyst的执行流程为

Spark SQL的一些优化策略

(1)内存列式存储与内存缓存表
Spark SQL可以通过cacheTable将数据存储转换为列式存储,同时将数据加载到内存缓存。cacheTable相当于在分布式集群的内存物化视图,将数据缓存,这样迭代的或者交互式的查询不用再从HDFS读数据,直接从内存读取数据大大减少了I/O开销。列式存储的优势在于Spark SQL只需要读出用户需要的列,而不需要像行存储那样每次都将所有列读出,从而大大减少内存缓存数据量,更高效地利用内存数据缓存,同时减少网络传输和I/O开销。数据按照列式存储,由于是数据类型相同的数据连续存储,所以能够利用序列化和压缩减少内存空间的占用。
(2)列存储压缩
为了减少内存和硬盘空间占用,Spark SQL采用了一些压缩策略对内存列存储数据进行压缩。Spark SQL的压缩方式要比Shark丰富很多,如它支持PassThrough、RunLengthEncoding、DictionaryEncoding、BooleanBitSet、IntDelta、LongDelta等多种压缩方式,这样能够大幅度减少内存空间占用、网络传输和I/O开销。
(3)逻辑查询优化
SparkSQL在逻辑查询优化(见图8-4)上支持列剪枝、谓词下压、属性合并等逻辑查询优化方法。列剪枝为了减少读取不必要的属性列、减少数据传输和计算开销,在查询优化器进行转换的过程中会优化列剪枝。

(4)Join优化
Spark SQL对Join进行了优化,支持多种连接算法,现在的连接算法已经比Shark丰富,而且很多原来Shark的元素也逐步迁移过来,如BroadcastHashJoin、BroadcastNestedLoopJoin、HashJoin、LeftSemiJoin,等等。
BroadcastHashJoin将小表转化为广播变量进行广播,这样避免Shuffle开销,最后在分区内做Hash连接

共有 人打赏支持
粉丝 5
博文 53
码字总数 47326
×
bigsloth
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: