Spark的job触发机制
- 1.Spark运行的基本概念
- 1.1 Driver
- 1.2 Cluster Manager
- 1.3 Executor
- 1.4 Worker
- 2. Spark Job触发机制
- 2.1 Job的逻辑执行
- 2.2 Job的物理执行
- 3. Job触发流程源代码解析
1.Spark运行的基本概念
本博客讲解的内容都是基于Spark的Standalone部署模式。在Standalone部署模式下, Spark比在YARN和Mesos更容易使用,因为不需要其他的东西。如果是基于Spark来处理数据,基本上一个Spark框架就可以了,无须使用YARN或者Mesos等。
上图展示了Spark的运行框架。如图所示,SparkContext在创建 DAGScheduler、TaskScheduler、SchedulerBackend的 同时还会向Master注册程序。如果注册没有问题的话, Master通过 Cluster Manager会给这个 程序分配资源 , 然后根据Action触发Job 。Job里面有一系列RDD, DAGScheduler从后往前推若发现是宽依赖的话, 就划分 不同的Stage 。Stage划分完成之后, Stage提交给底层的调度器TaskScheduler, TaskScheduler拿到这个Task的集合。因为一个Stage内部都是计算逻辑完全一样的任务, 只不过是计算的数据不同 。TaskScheduler就会根据数据的本地性, 将任务分配到Executor 上去执行 。Executor在任务运行结束或者出状况时 ,肯定要向Driver汇报。最后运行完毕之后, 关闭SparkConlext , 同时其创建的那些对象也要关闭掉。
1.1 Driver
Driver是应用程序运行时的核心, 它负责整个作业的调度, 同时会向Master申请资源成具体作业的工作过 程。所谓应用程序, 就是用户编写的Spark 代码打包后的jar包和相关 的依赖 ,其中包括Driver功能的代码和分布在集群中多个结点的Executor 代码 。Driver是驱动Executor去工作, Executor内部是线程池并发地去处理数据分片的。Driver部分的代码就 是Sparkconf和SparkContext部分。SparkContext在创建时包含很多内容, 包括DAGScheduler 、 TaskScheduler 、 Schedulerbackend和Spark - Env等(一个程序默认有一个DAGScheduler)。 所以一个Spark Application通常包含: Driver端的代码和分布在集群中多个结点上的Executor的代码。如textFile、flatMap和map等可以产生很多RDD的方法是具体的业务实现,也就是Executor中具体要执行的代码。所有的业务逻辑都是在具体的集群Worker上的Executor上执行。
1.2 Cluster Manager
Cluster Manager是集群中获取资源的Web服务。在Spark的最初阶段并没有YARN模式,也没有Standalone模式,资源管理服务是Meos,后来增加了yarn,后来为了推广普及产生了Standalone。最重要的特点是:Spaek的Application的运行不依赖于Cluster Manager。也就是说Spark的Application注册给Master,如果注册成功,Master提前给Application分配好资源,运行过程中根本不需要Cluster Manager的参与。Cluster Manager是可插拔的。这种资源分配方式是粗粒度的。
1.3 Executor
Executor是运行在Work结点上的为当前应用程序开启进程李的处理对象。这个对象负责具体的Task运行,是通过线程池并发运行和线程复用的方式。Spark在一个节点上为当前的程序开启一个JVM进程,JVM进程是线程池的方式,通过线程处理具体的 Task 任务。Executor 是进程里的对象。 一个 Worker 默认为当前的应用程序开启一个 Executor (可以配置多个)。 Executor 靠线程池中的线程运行 Task 时, 肯定会去磁盘或者内存中读写数据。 每个Application 都有自己独立的一批 Executor。
1.4 Worker
Worker 是集群中任何可以运行 Application 具体的 textFile 、 flatMap 、 map 、 filter 和 reduceByKey 等这些操作代码的结点。 Worker 上是不会运行程序代码的, Worker 是管理当前结点CPU 、 内存等资源的使用状态, 它会接收 Master 分配资源(即 Executor) 的指令, 会通过ExecutorRunner 启动一个新进程, 进程里面有 Executor。 为了便于理解, 可以把 Cluster Manager 看成是项目经理, Worker 是工长, 项目经理 (Cluster Manager) 会管理很多工长 (Worker) , 工长下面有很多工人 (Executor) 。 所以, Worker 管理当前结点的计算资源(主要是 CPU 和内存), 并接收 Master 的指令, 来分配具体的计算资源(在新的进程中分配)。 要分配一个新的进程做计算时, ExecutorRunner 相当于一个代理, 管理具体新分配的进程,也监控具体的 Executor 所在进程运行的状况。 其实就是在 ExecutorRunner 中远程创建出新的进程的。 Woker 是一个进程, 不会向 Master 汇报当前机器的 CPU 和内存等信息。 Worker 发送心跳汇报的信息只有 Workerid。应用程序注册成功时, Master 会给应用程序分配资源, 分配时都会记录资源。 如果中间 Executor 有丢失的情况, Worker 要向 Master 汇报, 然后动态地调整资源。
2. Spark Job触发机制
本博客所使用的Spark源码版本为2.12,全文参考《Spark内核机制解析和性能调优》,针对新版本的源码进行相应的改动。
2.1 Job的逻辑执行
Job的逻辑执行流程如下图所示。
上图中的逻辑执行共有4个步骤:
- 从数据源(数据源可以是本地 File 、内存数据结构、 HDFS 、 HBase 等)读取数据,创建最初的 RDD (createRDD());
- 对 RDD 进行一系列的 transformation ()操作, 每一个 transformation ()会产生一个或多个包含不同类型 T 的 RDD[T] 。 T 可以是 Scala 里面的基本类型或数据结构, 不限于 (K,V) 。但如果是(K,V),K不能是Array等复杂类型(因为难以在复杂类型上定义partition 函数);
- 对最后的 final RDD 进行 action() 操作, 每个 Partition 计算后产生结果 Result;
- 将Result 回送到 Driver 端, 进行最后的 f(list [ result ])计算。 例子中的 count()实际包含了 action() 和 sum() 两步计算。 RDD 可以被 Cache 到内存或者 Checkpoint 到磁盘上。 RDD 中的 Partition 个数不固定, 通常由用户设定。 RDD和RDD 之间 Partition 的依赖关系可以不是l对1’ 如图4-3所示, 既有1对1关系, 也有多对多的关系。
2.2 Job的物理执行
Spark Application里面可以产生1个或者多个Job,例如spark-shell默认启动的时候内部就没有Job,只是作为资源的分配程序,可以在spark-shell里面写代码产生若干个Job,普通程序中一般而言可以有不同的Action,每一个Action一般也会触发一个Job。
有了Job的逻辑执行图,如何生成物理执行图,也就是给定这样一个复杂数据依赖图,如何合理划分Stage, 并确定Task的类型和个数?
一个直观的方法是将前后关联的RDD组成一个Stage, 每个Stage生成 一个Task。这样虽然可以解决问题, 但显然 效率 不高。除了 效率问题,这个方法还有一个更严重的问题:大量中间数据需要存储。 对于 Task来说,其执行结果要么要存到磁盘,要么存到内存,或者两者皆有。 如果每个箭头都是Task的话,每个RDD里面的数据都需要存起来, 占用空间可想而知。 仔细观察一下逻辑执行图会发现:在每个RDD中,每个Partition是独立的,也就是说在RDD内部,每个Partition的数据依赖各自 不会相互干扰。 因此,一个大胆的想法是将整个流程图看成 一 个Stage, 为最后一个frnalRDD中的每个Partition分配一个Task。 即Pipeline 思想:数据用的时候再算,而且数据是流到要计算的位置的
Spark算法构造和物理执行时最基本的核心:最大化Pipeline! 基于Pipeline的思想,数据被使用的时候才开始计算,从数据流动的视角来说,是数据流动到计算的位置 。实质上从逻辑的角度来看,是算子在数据上流动!从算法构建的角度而言:肯定是算子作用于数据,所以是算子在数据上流动;方便算法的构建!
从Job物理执行的角度而言:是数据流动到计算的位置,方便系统最为高效的运行 。对于Pipeline而言,数据计算的位置就是每个Stage中最后的ROD。也就是说:每个Stage中除了最后一个RDD算子是真实的以外,前面的算子都是 的!由于计算的Lazy特性,计算是从后往前回溯,形成ComputingChain, 结果需要首先计算出具体一 个Stage内部左侧的 RDD中本次计算依赖的Partition。如图所示:
- 图中一共有3个Stage:Stage1、Stage2和Stage3。
- Stage3内部左侧ShuffledRDD的计算依于CoGroupedRDD的Partition,如CoGroupedRDD为3个Partition, 那就有3个井行Task, 从后往前回溯,ShuffledRDD也是3个Partition,3个Task。
- Stage1内部RDDa设置3个Partition, 与Stage3的ShuffledRDD进行GroupBy操作。
- Stage2内部左侧的RDDb、MappedRDD、UnionRDD,左侧的C的计算依赖于UnionRDD的partition,UnionRDD设置2个partition,2个Task并行计算,从后往前回溯,RDDb、MappedRDD也是2个task.
- Stage2 UnionRDD计算结果在于Stage3中的ShuffledRDD进行Join。
总结一下这个过程:整个ComputingChain根据数据依赖关系自后向前建立,遇到 ShuffleDependency后形成Stage。 在每个Stage中,每个RDD中的 compute()调用 parentRDD. iter() 来将parentRDD中的Record 一 个个 “ 拿" (fetch)过来。
例如,collect前面的RDD是 transformation级别的,不会立即执行。从后往前推,回溯
时如果是窄依赖则在内存中迭代,否则把中间结果写出到磁盘暂存给后面的计算使用。
依赖分为窄依赖和宽依赖。例如现实生活中,工作 依赖一 个对象,是窄依赖,依赖很多对象,是宽依赖。窄依赖除了一 对一外,还有range级别的依赖,依赖固定的 个数,随着数据的规模扩大而改变。
如果是宽依赖,DAGScheduler会划分成不同的 stage, stage内部是基于内存迭代的,也可以 基于磁盘迭代,stage内部 计算的逻辑是完全一样的,只是计算的 数据 不同而已。具体的任务就是计算 一 个数据 分片,一 个partition的大小是128Mb。一 个partition 不是完全精准的等于 一 个block的大小 ,一般最后 一条记录跨两个block。
3. Job触发流程源代码解析
对于Spark Job触发流程源代码的讲解,本节使用count()案例来进行分析。
RDD中的count()代码如下:
/**
* 返回RDD中元素的数量.
*/
def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum
从上面的代码可以看出, count() 方法触发 SparkContext 的 runJob 方法的调用。 SparkContext 的 runJob 方法代码如下。
/**
* 触发一个job处理RDD中的所有分区,并把处理结果以数组的形式返回
*
* @param 需要执行任务的RDD
* @param 每个分区执行的计算函数
* @return 执行结果数组
*/
def runJob[T, U: ClassTag](rdd: RDD[T], func: Iterator[T] => U): Array[U] = {
runJob(rdd, func, 0 until rdd.partitions.length)
}
该方法调用SparkContext中的同名的重载方法,如下所示:
def runJob[T, U: ClassTag](
rdd: RDD[T],
func: Iterator[T] => U,
partitions: Seq[Int]): Array[U] = {
//为了能将RDD算子正常发送到各个worker节点。那么就需要序列化的类必须是正常的(指该类中的对外部的引用也能找到)因此对一些没有用的资源进行删除、清理。在这个类的闭包范围内
val cleanedFunc = clean(func)
runJob(rdd, (ctx: TaskContext, it: Iterator[T]) => cleanedFunc(it), partitions)
}
相对于上一个方法,该方法中增加了clean函数,并且多了一个partitions参数,func函数的形式也不同。该方法依旧调用重名的重载方法:
def runJob[T, U: ClassTag](
rdd: RDD[T],
func: (TaskContext, Iterator[T]) => U,
partitions: Seq[Int]): Array[U] = {
//创建保存结果的数组
val results = new Array[U](partitions.size)
runJob[T, U](rdd, func, partitions, (index, res) => results(index) = res)
results
}
最后一次调用重载方法如下所示:
/**
触发一个Job处理分RDD的指定部分的partitions,荆也顷结果给指定的handler函数,这是Spark所有Action的主入口
*/
def runJob[T, U: ClassTag](
rdd: RDD[T],
func: (TaskContext, Iterator[T]) => U,
partitions: Seq[Int],
resultHandler: (Int, U) => Unit): Unit = {
if (stopped.get()) {
throw new IllegalStateException("SparkContext has been shutdown")
}
//记录了方法调用的方法栈
val callSite = getCallSite
//清楚闭包资源,以便于序列化
val cleanedFunc = clean(func)
logInfo("Starting job: " + callSite.shortForm)
if (conf.getBoolean("spark.logLineage", false)) {
logInfo("RDD's recursive dependencies:\n" + rdd.toDebugString)
}
//向高层调度器(DAGSheduler)提交Job, 从而获得Job执行结果
dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, resultHandler, localProperties.get)
//待所有阶段结束后,清除进度条(如果显示),则进度将不会与作业输出交织在一起。
progressBar.foreach(_.finishAll())
//执行check,防止任务失败,再次运行
rdd.doCheckpoint()
}
到这里就大体上完成了
参考
《Python内核机制解析和内存调优》
https://cloud.tencent.com/developer/article/1085709 https://www.jianshu.com/p/90148ed3a1f1