Apache Spark是一个围绕速度、易用性和复杂分析构建的大数据处理框架,Spark有如下优势:
其架构示意图如下:
Spark架构的组成图如下:
standalone
模式中即为Master主节点,控制整个集群,监控Worker
。在Yarn
模式中为资源管理器。Executor
或者Driver
。Hadoop的局限和不足
但是,MapRecue存在以下局限,使用起来比较困难。
(1)Hadoop有两个核心模块,分布式存储模块HDFS
和分布式计算模块MapReduce
,Spark本身并没有提供分布式文件系统,因此Spark的磁盘存储大多依赖于Hadoop的分布式文件系统HDFS。
(2)Hadoop的MapReduce与Spark都可以进行数据计算。Spark与MapReduce最大的不同在于计算模型:
所以,Spark相较于MapReduce来说,计算模型可以提供更强大的功能。
Yarn是一个资源管理、任务调度的框架,主要包含三大模块:ResourceManager(RM)、NodeManager(NM)、ApplicationMaster(AM)。
对于所有的applications,RM拥有绝对的控制权和对资源的分配权。而每个AM则会和RM协商资源,同时和NodeManager通信来执行和监控task。几个模块之间的关系如图所示:
Yarn总体上仍然是master/slave结构,在整个资源管理框架中,ResourceManager为master,NodeManager是slave。ResourceManager负责对各个NodeManger上资源进行统一管理和调度。当用户提交一个应用程序时,需要提供一个用以跟踪和管理这个程序的ApplicationMaster,它负责向ResourceManager申请资源,并要求NodeManger启动可以占用一定资源的任务。由于不同的ApplicationMaster被分布到不同的节点上,因此它们之间不会相互影响。
ResourceManager是Master上一个独立运行的进程,负责集群统一的资源管理、调度、分配等等;NodeManager是Slave上一个独立运行的进程,负责上报节点的状态;App Master和Container是运行在Slave上的组件,Container是Yarn中分配资源的一个单位,包涵内存、CPU等等资源,Yarn以Container为单位分配资源。RM是一个全局的资源管理器,集群只有一个,负责整个系统的资源管理和分配,包括处理客户端请求、启动/监控APP master、监控NodeManager、资源的分配与调度。它主要由两个组件构成:调度器(Scheduler)和应用程序管理器(Applications Manager,ASM)。
(1) 调度器Scheduler
调度器根据容量、队列等限制条件(如每个队列分配一定的资源,最多执行一定数量的作业等),将系统中的资源分配给各个正在运行的应用程序。需要注意的是,该调度器是一个“纯调度器”,它不再从事任何与具体应用程序相关的工作,比如不负责监控或者跟踪应用的执行状态等,也不负责重新启动因应用执行失败或者硬件故障而产生的失败任务,这些均交由应用程序相关的ApplicationMaster
完成。调度器仅根据各个应用程序的资源需求进行资源分配,而资源分配单位用一个抽象概念“资源容器”(Resource Container,简称Container)表示,Container是一个动态资源分配单位,它将内存、CPU、磁盘、网络等资源封装在一起,从而限定每个任务使用的资源量。此外,该调度器是一个可插拔的组件,用户可根据自己的需要设计新的调度器,Yarn提供了多种直接可用的调度器,比如Fair Scheduler和Capacity Scheduler等。
(2) 应用程序管理器
应用程序管理器负责管理整个系统中所有应用程序,包括应用程序提交、与调度器协商资源以启动ApplicationMaster、监控ApplicationMaster运行状态并在失败时重新启动它等。
ApplicationMaster
的必须信息,例如ApplicationMaster
程序、启动ApplicationMaster
的命令、用户程序等。ResourceManager
启动一个Container
用于运行ApplicationMaster。ApplicationMaster
向ResourceManager
注册自己,启动成功后与RM保持心跳。ApplicationMaster
向ResourceManager
发送请求,申请相应数目的Container。ResourceManager
返回ApplicationMaster
的申请的Containers
信息。申请成功的Container
,由ApplicationMaster
进行初始化。Container
的启动信息初始化后,AM
与对应的NodeManager
通信,要求NM
启动Container
。AM与NM保持心跳,从而对NM上运行的任务进行监控和管理。Container
运行期间,ApplicationMaster
对Container
进行监控。Container
通过RPC协议向对应的AM
汇报自己的进度和状态等信息。Client
直接与AM
通信获取应用的状态、进度更新等信息。ApplicationMaster
向ResourceManager
注销自己,并允许属于它的Container
被收回。(1)Application: Appliction都是指用户编写的Spark应用程序,其中包括一个Driver功能的代码和分布在集群中多个节点上运行的Executor代码。
(2)Driver: Spark中的Driver即运行上述Application的main函数并创建SparkContext,创建SparkContext的目的是为了准备Spark应用程序的运行环境,在Spark中有SparkContext负责与ClusterManager通信,进行资源申请、任务的分配和监控等,当Executor部分运行完毕后,Driver同时负责将SparkContext关闭,通常用SparkContext代表Driver。
(3)Executor: 某个Application运行在Worker节点上的一个进程, 该进程负责运行某些Task, 并且负责将数据存到内存或磁盘上,每个Application都有各自独立的一批Executor。
(4)Cluter Manager:指的是在集群上管理资源的外部服务。目前有三种类型
Standalon
: Spark原生的资源管理,由Master负责资源的分配Apache Mesos
: 与hadoop MR兼容性良好的一种资源调度框架Hadoop Yarn
: 主要是指Yarn中的ResourceManager(5)Worker: 集群中任何可以运行Application代码的节点,在Standalone模式中指的是通过slave文件配置的Worker节点,在Spark on Yarn模式下就是NoteManager节点。
(6)Task: 被送到某个Executor上的工作单元,但Hadoop MR中的MapTask和ReduceTask概念一样,是运行Application的基本单位,多个Task组成一个Stage,而Task的调度和管理等是由TaskScheduler负责。
(7)Job: 包含多个Stage组成的并行计算,往往由Spark Action触发生成。
(8)Stage: 每个Job会被拆分成多组Task, 作为一个TaskSet, 其名称为Stage,Stage的划分和调度是由DAGScheduler
来负责的,Stage有非最终的Stage(Shuffle Map Stage)和最终的Stage(Result Stage)两种,Stage的边界就是发生shuffle的地方。
(9)DAGScheduler: 根据Job构建基于Stage的DAG(Directed Acyclic Graph有向无环图),并提交Stage给TASkScheduler。 其划分Stage的依据是RDD之间的依赖的关系找出开销最小的调度方法,如下图
(10)TaskSedulter: 将TaskSet提交给Worker运行,每个Executor运行什么Task就是在此处分配的. TaskScheduler维护所有TaskSet,当Executor向Driver发生心跳时,TaskScheduler会根据资源剩余情况分配相应的Task。另外TaskScheduler还维护着所有Task的运行标签,重试失败的Task。下图展示了TaskScheduler的作用
在不同运行模式中任务调度器具体为:
TaskScheduler
YarnClientClusterScheduler
YarnClusterScheduler
Job=多个stage,Stage=多个同种task, Task分为ShuffleMapTask和ResultTask,Dependency分为ShuffleDependency和NarrowDependency。
Standalone模式使用Spark自带的资源调度框架,采用Master/Slaves的典型架构,选用ZooKeeper来实现Master的HA,框架结构图如下:
该模式主要的节点有Client节点、Master节点和Worker节点。其中Driver既可以运行在Master节点上中,也可以运行在本地Client端。当用Spark-shell交互式工具提交Spark的Job时,Driver在Master节点上运行;当使用Spark-submit工具提交Job或者在Eclips、IDEA等开发平台上使用”new SparkConf.setManager(“Spark://master:7077”)”方式运行Spark任务时,Driver是运行在本地Client端上的
当在YARN上运行Spark作业,每个Spark Executor作为一个YARN容器(container)运行。Spark可以使得多个Tasks在同一个容器(Container)里面运行。Spark on YARN
有两种模式,yarn-cluster
适用于生产环境;而yarn-client
适用于交互和调试,也就是希望快速地看到application的输出。
在我们介绍yarn-cluster和yarn-client的深层次的区别之前,我们先明白一个概念:Application Master。在YARN中,每个Application实例都有一个Application Master进程,它是Application启动的第一个容器。它负责和ResourceManager打交道,并请求资源。获取资源之后告诉NodeManager为其启动Container。
yarn-cluster模式下,Driver运行在AM中,它负责向YARN申请资源,并监督作业的运行状况。当用户提交了作业之后,就可以关掉Client,作业会继续在YARN上运行。然而yarn-cluster模式不适合运行交互类型的作业。
而yarn-client模式下,Application Master仅仅向YARN请求executor,client会和请求的container通信来调度他们工作,也就是说Client不能离开。
弹性分布式数据集(RDD,Resilient Distributed Datasets)支持基于工作集的应用,同时具有数据流模型的特点:自动容错、位置感知调度和可伸缩性。RDD允许用户在执行多个查询时显式地将工作集缓存在内存中,后续的查询能够重用工作集,这极大地提升了查询速度。
RDD提供了一种高度受限的共享内存模型,即RDD是只读的记录分区的集合,只能通过在其他RDD执行转换操作(如map
、join
和group by
)而创建,然而这些限制使得实现容错的开销很低。与分布式共享内存系统需要付出高昂代价的检查点和回滚机制不同,RDD通过Lineage
来重建丢失的分区:一个RDD中包含了如何从其他RDD衍生所必需的相关信息,从而不需要检查点操作就可以重构丢失的数据分区。尽管RDD不是一个通用的共享内存抽象,但却具备了良好的描述能力、可伸缩性和可靠性,能够广泛适用于数据并行类应用。
RDD是只读的、分区记录的集合。RDD只能基于在稳定物理存储中的数据集和其他已有的RDD上执行确定性操作来创建。这些确定性操作称之为转换,如map、filter、groupBy、join(转换不是程开发人员在RDD上执行的操作)。RDD不需要物化。RDD含有如何从其他RDD衍生(即计算)出本RDD的相关信息(即Lineage),据此可以从物理存储的数据计算出相应的RDD分区。
转换 | map(f : T ) U) : RDD[T] ) RDD[U]filter(f : T ) Bool) : RDD[T] ) RDD[T]flatMap(f : T ) Seq[U]) : RDD[T] ) RDD[U]sample(fraction : Float) : RDD[T] ) RDD[T] (Deterministic sampling)groupByKey() : RDD[(K, V)] ) RDD[(K, Seq[V])]reduceByKey(f : (V; V) ) V) : RDD[(K, V)] ) RDD[(K, V)]union() : (RDD[T]; RDD[T]) ) RDD[T]join() : (RDD[(K, V)]; RDD[(K, W)]) ) RDD[(K, (V, W))]cogroup() : (RDD[(K, V)]; RDD[(K, W)]) ) RDD[(K, (Seq[V], Seq[W]))]crossProduct() : (RDD[T]; RDD[U]) ) RDD[(T, U)]mapValues(f : V ) W) : RDD[(K, V)] ) RDD[(K, W)] (Preserves partitioning)sort(c : Comparator[K]) : RDD[(K, V)] ) RDD[(K, V)]partitionBy(p : Partitioner[K]) : RDD[(K, V)] ) RDD[(K, V)] |
---|---|
动作 | count() : RDD[T] ) Longcollect() : RDD[T] ) Seq[T]reduce(f : (T; T) ) T) : RDD[T] ) Tlookup(k : K) : RDD[(K, V)] ) Seq[V] (On hash/range partitioned RDDs)save(path : String) : Outputs RDD to a storage system, e.g., HDFS |
一般来说,分布式数据集的容错性有两种方式:即数据检查点和记录数据的更新。我们面向的是大规模数据分析,数据检查点操作成本很高:需要通过数据中心的网络连接在机器之间复制庞大的数据集,而网络带宽往往比内存带宽低得多,同时还需要消耗更多的存储资源(在内存中复制数据可以减少需要缓存的数据量,而存储到磁盘则会拖慢应用程序)。所以,RDD选择记录更新的方式。但是,如果更新太多,那么记录更新成本也不低。因此,RDD只支持粗粒度转换,即在大量记录上执行的单个操作。将创建RDD的一系列转换记录下来(即Lineage),以便恢复丢失的分区。
虽然只支持粗粒度转换限制了编程模型,但我们发现RDD仍然可以很好地适用于很多应用,特别是支持数据并行的批量分析应用,包括数据挖掘、机器学习、图算法等,因为这些程序通常都会在很多记录上执行相同的操作。RDD不太适合那些异步更新共享状态的应用,例如并行web爬行器。因此,我们的目标是为大多数分析型应用提供有效的编程模型,而其他类型的应用交给专门的系统。
定义RDD之后,程序员就可以在动作中使用RDD了。动作是向应用程序返回值,或向存储系统导出数据的那些操作,例如,count(返回RDD中的元素个数),collect(返回元素本身),save(将RDD输出到存储系统)。在Spark中,只有在动作第一次使用RDD时,才会计算RDD(即延迟计算)。这样在构建RDD的时候,运行时通过管道的方式传输多个转换。
用户可以请求将RDD缓存,这样运行时将已经计算好的RDD分区存储起来,以加速后期的重用。缓存的RDD一般存储在内存中,但如果内存不够,可以写到磁盘上。
RDD还允许用户根据关键字(key)指定分区顺序,这是一个可选的功能。目前支持哈希分区和范围分区。例如,应用程序请求将两个RDD按照同样的哈希分区方式进行分区(将同一机器上具有相同关键字的记录放在一个分区),以加速它们之间的join操作。
RDD之间的依赖关系可以分为两类,即:(1)窄依赖(narrow dependencies):子RDD的每个分区依赖于常数个父分区(即与数据规模无关);(2)宽依赖(wide dependencies):子RDD的每个分区依赖于所有父RDD分区。例如,map产生窄依赖,而join则是宽依赖(除非父RDD被哈希分区)。
区分这两种依赖很有用。首先,窄依赖允许在一个集群节点上以流水线的方式(pipeline)计算所有父分区。例如,逐个元素地执行map、然后filter操作;而宽依赖则需要首先计算好所有父分区数据,然后在节点之间进行Shuffle,这与MapReduce类似。第二,窄依赖能够更有效地进行失效节点的恢复,即只需重新计算丢失RDD分区的父分区,而且不同节点之间可以并行计算;而对于一个宽依赖关系的Lineage图,单个节点失效可能导致这个RDD的所有祖先丢失部分分区,因而需要整体重新计算。
本部分我们通过一个具体示例来阐述RDD。假定有一个大型网站出错,操作员想要检查Hadoop文件系统(HDFS)中的日志文件(TB级大小)来找出原因。通过使用Spark,操作员只需将日志中的错误信息装载到一组节点的内存中,然后执行交互式查询。首先,需要在Spark解释器中输入如下Scala命令:
lines = spark.textFile("hdfs://...")
errors = lines.filter(_.startsWith("ERROR"))
errors.cache()
第1行从HDFS文件定义了一个RDD(即一个文本行集合),第2行获得一个过滤后的RDD,第3行请求将errors缓存起来。注意在Scala语法中filter的参数是一个闭包。
这时集群还没有开始执行任何任务。但是,用户已经可以在这个RDD上执行对应的动作,例如统计错误消息的数目:
errors.count()
用户还可以在RDD上执行更多的转换操作,并使用转换结果,如:
// Count errors mentioning MySQL:
errors.filter(_.contains("MySQL")).count()
// Return the time fields of errors mentioning
// HDFS as an array (assuming time is field
// number 3 in a tab-separated format):
errors.filter(_.contains("HDFS"))
.map(_.split('\t')(3))
.collect()
使用errors的第一个action运行以后,Spark会把errors的分区缓存在内存中,极大地加快了后续计算速度。注意,最初的RDD lines不会被缓存。因为错误信息可能只占原数据集的很小一部分(小到足以放入内存)。
最后,为了说明模型的容错性,下图给出了第3个查询的Lineage图。在lines RDD上执行filter操作,得到errors,然后再filter、map后得到新的RDD,在这个RDD上执行collect操作。Spark调度器以流水线的方式执行后两个转换,向拥有errors分区缓存的节点发送一组任务。此外,如果某个errors分区丢失,Spark只在相应的lines分区上执行filter操作来重建该errors分区。
调度器根据RDD的结构信息为每个动作确定有效的执行计划。调度器的接口是runJob函数,参数为RDD及其分区集,和一个RDD分区上的函数。该接口足以表示Spark中的所有动作(即count、collect、save等)。
总的来说,我们的调度器跟Dryad类似,但我们还考虑了哪些RDD分区是缓存在内存中的。调度器根据目标RDD的Lineage图创建一个由stage构成的无回路有向图(DAG)。每个stage内部尽可能多地包含一组具有窄依赖关系的转换,并将它们流水线并行化(pipeline)。stage的边界有两种情况:一是宽依赖上的Shuffle操作;二是已缓存分区,它可以缩短父RDD的计算过程。例如图6。父RDD完成计算后,可以在stage内启动一组任务计算丢失的分区。
Spark怎样划分任务阶段(stage)的例子。实线方框表示RDD,实心矩形表示分区(黑色表示该分区被缓存)。要在RDD G上执行一个动作,调度器根据宽依赖创建一组stage,并在每个stage内部将具有窄依赖的转换流水线化(pipeline)。 本例不用再执行stage 1,因为B已经存在于缓存中了,所以只需要运行2和3。
调度器根据数据存放的位置分配任务,以最小化通信开销。如果某个任务需要处理一个已缓存分区,则直接将任务分配给拥有这个分区的节点。否则,如果需要处理的分区位于多个可能的位置(例如,由HDFS的数据存放位置决定),则将任务分配给这一组节点。
对于宽依赖(例如需要Shuffle的依赖),目前的实现方式是,在拥有父分区的节点上将中间结果物化,简化容错处理,这跟MapReduce中物化map输出很像。
如果某个任务失效,只要stage中的父RDD分区可用,则只需在另一个节点上重新运行这个任务即可。如果某些stage不可用(例如,Shuffle时某个map输出丢失),则需要重新提交这个stage中的所有任务来计算丢失的分区。
多个task想要共享某个变量,Spark为此提供了两个共享变量,一种是Broadcast Variable(广播变量),另一种是Accumulator(累加变量)。Broadcast Variable会将使用到的变量,仅仅为每个节点拷贝一份,更大的用处是优化性能,减少网络传输以及内存消耗。Accumulator则可以让多个task共同操作一份变量,主要可以进行累加操作。
Spark提供的Broadcast Variable,是只读的。并且在每个节点上只会有一份副本,而不会为每个task都拷贝一份。因此其最大的作用,就是减少变量到各个节点的网络传输消耗,以及在各个节点上的内存消耗。此外,spark内部也使用了高效的广播算法来减少网络消耗。
可以通过调用SparkContext的broadcast()方法,来针对某个变量创建广播变量。然后在算子的函数内,使用到广播变量时,每个节点只会拷贝一份副本,每个节点可以使用广播变量的value()方法获取值。
val factor = 3
val factorBroadc ast = sc.broadcast(factor)
val arr = Array(1,2,3,4,5)
val rdd = sc.parallelize(arr)
val multipleRdd = rdd.map(num => num*factorBroadcast.Value())
multipleRdd.foreach(num => println(num))
Spark提供Accumulator,主要用于多个节点对一个变量进行共享性的操作。Accumulator只提供了累加的功能。但是却给我们提供了多个task对一个变量并行操作的功能。但是task只能对Accumulator进行累加操作,不能读取它的值。只有Driver程序可以读取Accumulator的值。
al sumAccumulator = sc.accumulator(0)
val arr = Array(1,2,3,4,5)
val rdd = sc.parallelize(arr)
rdd.foreach(num => sumAccumulator += num)
println(sumAccumulator.value)