大数据学习笔记之Spark(六):Spark内核解析

文章目录

  • 小笔记
    • spark通信架构
    • spark脚本
    • spark Standalone启动流程
    • spark应用提交流程
    • spark shuffle过程
    • Spark内存管理与分配
  • 第1章Spark整体概述
    • 如何查看spark源码
    • 1.1整体概念
    • 1.2RDD抽象
    • 1.3计算抽象(重点看下,也算是任务运行吧)
    • 1.4集群模式
    • 1.5RPC网络通信抽象
    • 1.6启动Standalone集群
    • 1.7核心组件
    • 1.8核心组件交互流程(重点看,面试必问呦,6.2 、 第七章、8.2有更加详细的根据源码的解释,看一遍)
    • 1.9Block管理
    • 1.10整体应用
  • 第2章脚本解析
    • 2.1start-daemon.sh
    • 2.2spark-class
    • 2.3start-master.sh
    • 2.4start-slaves.sh
    • 2.5start-all.sh
    • 2.6spark-submit
  • 第3章Spark通信架构(重点看下)
    • 3.1通信组件概览
    • spark的通信架构--类图
    • 3.2Endpoint启动过程
    • 3.3Endpoint Send&Ask流程
    • 3.4Endpoint receive流程
    • 3.5Endpoint Inbox处理流程
    • 3.6Endpoint画像
  • 第4章Master节点启动
    • 4.1脚本概览
    • 4.2启动流程
    • 4.3OnStart监听事件
    • 4.4RpcMessage处理(receiveAndReply)
    • 4.5Master对RpcMessage/OneWayMessage处理逻辑
  • 第5章Work节点启动
    • 5.1脚本概览
    • 5.2启动流程
    • 5.3OnStart监听事件
    • 5.4RpcMessage处理(receiveAndReply)
    • 5.5OneWayMessage处理(receive)
  • 第6章Client启动流程
    • 6.1脚本概览
    • 6.2SparkSubmit启动流程
    • 6.3Client启动流程
    • 6.4Client的OnStart监听事件
    • 6.5RpcMessage处理(receiveAndReply)
    • 6.6OneWayMessage处理(receive)
  • 第7章Driver和DriverRunner
    • 7.1Master对Driver资源分配
    • 7.2Worker运行DriverRunner
    • 7.3DriverRunner创建并运行DriverWrapper
  • 第8章SparkContext解析
    • 8.1SparkContext解析
    • 8.2SparkContext创建过程
    • 8.3SparkContext简易结构与交互关系
    • 8.4Master对Application资源分配
    • 8.5Worker创建Executor
  • 第9章Job提交和Task的拆分
    • 9.1整体预览
    • 9.2Code转化为初始RDDs
    • 9.3RDD分解为待执行任务集合(TaskSet)
    • 9.4TaskSet封装为TaskSetManager并提交至Driver
    • 9.5Driver将TaskSetManager分解为TaskDescriptions并发布任务到Executor
  • 第10章Task执行和回执
    • 10.1Task的执行流程
    • 10.2Task的回馈流程
    • 10.3Task的迭代流程
  • 第11章Spark的数据存储
    • 11.1存储子系统概览
    • 11.2启动过程分析
    • 11.3通信层
    • 11.4存储层
      • 11.4.1Disk Store
      • 11.4.2Memory Store
    • 11.5数据写入过程分析
      • 11.5.1序列化与否
    • 11.6数据读取过程分析
      • 11.6.1本地读取
    • 11.7Partition如何转化为Block
    • 11.8partition和block的对应关系
  • 第12章Spark Shuffle过程(重点看)
    • 12.1MapReduce的Shuffle过程介绍
      • 12.1.1Spill过程
        • 12.1.1.1 Collect
        • 12.1.1.2 Sort
        • 12.1.1.3 Spill
      • 12.1.2Merge
      • 12.1.3Copy
      • 12.1.4Merge Sort
    • 12.2 Spark的Shuffle过程介绍 - HashShuffle
    • 12.3Spark的Shuffle过程介绍 - SortShuffle
    • 12.4TungstenShuffle过程介绍
    • 12.5MapReduce与Spark过程对比
  • 第13章Spark内存管理 重点看
    • 13.1堆内和堆外内存规划
      • 13.1.1堆内内存
      • 13.1.2堆外内存
      • 13.1.3内存管理接口
    • 13.2内存空间分配
      • 13.2.1静态内存管理
      • 13.2.2统一内存管理
    • 13.3存储内存管理
      • 13.3.1RDD 的持久化机制
      • 13.3.2RDD 缓存的过程
      • 13.3.3淘汰和落盘
    • 13.4执行内存管理
      • 13.4.1多任务间内存分配
      • 13.4.2Shuffle 的内存占用
  • 第14章部署模式解析
    • 14.1部署模式概述
    • 14.2standalone框架
      • 14.2.1Standalone模式下任务运行过程
      • 14.2.2总结
      • 14.3.1集群下任务运行过程
    • 14.4mesos集群模式
    • 14.5spark 三种部署模式的区别
    • 14.6异常场景分析
      • 14.6.1异常分析1: worker异常退出
        • 14.6.1.1 后果分析
        • 14.6.1.2 测试步骤
        • 14.6.1.3 异常退出的代码处理
        • 14.6.1.4 小结
      • 14.6.2异常分析2: executor异常退出
        • 14.6.2.1 测试步骤
        • 14.6.2.2 fetchAndRunExecutor
      • 14.6.3异常分析3: master 异常退出
    • 15.2原理
  • 分配逻辑(重点看下,后面的视频中进行的补充)


小笔记

spark通信架构

大数据学习笔记之Spark(六):Spark内核解析_第1张图片
1、Spark 采用Netty作为新的消息通讯库,采用了Actor模型进行底层通信架构的设计与实现,在Spark2. 0中,彻底取代了以AKKA作为底层通信框架的实现
2、RpcEnv, 是Spark通信架构的一一个入口点,类似于SparkContext, 是用于创建整个Spark通信端点与通信服务的上下文环境。实现上使用NettyRpcEnv来作为默认实现。
3、RpcEndPoint, 是类似于Actor的具体的消息顶点,通过继承RpcEndpoint来实现消息顶点的实例化,需要复写onStart、receive以及receiveAndRelpy方法。
3、Inbox, 一个RpcEndpoint带有- -个Inbox,用于存储外部发过来的数据。
4、Dispatcher,消息的转发器,- -个RpcEnv带有- -个Dispatcher的实例,主要功能是通过接受Tr anspor tServer传过来的消息,将消息存入Inbox里面,通过接受当前端点传过来的消息,将消息放到对应的0utbox中。
5、Outbox, - -个远程端点的代理具有一个0utbox, 用于存放当前节点发送给远程端点的数据。
6、TransportServer, 主要是用于接受远程Endpoint发送过来的消息,并把消息传送给Dispatcher
7、Tr anspor tClient,主要负责将相对应对的0utBox中额数据发送给远程的Tr anspor tServer。

spark脚本

大数据学习笔记之Spark(六):Spark内核解析_第2张图片
1、start-slave. sh用于启动slave节点,最终启动的类是org. apache. spark. dep1oy. worker. Worker类
2、start -master. sh用于启动master节点,最终启动的类是org. apache. spark. deploy. master. Master类
3、Spark-submi t和Spark -she11最终都会调用spark-c1ass脚本,通过spark-c1ass脚本启动相对应的入口类。

spark Standalone启动流程

大数据学习笔记之Spark(六):Spark内核解析_第3张图片
1、Master和Workero都继承 了RpcEndPoint类,成为了具体的消息发送与接收端点,整个应用架构是利用Actor模型实现的异步消息通信架构。
2、Master节点在启动的时候主要任务就是创建了通信架构中的RpcEnv,并注册了Master成为端点。
3、Worker节点在启动的时候主要任务也是创建了通信架构中的RpcEnv,并注册了Worker成为端点,并且获取了Master的端点代理,通过端点代理像
Master发送消息。
4、Worker在启动的时候执行0nstar t方法,向Master进行了注册。

spark应用提交流程

大数据学习笔记之Spark(六):Spark内核解析_第4张图片
[橙色:应用提交] -》[紫色: 启动Driver进程] --》[红色:注册Application] --》[蓝 色:启动Executor进程] -》[粉色:启动
Task执行] -》[绿 色:Tash运行完成]
1、Driver提交流程:用户通过Spark- -submi t将J ar包和相对应的参数提交给Spark框架,内部实现是通过Cli entEndpoint向Master发送了
RequestSubmi tDriver消息。Master获取消息之后通过worker进行LaunchDriver操作。
2、Driver的进程启动:主要通过Worker节点的DriverRunner来启动整个的Driver进程。
3、注册Application: Driver进程在启动之后,通过SparkContext的初始化操作,创建了对应的SchedulerBackend,实现了像Mas ter进行当前应用的注
册。
4、启动Executor进程:当Driver向Master进行注册之后,Mastex通过Scheduler方法来对当前的App进行Executor的分配,实现上是通过Worker的
ExecutorRunner来进行Executor的创建和运行。
5、启动Task运行:当Driver收到所有的Exector资源后,通过RDD的action操作,触发SparkContext. runJob方法,进而调用DagScheduler进行当前DAG
的运行。通过向Executor发送LaunchTask消息来启动Executor.上的任务运行。
6、Task运行完成:当Executor运行任务完成之后,会通知Driver当前任务的运行状态。然后迭代执行任务或者退出整个应用。

spark shuffle过程

MapReduce:
1、spil1阶段,数据直接写入到kvbuffer数据缓冲器,会写两种类型的属性,-种是kmeta,用于存放分区信息、索引信息,另- -种是(k,v)数据,
是真实的数据。会以一个起点反向来写,当遇到spill1进程启动的时候,写入点会重新进行选择。
Hash Shuffle:
1、未优化版本中,每一一个task任务都会根据reduce任务的个数创建对应数量的bucket, bucket其实就是写入缓冲区, 每一-个bucke都会存入一个文
件,这个文件叫blockfile.最大的劣势是产生的文件过多。
2、在优化版本中,主要通过consolidation这个参数进行优化, 实现了ShuffleFileGroup的概念,不同批次的task任务可以服用最终写入的文件,来整
体减少文件的数量。
Sort Shuffle:
1、Sort Shuffle整个过程的实现和MapReduce的shuffle过程很类似。
2、ByPass机制, Hashshuffle在reduce数量比较少的时候性能要比Sor tshuffle要高,所以如果你的reduce数量少于bypass定义的数值的时候,sort .
shuffle在task任务写出的时候会采用hash的方式,而不会采用&pply0n1yMap以及排序。

Spark内存管理与分配

大数据学习笔记之Spark(六):Spark内核解析_第5张图片
大数据学习笔记之Spark(六):Spark内核解析_第6张图片
1、内存分配模式上,主要分为静态分配以及统- -分配两种方式。 静态就是固定大小,统- -分配是存储区和Shuff1e区可以动态占用。
2、有几种内存配置模式:
1、other区, - -般占用20%的内存区域,主要是用于代码的运行以及相关数据结构的运行。
2、Execution区, 这个区域- -般占用20%的内存区域,主要通过spark. shuffle. memoryFraction参数指定。主要用于Shuff le过程的内存消耗。
3、Storage区, 这个区域主要用于RDD的缓存,主要通过spark. stor age. memoryFraction参数指定,-般会占用60%的区域。
3、Spark目前支持堆内内存以及堆外内存,堆外内存主要存储序列化后的二进制数据。|

第1章Spark整体概述

如何查看spark源码

直接进入spark官网http://spark.apache.org/downloads.html,用github试过了,老是网络超时。
大数据学习笔记之Spark(六):Spark内核解析_第7张图片
大数据学习笔记之Spark(六):Spark内核解析_第8张图片
这里应该是选其他的也可以,我选的是http的。
下载下来之后解压。
然后通过idea import,选择maven
之后进入idea中看到代码会有很多红线,引入jdk,然后plugin install scala,重启idea。之后就可以了。
大数据学习笔记之Spark(六):Spark内核解析_第9张图片
如果还是有报错,注意修改jdk的编译版本,java compile里面设置,最后重新编译一下。

1.1整体概念

Apache Spark是一个开源的通用集群计算系统,它提供了High-level编程API,支持Scala、Java和Python三种编程语言。Spark内核使用Scala语言编写,通过基于Scala的函数式编程特性,在不同的计算层面进行抽象,代码设计非常优秀。

1.2RDD抽象

RDD(Resilient Distributed Datasets),弹性分布式数据集,它是对分布式数据集的一种内存抽象,通过受限的共享内存方式来提供容错性,同时这种内存模型使得计算比传统的数据流模型要高效。RDD具有5个重要的特性,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第10张图片
上图展示了2个RDD进行JOIN操作,体现了RDD所具备的5个主要特性,如下所示:
1)一组分区
2)计算每一个数据分片的函数
3)RDD上的一组依赖
4)可选,对于键值对RDD,有一个Partitioner(通常是HashPartitioner)
5)可选,一组Preferred location信息(例如,HDFS文件的Block所在location信息)
有了上述特性,能够非常好地通过RDD来表达分布式数据集,并作为构建DAG图的基础:首先抽象一次分布式计算任务的逻辑表示,最终将任务在实际的物理计算环境中进行处理执行。

1.3计算抽象(重点看下,也算是任务运行吧)

大数据学习笔记之Spark(六):Spark内核解析_第11张图片
在描述Spark中的计算抽象,我们首先需要了解如下几个概念:
1)Application
用户编写的Spark程序,完成一个计算任务的处理。它是由一个Driver程序和一组运行于Spark集群上的Executor组成。
2)Job
用户程序中,每次调用Action时,逻辑上会生成一个Job,一个Job包含了多个Stage。即一个application对应于多个job
3)Stage
Stage包括两类:ShuffleMapStage和ResultStage,如果用户程序中调用了需要进行Shuffle计算的Operator,如groupByKey等,就会以Shuffle为边界分成ShuffleMapStage和ResultStage。stage的划分是根据宽依赖和窄依赖划分的 ,ShuffleMapStage是为底层shuffle操作提供数据源的stage,resultStage更像mapreduce里面的reduce操作,他是去手机shuffleMapStage里面所有数据,然后继续进行resultstage这个过程
4)TaskSet
基于Stage可以直接映射为TaskSet,一个TaskSet封装了一次需要运算的、具有相同处理逻辑的Task,这些Task可以并行计算,粗粒度的调度是以TaskSet为单位的。在stage里面可以生成taskset
5)Task
Task是在物理节点上运行的基本单位,Task包含两类:ShuffleMapTask和ResultTask,分别对应于Stage中ShuffleMapStage和ResultStage中的一个执行基本单元。
下面,我们看一下,上面这些基本概念之间的关系,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第12张图片
上图,为了简单,每个Job假设都很简单,并且只需要进行一次Shuffle处理,所以都对应2个Stage。实际应用中,一个Job可能包含若干个Stage,或者是一个相对复杂的Stage DAG。
上图的意思是job1 job2 的输出信息是job3的输入信息,根据stage的不同分成了不同的taskset

在Standalone模式下,默认使用的是FIFO这种简单的调度策略,在进行调度的过程中,大概流程如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第13张图片
可以看到是一个先进先出的调度,因为对于taskset来说,其实是有先后顺序的

从用户提交Spark程序,最终生成TaskSet,而在调度时,通过TaskSetManager来管理一个TaskSet(包含一组可在物理节点上执行的Task),这里面TaskSet必须要按照顺序执行才能保证计算结果的正确性,因为TaskSet之间是有序依赖的(上溯到ShuffleMapStage和ResultStage),只有一个TaskSet中的所有Task都运行完成后,才能调度下一个TaskSet中的Task去执行。
大数据学习笔记之Spark(六):Spark内核解析_第14张图片

1.4集群模式

Spark集群在设计的时候,并没有在资源管理的设计上对外封闭,而是充分考虑了未来对接一些更强大的资源管理系统,如YARN、Mesos等,所以Spark架构设计将资源管理单独抽象出一层,通过这种抽象能够构建一种适合企业当前技术栈的插件式资源管理模块,从而为不同的计算场景提供不同的资源分配与调度策略。Spark集群模式架构,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第15张图片
上图中,Spark集群Cluster Manager目前支持如下三种模式:
1)Standalone模式
Standalone模式是Spark内部默认实现的一种集群管理模式,这种模式是通过集群中的Master来统一管理资源,而与Master进行资源请求协商的是Driver内部的StandaloneSchedulerBackend(实际上是其内部的StandaloneAppClient真正与Master通信),后面会详细说明。
2)YARN模式
YARN模式下,可以将资源的管理统一交给YARN集群的ResourceManager去管理,选择这种模式,可以更大限度的适应企业内部已有的技术栈,如果企业内部已经在使用Hadoop技术构建大数据处理平台。
3)Mesos模式
随着Apache Mesos的不断成熟,一些企业已经在尝试使用Mesos构建数据中心的操作系统(DCOS),Spark构建在Mesos之上,能够支持细粒度、粗粒度的资源调度策略(Mesos的优势),也可以更好地适应企业内部已有技术栈。
那么,Spark中是怎么考虑满足这一重要的设计决策的呢?也就是说,如何能够保证Spark非常容易的让第三方资源管理系统轻松地接入进来。我们深入到类设计的层面看一下,如下图类图所示:
大数据学习笔记之Spark(六):Spark内核解析_第16张图片
可以看出,Task调度直接依赖SchedulerBackend,SchedulerBackend与实际资源管理模块交互实现资源请求。这里面,CoarseGrainedSchedulerBackend是Spark中与资源调度相关的最重要的抽象,它需要抽象出与TaskScheduler通信的逻辑,同时还要能够与各种不同的第三方资源管理系统无缝地交互。实际上,CoarseGrainedSchedulerBackend内部采用了一种ResourceOffer的方式来处理资源请求。

1.5RPC网络通信抽象

Spark RPC层是基于优秀的网络通信框架Netty设计开发的,但是Spark提供了一种很好地抽象方式,将底层的通信细节屏蔽起来,而且也能够基于此来设计满足扩展性,比如,如果有其他不基于Netty的网络通信框架的新的RPC接入需求,可以很好地扩展而不影响上层的设计。RPC层设计,如下图类图所示:
大数据学习笔记之Spark(六):Spark内核解析_第17张图片
任何两个Endpoint只能通过消息进行通信,可以实现一个RpcEndpoint和一个RpcEndpointRef:想要与RpcEndpoint通信,需要获取到该RpcEndpoint对应的RpcEndpointRef即可,而且管理RpcEndpoint和RpcEndpointRef创建及其通信的逻辑,统一在RpcEnv对象中管理。

1.6启动Standalone集群

Standalone模式下,Spark集群采用了简单的Master-Slave架构模式,Master统一管理所有的Worker,这种模式很常见,我们简单地看下Spark Standalone集群启动的基本流程,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第18张图片
可以看到,Spark集群采用的消息的模式进行通信,也就是EDA架构模式,借助于RPC层的优雅设计,任何两个Endpoint想要通信,发送消息并携带数据即可。上图的流程描述如下所示:
1)Master启动时首先创一个RpcEnv对象,负责管理所有通信逻辑
2)Master通过RpcEnv对象创建一个Endpoint,Master就是一个Endpoint,Worker可以与其进行通信
3)Worker启动时也是创一个RpcEnv对象
4)Worker通过RpcEnv对象创建一个Endpoint
5)Worker通过RpcEnv对,建立到Master的连接,获取到一个RpcEndpointRef对象,通过该对象可以与Master通信
6)Worker向Master注册,注册内容包括主机名、端口、CPU Core数量、内存数量
7)Master接收到Worker的注册,将注册信息维护在内存中的Table中,其中还包含了一个到Worker的RpcEndpointRef对象引用
8)Master回复Worker已经接收到注册,告知Worker已经注册成功
9)此时如果有用户提交Spark程序,Master需要协调启动Driver;而Worker端收到成功注册响应后,开始周期性向Master发送心跳

1.7核心组件

集群处理计算任务的运行时(用户提交了Spark程序),最核心的顶层组件就是Driver和Executor,它们内部管理很多重要的组件来协同完成计算任务,核心组件栈如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第19张图片
Driver和Executor都是运行时创建的组件,一旦用户程序运行结束,他们都会释放资源,等待下一个用户程序提交到集群而进行后续调度。上图,我们列出了大多数组件,其中SparkEnv是一个重量级组件,他们内部包含计算过程中需要的主要组件,而且,Driver和Executor共同需要的组件在SparkEnv中也包含了很多。这里,我们不做过多详述,后面交互流程等处会说明大部分组件负责的功能。

1.8核心组件交互流程(重点看,面试必问呦,6.2 、 第七章、8.2有更加详细的根据源码的解释,看一遍)

在Standalone模式下,Spark中各个组件之间交互还是比较复杂的,但是对于一个通用的分布式计算系统来说,这些都是非常重要而且比较基础的交互。首先,为了理解组件之间的主要交互流程,我们给出一些基本要点:
一个Application会启动一个Driver
一个Driver负责跟踪管理该Application运行过程中所有的资源状态和任务状态
一个Driver会管理一组Executor
一个Executor只执行属于一个Driver的Task
核心组件之间的主要交互流程,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第20张图片
【橙色:应用提交】–>【 紫色:启动Driver的进程】 -->【红色:注册application】–>【蓝色:启动Executor进程】 -->【粉色:启动Task执行】–>【绿色:Task进行完成】(这里说的颜色是图中的线条的颜色)

上图中,通过不同颜色或类型的线条,给出了如下6个核心的交互流程,我们会详细说明:
橙色:提交用户Spark程序
用户提交一个Spark程序,主要的流程如下所示:
1)用户spark-submit脚本提交一个Spark程序(左上角),会在一个新的jvm中有一个Driverclient,会创建一个ClientEndpoint对象,该对象负责与Master通信交互
2)ClientEndpoint向Master发送一个RequestSubmitDriver消息,表示提交用户程序,也就是spark submit后面提交的jar包
3)Master收到RequestSubmitDriver消息,向ClientEndpoint回复SubmitDriverResponse,表示用户程序已经完成注册
4)ClientEndpoint向Master发送RequestDriverStatus消息,请求Driver状态
5)如果当前用户程序对应的Driver已经启动,则ClientEndpoint直接退出,完成提交用户程序

紫色:启动Driver进程
当用户提交用户Spark程序后,需要启动Driver来处理用户程序的计算逻辑,完成计算任务,这时Master协调需要启动一个Driver,具体流程如下所示:
1)Maser内存中维护着用户提交计算的任务Application,每次内存结构变更都会触发调度,向Worker发送LaunchDriver请求
2)Worker收到LaunchDriver消息,会启动一个DriverRunner线程去执行LaunchDriver的任务
3)DriverRunner线程在Worker上启动一个新的JVM实例(run DriverWrapper,最后右边的Driver),该JVM实例内运行一个Driver进程,该Driver会创建SparkContext对象
注意:这个Driver是在某一个worker上,因为目前演示的流程是cluster模式,如果用的client模式,driver就是在submit这台机器上启动的
创建一个driver在driver里面运行我的程序

红色:注册Application
Dirver启动以后,它会创建SparkContext对象,初始化计算过程中必需的基本组件,并向Master注册Application,流程描述如下:
1)创建SparkEnv对象,创建并管理一些数基本组件
2)创建TaskScheduler,负责Task调度
3)创建StandaloneSchedulerBackend(运行程序或者协调资源,都是通过他来做),负责与ClusterManager进行资源协商
4)创建DriverEndpoint,其它组件可以与Driver进行通信(和masterEndpoint和ClientEndpoint的作用是一样的)
5)在StandaloneSchedulerBackend内部创建一个StandaloneAppClient,负责处理与Master的通信交互
6)StandaloneAppClient创建一个ClientEndpoint,实际负责与Master通信
7)ClientEndpoint向Master发送RegisterApplication消息,注册Application(主要是driver和master进行通信的),在driver中可以看到有两个rpc端点,
8)Master收到RegisterApplication请求后,回复ClientEndpoint一个RegisteredApplication消息,表示已经注册成功

蓝色:启动Executor进程
1)Master向Worker发送LaunchExecutor消息,请求启动Executor;同时Master会向Driver发送ExecutorAdded消息,表示Master已经新增了一个Executor(此时还未启动)
2)Worker收到LaunchExecutor消息,会启动一个ExecutorRunner线程去执行LaunchExecutor的任务
3)Worker向Master发送ExecutorStageChanged消息,通知Executor状态已发生变化
4)Master向Driver发送ExecutorUpdated消息,此时Executor已经启动

粉色:启动Task执行
1)StandaloneSchedulerBackend启动一个DriverEndpoint
2)DriverEndpoint启动后,会周期性地检查Driver维护的Executor的状态,如果有空闲的Executor便会调度任务执行
3)DriverEndpoint向TaskScheduler发送Resource Offer请求(能够把RDD转换啊这些,能够创建task)
4)如果有可用资源启动Task,则DriverEndpoint向Executor发送LaunchTask请求(同样是启动一个jvm,和上面的driverclient这个jvm同等重要)
5)Executor进程内部的CoarseGrainedExecutorBackend调用内部的Executor线程的launchTask方法启动Task
6)Executor线程内部维护一个线程池,创建一个TaskRunner线程并提交到线程池执行
绿色:Task运行完成
1)Executor进程内部的Executor线程通知CoarseGrainedExecutorBackend,Task运行完成
2)CoarseGrainedExecutorBackend向DriverEndpoint发送StatusUpdated消息,通知Driver运行的Task状态发生变更
3)StandaloneSchedulerBackend调用TaskScheduler的updateStatus方法更新Task状态
4)StandaloneSchedulerBackend继续调用TaskScheduler的resourceOffers方法,调度其他任务运行

有下一个任务过来,还是走这条路,然后如此往复,最后所有的任务完成了,然后driver结束,driver结束之后,会告诉master,当前的application结束了,然后告诉相应的woker,干掉executer,这个时候完全结束

通过查看submit的脚本,知道最后调用的是org.apache.spark.deploy.SparkSubmit
一起来看看这个类的源码把(更加详情,可以看下面的6.2sparksubmit启动流程

首先看一下这个main方法
大数据学习笔记之Spark(六):Spark内核解析_第21张图片
这个main方法中传入了一些参数,new 一个sparkSubmitArguments

1.9Block管理

Block管理,主要是为Spark提供的Broadcast机制提供服务支撑的。Spark中内置采用TorrentBroadcast实现,该Broadcast变量对应的数据(Task数据)或数据集(如RDD),默认会被切分成若干4M大小的Block,Task运行过程中读取到该Broadcast变量,会以4M为单位的Block为拉取数据的最小单位,最后将所有的Block合并成Broadcast变量对应的完整数据或数据集。将数据切分成4M大小的Block,Task从多个Executor拉取Block,可以非常好地均衡网络传输负载,提高整个计算集群的稳定性。
通常,用户程序在编写过程中,会对某个变量进行Broadcast,该变量称为Broadcast变量。在实际物理节点的Executor上执行Task时,需要读取Broadcast变量对应的数据集,那么此时会根据需要拉取DAG执行流上游已经生成的数据集。采用Broadcast机制,可以有效地降低数据在计算集群环境中传输的开销。具体地,如果一个用户对应的程序中的Broadcast变量,对应着一个数据集,它在计算过程中需要拉取对应的数据,如果在同一个物理节点上运行着多个Task,多个Task都需要该数据,有了Broadcast机制,只需要拉取一份存储在本地物理机磁盘即可,供多个Task计算共享。
另外,用户程序在进行调度过程中,会根据调度策略将Task计算逻辑数据(代码)移动到对应的Worker节点上,最优情况是对本地数据进行处理,那么代码(序列化格式)也需要在网络上传输,也是通过Broadcast机制进行传输,不过这种方式是首先将代码序列化到Driver所在Worker节点,后续如果Task在其他Worker中执行,需要读取对应代码的Broadcast变量,首先就是从Driver上拉取代码数据,接着其他晚一些被调度的Task可能直接从其他Worker上的Executor中拉取代码数据。
我们通过以Broadcast变量taskBinary为例,说明Block是如何管理的,如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第22张图片
上图中,Driver负责管理所有的Broadcast变量对应的数据所在的Executor,即一个Executor维护一个Block列表。在Executor中运行一个Task时,执行到对应的Broadcast变量taskBinary,如果本地没有对应的数据,则会向Driver请求获取Broadcast变量对应的数据,包括一个或多个Block所在的Executor列表,然后该Executor根据Driver返回的Executor列表,直接通过底层的BlockTransferService组件向对应Executor请求拉取Block。Executor拉取到的Block会缓存到本地,同时向Driver报告该Executor上存在的Block信息,以供其他Executor执行Task时获取Broadcast变量对应的数据。

1.10整体应用

用户通过spark-submit提交或者运行spark-shell REPL,集群创建Driver,Driver加载Application,最后Application根据用户代码转化为RDD,RDD分解为Tasks,Executor执行Task等系列知识,整体交互蓝图如下:
大数据学习笔记之Spark(六):Spark内核解析_第23张图片
1)Client运行时向Master发送启动驱动申请(发送RequestSubmitDriver指令)
2)Master调度可用Worker资源进行驱动安装(发送LaunchDriver指令)
3)Worker运行DriverRunner进行驱动加载,并向Master发送应用注册请求(发送RegisterApplication指令)
4)Master调度可用Worker资源进行应用的Executor安装(发送LaunchExecutor指令)
5)Executor安装完毕后向Driver注册驱动可用Executor资源(发送RegisterExecutor指令)
6)最后是运行用户代码时,通过DAGScheduler,TaskScheduler封装为可以执行的TaskSetManager对象
7)TaskSetManager对象与Driver中的Executor资源进行匹配,在队形的Executor中发布任务(发送LaunchTask指令)
8)TaskRunner执行完毕后,调用DriverRunner提交给DAGScheduler,循环7.直到任务完成

第2章脚本解析

在看源码之前,我们一般会看相关脚本了解其初始化信息以及Bootstrap类,Spark也不例外,而Spark中相关的脚本如下:
%SPARK_HOME%/sbin/start-master.sh
%SPARK_HOME%/sbin/start-slaves.sh
%SPARK_HOME%/sbin/start-all.sh
%SPARK_HOME%/bin/spark-submit
启动脚本中对于公共处理部分进行抽取为独立的脚本,如下:

spark-config.sh 初始化环境变量 SPARK_CONF_DIR, PYTHONPATH
bin/load-spark-env.sh 初始化环境变量SPARK_SCALA_VERSION,
调用%SPARK_HOME%/conf/spark-env.sh加载用户自定义环境变量 调用%SPARK_HOME%/conf/spark-env.sh加载用户自定义环境变量
conf/spark-env.sh 用户自定义配置

一般是在spark的bin 和 sbin 目录下

2.1start-daemon.sh

主要完成进程相关基本信息初始化,然后调用bin/spark-class进行守护进程启动,该脚本是创建端点的通用脚本,三端各自脚本都会调用spark-daemon.sh脚本启动各自进程
大数据学习笔记之Spark(六):Spark内核解析_第24张图片
1)初始化 SPRK_HOME,SPARK_CONF_DIR,SPARK_IDENT_STRING,SPARK_LOG_DIR环境变量(如果不存在)
2)初始化日志并测试日志文件夹读写权限,初始化PID目录并校验PID信息
3)调用/bin/spark-class脚本,/bin/spark-class见下

2.2spark-class

Master调用举例:
bin/spark-class --class org.apache.spark.deploy.master.Master --host $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT O R I G I N A L A R G S 1 ) 初 始 化 R U N N E R ( j a v a ) , S P A R K J A R S D I R ( 2 ) 调 用 ( " ORIGINAL_ARGS 1)初始化 RUNNER(java),SPARK_JARS_DIR(%SPARK_HOME%/jars),LAUNCH_CLASSPATH信息 2)调用( " ORIGINALARGS1)RUNNERjava,SPARKJARSDIR2)"RUNNER" -Xmx128m -cp “ L A U N C H C L A S S P A T H " o r g . a p a c h e . s p a r k . l a u n c h e r . M a i n " LAUNCH_CLASSPATH" org.apache.spark.launcher.Main " LAUNCHCLASSPATH"org.apache.spark.launcher.Main"@”)获取最终执行的shell语句
3)执行最终的shell语句(比如:/opt/jdk1.7.0_79/bin/java -cp /opt/spark-2.1.0/conf/:/opt/spark-2.1.0/jars/*:/opt/hadoop-2.6.4/etc/hadoop/ -Xmx1g -XX:MaxPermSize=256m org.apache.spark.deploy.master.Master --host zqh --port 7077 --webui-port 8080),如果是Client,那么可能为r,或者python脚本

2.3start-master.sh

启动Master的脚本,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第25张图片
1)用户执行start-master.sh脚本,初始化环境变量SPARK_HOME (如果PATH不存在SPARK_HOME,初始化脚本的上级目录为SPARK_HOME),调用spark-config.sh,调用load-spark-env.sh
2)如果环境变量SPARK_MASTER_HOST, SPARK_MASTER_PORT,SPARK_MASTER_WEBUI_PORT不存在,进行初始化7077,hostname -f,8080
3)调用spark-daemon.sh脚本启动master进程(spark-daemon.sh start org.apache.spark.deploy.master.Master 1 --host $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT $ORIGINAL_ARGS)

2.4start-slaves.sh

启动Worker的脚本,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第26张图片
1)用户执行start-slaves.sh脚本,初始化环境变量SPARK_HOME,调用spark-config.sh,调用load-spark-env.sh,初始化Master host/port信息,
2)调用slaves.sh脚本,读取conf/slaves文件并遍历,通过ssh连接到对应slave节点,启动 S P A R K H O M E / s b i n / s t a r t − s l a v e . s h s p a r k : / / {SPARK_HOME}/sbin/start-slave.sh spark:// SPARKHOME/sbin/startslave.shspark://SPARK_MASTER_HOST: S P A R K M A S T E R P O R T 3 ) s t a r t − s l a v e . s h 在 各 个 节 点 中 , 初 始 化 环 境 变 量 S P A R K H O M E , 调 用 s p a r k − c o n f i g . s h , 调 用 l o a d − s p a r k − e n v . s h , 根 SPARK_MASTER_PORT 3)start-slave.sh在各个节点中,初始化环境变量SPARK_HOME,调用spark-config.sh,调用load-spark-env.sh,根 SPARKMASTERPORT3)startslave.shSPARKHOMEsparkconfig.shloadsparkenv.shSPARK_WORKER_INSTANCES计算WEBUI_PORT端口(worker端口号依次递增 )并启动Worker进程(${SPARK_HOME}/sbin /spark-daemon.sh start org.apache.spark.deploy.worker.Worker W O R K E R N U M − − w e b u i − p o r t " WORKER_NUM --webui-port " WORKERNUMwebuiport"WEBUI_PORT" $PORT_FLAG $PORT_NUM M A S T E R " MASTER " MASTER"@")

2.5start-all.sh

属于快捷脚本,内部调用start-master.sh与start-slaves.sh脚本,并无额外工作

启动所有的spark程序
在哪个上面执行这个master,就会在哪个里面设为master
如果在slave里面设置了node,会逐步的启动这个worker
加载了配置文件
直接调用了start-master脚本

2.6spark-submit

任务提交的基本脚本,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第27张图片
1)直接调用spark-class脚本进行进程创建(./spark-submit --class org.apache.spark.examples.SparkPi --master spark://master01:7077 …/examples/jars/spark-examples_2.11-2.1.0.jar 10)
2)如果是java/scala任务,那么最终调用SparkSubmit.scala进行任务处理(/opt/jdk1.7.0_79/bin/java -cp /opt/spark-2.1.0/conf/:/opt/spark-2.1.0/jars/*:/opt/hadoop-2.6.4/etc/hadoop/ -Xmx1g -XX:MaxPermSize=256m org.apache.spark.deploy.SparkSubmit --master spark://zqh:7077 --class org.apache.spark.examples.SparkPi …/examples/jars/spark-examples_2.11-2.1.0.jar 10)

-cp是把后面的jar包都给引用了,执行了org.apache.spark.deploy.SparkSubmit这个类
所以这个提交的脚本最终还是走的SparkSubmit

第3章Spark通信架构(重点看下)

Spark作为分布式计算框架,多个节点的设计与相互通信模式是其重要的组成部分。
Spark一开始使用 Akka 作为内部通信部件。在Spark 1.3年代,为了解决大块数据(如Shuffle)的传输问题,Spark引入了Netty通信框架。到了 Spark 1.6, Spark可以配置使用 Akka 或者 Netty 了,这意味着 Netty 可以完全替代 Akka了。再到 Spark 2, Spark 已经完全抛弃 Akka了,全部使用Netty了。
为什么呢?官方的解释是:
1)很多Spark用户也使用Akka,但是由于Akka不同版本之间无法互相通信,这就要求用户必须使用跟Spark完全一样的Akka版本,导致用户无法升级Akka。
2)Spark的Akka配置是针对Spark自身来调优的,可能跟用户自己代码中的Akka配置冲突。
3)Spark用的Akka特性很少,这部分特性很容易自己实现。同时,这部分代码量相比Akka来说少很多,debug比较容易。如果遇到什么bug,也可以自己马上fix,不需要等Akka上游发布新版本。而且,Spark升级Akka本身又因为第一点会强制要求用户升级他们使用的Akka,对于某些用户来说是不现实的。

大数据学习笔记之Spark(六):Spark内核解析_第28张图片
如上图,spark的通信框架和akka相比,多了一个outBox,不过总体是差不多的

3.1通信组件概览

对源码分析,对于设计思路理解如下:
大数据学习笔记之Spark(六):Spark内核解析_第29张图片
注意点:
一个RpcEndpoint有一个inbox,但是假如当前的RpcEndpoint要发给三个RpcEndpoint,那会对已经有三个outbox。
RpcEndpoint和Dispatcher和TransportServer是成对出现的,也就是接收到消息之后,TransportServer给了Dispatcher,给完之后Dispatcher把消息放到inbox里面,放完之后RpcEndpoint会异步消费这个消息,读取这个消息。
如果RpcEndpoint要发数据,直接调用Dispatcher,Dispatcher把消息给了outBox
TransportClient和outBox也是一一对应的关系,也就是Dispatcher把消息给了outBox之后,TransportClient就直接把消息往外发送 。
然后就是另一个TransportServer接收消息,然后重复上面的步骤了

1)RpcEndpoint:RPC端点 ,Spark针对于每个节点(Client/Master/Worker)都称之一个Rpc端点 ,且都实现RpcEndpoint接口,内部根据不同端点的需求,设计不同的消息和不同的业务处理,如果需要发送(询问)则调用Dispatcher
2)RpcEnv:RPC上下文环境,每个Rpc端点运行时依赖的上下文环境称之为RpcEnv
3)Dispatcher:消息分发器,针对于RPC端点需要发送消息或者从远程RPC接收到的消息,分发至对应的指令收件箱/发件箱。如果指令接收方是自己存入收件箱,如果指令接收方为非自身端点,则放入发件箱
4)Inbox:指令消息收件箱,一个本地端点对应一个收件箱,Dispatcher在每次向Inbox存入消息时,都将对应EndpointData加入内部待Receiver Queue中,另外Dispatcher创建时会启动一个单独线程进行轮询Receiver Queue,进行收件箱消息消费
5)OutBox:指令消息发件箱,一个远程端点对应一个发件箱,当消息放入Outbox后,紧接着将消息通过TransportClient发送出去。消息放入发件箱以及发送过程是在同一个线程中进行,这样做的主要原因是远程消息分为RpcOutboxMessage, OneWayOutboxMessage两种消息,而针对于需要应答的消息直接发送且需要得到结果进行处理
6)TransportClient:Netty通信客户端,根据OutBox消息的receiver信息,请求对应远程TransportServer
7)TransportServer:Netty通信服务端,一个RPC端点一个TransportServer,接受远程消息后调用Dispatcher分发消息至对应收发件箱
注意:
TransportClient与TransportServer通信虚线表示两个RpcEnv之间的通信,图示没有单独表达式
一个Outbox一个TransportClient,图示没有单独表达式
一个RpcEnv中存在两个RpcEndpoint,一个代表本身启动的RPC端点,另外一个为 RpcEndpointVerifier

大数据学习笔记之Spark(六):Spark内核解析_第30张图片
如果有两个master、三个worker,master1和master2只有一个inbox,因为master里面只有一个端点,master要和三个worker通信,所以有三个outbox

spark的通信架构–类图

大数据学习笔记之Spark(六):Spark内核解析_第31张图片
首先看红色的RpcEnv上下文环境,然后绿色的PrcEndPoint,RpcEndpointRef之前akka框架说过,如果向另外一个actor通信,只要获取到actor的ref就可以了,然后直接向ref通信就行,而不用拿到RpcEndpoint的实例,然后看RpcEndpoint下面的ThreadSafeRpcEndpoint,对RpcEndpoint封装了一下,让他变成线程安全的,然后master和worker再去继承它,因为继承了他,所以master和worker其实也是RpcEndpoint。然后看RpcEnv的上面,有一个NettyRpcEnvFactory,这是最终创建RpcEnv的工厂类,而这个RpcEnv其实是一个抽象类,真正的实现是RpcEnv右边的NettyRpcEnv,在实现里面包装了TransportClient和TransportServer、Dispatcher。TransportContext下面的灰色区域,是通信的时候编码解码这些过程

去查看源码
大数据学习笔记之Spark(六):Spark内核解析_第32张图片
RpcEnv是一个抽象类,里面有create方法,是用来创建Rpc应用的 ,包括还有setupEndPoint方法,这个方法就是为了注册端点

ctrl+h后
大数据学习笔记之Spark(六):Spark内核解析_第33张图片
发现这个类的继承类nettyRpcEnv,在这里我们看到了消息分发器
大数据学习笔记之Spark(六):Spark内核解析_第34张图片
上面左下方的框中能够看到很多信息,包括clientfactory,outbox,createServer,是将transportServer放了进来,这里注意下send方法其实是dispatcher的send,这就和上面的图吻合起来了。
大数据学习笔记之Spark(六):Spark内核解析_第35张图片
在Dispatcher类中,endpoint被封装在了EndpointData中
大数据学习笔记之Spark(六):Spark内核解析_第36张图片
所有的post方法最后都是调用的postmessage
大数据学习笔记之Spark(六):Spark内核解析_第37张图片
最后调用了inbox.post方法
在inbox里面处理数据是怎么处理的呢?
大数据学习笔记之Spark(六):Spark内核解析_第38张图片
在RpcEndPoint里面有几个方法

大数据学习笔记之Spark(六):Spark内核解析_第39张图片
recieve是只接收消息
receiveAndReply是接收消息后进行处理
onStart是在端点启动的时候执行,就是在端点启动的时候,自动往inbox里面发送一个onstart消息,当inbox在处理消息的时候,就处理了onstart方法

主要需要注意的是,每一个rpcpoint都要实现哪些方法(recieve、receiveAndReply、onStart),rpcEnv,是谁构造了他呢?NettyRpcEnvFactory,发现最后创建的是NettyRpcEnv

3.2Endpoint启动过程

启动的流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第40张图片
Endpoint启动后,默认会向Inbox中添加OnStart消息,不同的端点(Master/Worker/Client)消费OnStart指令时,进行相关端点的启动额外处理
Endpoint启动时,会默认启动TransportServer,且启动结束后会进行一次同步测试rpc可用性(askSync-BoundPortsRequest)
Dispatcher作为一个分发器,内部存放了Inbox,Outbox的等相关句柄和存放了相关处理状态数据,结构大致如下
大数据学习笔记之Spark(六):Spark内核解析_第41张图片

3.3Endpoint Send&Ask流程

Endpoint的消息发送与请求流程,如下:
大数据学习笔记之Spark(六):Spark内核解析_第42张图片
Endpoint根据业务需要存入两个维度的消息组合:send/ask某个消息,receiver是自身与非自身
1)OneWayMessage: send + 自身, 直接存入收件箱
2)OneWayOutboxMessage:send + 非自身,存入发件箱并直接发送
3)RpcMessage: ask + 自身, 直接存入收件箱,另外还需要存入LocalNettyRpcCallContext,需要回调后再返回
4)RpcOutboxMessage: ask + 非自身,存入发件箱并直接发送,,需要回调后再返回

3.4Endpoint receive流程

Endpoint的消息的接收,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第43张图片
上图 ServerBootstrap为Netty启动服务,SocketChanel为Netty数据通道
上述包含TransportSever启动与消息接受两个流程

3.5Endpoint Inbox处理流程

Spark在Endpoint的设计上核心设计即为Inbox与Outbox,其中Inbox核心要点为:
1)内部的处理流程拆分为多个消息指令(InboxMessage)存放入Inbox
2)当Dispatcher启动最后,会启动一个名为【dispatcher-event-loop】的线程扫描Inbox待处理InboxMessage,并调用Endpoint根据InboxMessage类型做相应处理
3)当Dispatcher启动最后,默认会向Inbox存入OnStart类型的InboxMessage,Endpoint在根据OnStart指令做相关的额外启动工作,三端启动后所有的工作都是对OnStart指令处理衍生出来的,因此可以说OnStart指令是相互通信的源头
大数据学习笔记之Spark(六):Spark内核解析_第44张图片
消息指令类型大致如下三类
1)OnStart/OnStop
2)RpcMessage/OneWayMessage
3)RemoteProcessDisconnected/RemoteProcessConnected/RemoteProcessConnectionError

3.6Endpoint画像

大数据学习笔记之Spark(六):Spark内核解析_第45张图片

第4章Master节点启动

Master作为Endpoint的具体实例,下面我们介绍一下Master启动以及OnStart指令后的相关工作

4.1脚本概览

下面是一个举例:

/opt/jdk1.7.0_79/bin/java
-cp /opt/spark-2.1.0/conf/:/opt/spark-2.1.0/jars/*:/opt/hadoop-2.6.4/etc/hadoop/
-Xmx1g
-XX:MaxPermSize=256m
org.apache.spark.deploy.master.Master
--host zqh
--port 7077

所以最终走的是org.apache.spark.deploy.master.Master这个类,这个类的内部实现和下面的这个图是紧密相关的。
大数据学习笔记之Spark(六):Spark内核解析_第46张图片
大数据学习笔记之Spark(六):Spark内核解析_第47张图片
大数据学习笔记之Spark(六):Spark内核解析_第48张图片
大数据学习笔记之Spark(六):Spark内核解析_第49张图片
onstart和onstop方法通过字面就能够理解了,就是启动和停止
大数据学习笔记之Spark(六):Spark内核解析_第50张图片
核心方法receive
receiveAndReply

看完了master,现在看worker
大数据学习笔记之Spark(六):Spark内核解析_第51张图片
可以看到这里也有rpcEnv等,多了一个masterAddress,是对master的一个引用
同样有onstart receive receiveAndReply等方法
RegisterWorker是一个消息
注册worker成功之后会有心跳,具体详情看代码。

4.2启动流程

Master的启动流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第52张图片
1)SparkConf:加载key以spark.开头的系统属性(Utils.getSystemProperties)
2)MasterArguments:
a)解析Master启动的参数(–ip -i --host -h --port -p --webui-port --properties-file)
b)将–properties-file(没有配置默认为conf/spark-defaults.conf)中spark.开头的配置存入SparkConf
3)NettyRpcEnv中的内部处理遵循RpcEndpoint统一处理,这里不再赘述
4)BoundPortsResponse返回rpcEndpointPort,webUIPort,restPort真实端口
5)最终守护进程会一直存在等待结束信awaitTermination

4.3OnStart监听事件

Master的启动完成后异步执行工作如下:
大数据学习笔记之Spark(六):Spark内核解析_第53张图片
1)【dispatcher-event-loop】线程扫描到OnStart指令后会启动相关MasterWebUI(默认端口8080),根据配置选择安装ResetServer(默认端口6066)
2)另外新起【master-forward-message-thread】线程定期进行worker心跳是否超时
3)如果Worker心跳检测超时,那么对Worker下的发布的所有任务所属Driver进行ExecutorUpdated发送,同时自己在重新LaunchDriver

4.4RpcMessage处理(receiveAndReply)

消息实例 发起方 接收方 说明
RequestSubmitDriver Client Master 提交驱动程序
RequestKillDriver Client Master
RequestDriverStatus Client Master
RequestMasterState MasterWebUI Master
BoundPortsRequest Master Master
RequestExecutors StandaloneAppClient Master
KillExecutors StandaloneAppClient Master

五、OneWayMessage处理(receive)

消息实例 发起方 接收方 说明
ElectedLeader Master Master
CompleteRecovery Master Master
RevokedLeadership Master Master
RegisterWorker Worker Master
RegisterApplication StandaloneAppClient Master
UnregisterApplication StandaloneAppClient Master
ExecutorStateChanged Worker/ExecutorRunner Master
DriverStateChanged DriverRunner/Master Master
Heartbeat Worker Master
MasterChangeAcknowledged StandaloneAppClient Master
WorkerSchedulerStateResponse Worker Master
WorkerLatestState Worker Master
CheckForWorkerTimeOut Master Master

4.5Master对RpcMessage/OneWayMessage处理逻辑

这部分对整体Master理解作用不是很大且理解比较抽象,可以先读后续内容,回头再考虑看这部分内容,或者不读
大数据学习笔记之Spark(六):Spark内核解析_第54张图片

第5章Work节点启动

Worker作为Endpoint的具体实例,下面我们介绍一下Worker启动以及OnStart指令后的额外工作

5.1脚本概览

下面是一个举例:

/opt/jdk1.7.0_79/bin/java
-cp /opt/spark-2.1.0/conf/:/opt/spark-2.1.0/jars/*:/opt/hadoop-2.6.4/etc/hadoop/
-Xmx1g
-XX:MaxPermSize=256m
org.apache.spark.deploy.worker.Worker
--webui-port 8081
spark://master01:7077

5.2启动流程

Worker的启动流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第55张图片
1)SparkConf:加载key以spark.开头的系统属性(Utils.getSystemProperties)
2)WorkerArguments:
a)解析Master启动的参数(–ip -i --host -h --port -p --cores -c --memory -m --work-dir --webui-port --properties-file)
b)将–properties-file(没有配置默认为conf/spark-defaults.conf)中spark.开头的配置存入SparkConf
c)在没有配置情况下,cores默认为服务器CPU核数
d)在没有配置情况下,memory默认为服务器内存减1G,如果低于1G取1G
e)webUiPort默认为8081
3)NettyRpcEnv中的内部处理遵循RpcEndpoint统一处理,这里不再赘述
4)最终守护进程会一直存在等待结束信awaitTermination

5.3OnStart监听事件

Worker的启动完成后异步执行工作如下
大数据学习笔记之Spark(六):Spark内核解析_第56张图片
1)【dispatcher-event-loop】线程扫描到OnStart指令后会启动相关WorkerWebUI(默认端口8081)
2)Worker向Master发起一次RegisterWorker指令
3)另起【master-forward-message-thread】线程定期执行ReregisterWithMaster任务,如果注册成功(RegisteredWorker)则跳过,否则再次向Master发起RegisterWorker指令,直到超过最大次数报错(默认16次)
4)Master如果可以注册,则维护对应的WorkerInfo对象并持久化,完成后向Worker发起一条RegisteredWorker指令,如果Master为standby状态,则向Worker发起一条MasterInStandby指令
5)Worker接受RegisteredWorker后,提交【master-forward-message-thread】线程定期执行SendHeartbeat任务,,完成后向Worker发起一条WorkerLatestState指令
6)Worker发心跳检测,会触发更新Master对应WorkerInfo对象,如果Master检测到异常,则发起ReconnectWorker指令至Worker,Worker则再次执行ReregisterWithMaster工作

5.4RpcMessage处理(receiveAndReply)

消息实例 发起方 接收方 说明
RequestWorkerState WorkerWebUI Worker 返回WorkerStateResponse

5.5OneWayMessage处理(receive)

消息实例 发起方 接收方 说明
SendHeartbeat Worker Worker
WorkDirCleanup Worker Worker
ReregisterWithMaster Worker Worker
MasterChanged Master Worker
ReconnectWorker Master Worker
LaunchExecutor Master Worker
ApplicationFinished Master Worker
KillExecutor Master Worker
LaunchDriver Master Worker
KillDriver Master Worker
DriverStateChanged DriverRunner Worker
ExecutorStateChanged ExecutorRunner ExecutorStateChanged ExecutorRunner
/Worker Worker/Master /Worker

第6章Client启动流程

Client作为Endpoint的具体实例,下面我们介绍一下Client启动以及OnStart指令后的额外工作

6.1脚本概览

下面是一个举例:

/opt/jdk1.7.0_79/bin/java
-cp /opt/spark-2.1.0/conf/:/opt/spark-2.1.0/jars/*:/opt/hadoop-2.6.4/etc/hadoop/
-Xmx1g
-XX:MaxPermSize=256m
org.apache.spark.deploy.SparkSubmit
--master spark://zqh:7077
--class org.apache.spark.examples.SparkPi
../examples/jars/spark-examples_2.11-2.1.0.jar 10

6.2SparkSubmit启动流程

SparkSubmit的启动流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第57张图片
new了一个sparkSubmitArguments,传了参数,包括解析argstrings,合并系统级默认配置等等,然后执行完了之后,是spark sunmit(),这个submit中做了那些事情呢?获得childMainClass childArgs,加载了当前的类加载器,反射执行childMainClass

1)SparkSubmitArguments:
a)解析Client启动的参数
i.–name --master --class --deploy-mode
ii.–num-executors --executor-cores --total-executor-cores --executor-memory
iii.–driver-memory --driver-cores --driver-class-path --driver-java-options --driver-library-path
iv.–properties-file
v.–kill --status --supervise --queue
vi.–files --py-files
vii.–archives --jars --packages --exclude-packages --repositories
viii.–conf(解析存入Map : sparkProperties中)
ix.–proxy-user --principal --keytab --help --verbose --version --usage-error
b)合并–properties-file(没有配置默认为conf/spark-defaults.conf)文件配置项(不在–conf中的配置 )至sparkProperties
c)删除sparkProperties中不以spark.开头的配置项目
d)启动参数为空的配置项从sparkProperties中合并
e)根据action(SUBMIT,KILL,REQUEST_STATUS)校验各自必须参数是否有值
2)Case Submit:
a)获取childMainClass
i.[–deploy-mode] = clent(默认):用户任务启动类mainClass(–class)
ii.[–deploy-mode] = cluster & [–master] = spark:* & useRest:org.apache.spark.deploy.rest.RestSubmissionClient
iii.[–deploy-mode] = cluster & [–master] = spark:* & !useRest : org.apache.spark.deploy.Client
iv.[–deploy-mode] = cluster & [–master] = yarn: org.apache.spark.deploy.yarn.Client
v.[–deploy-mode] = cluster & [–master] = mesos:*: org.apache.spark.deploy.rest.RestSubmissionClient
b)获取childArgs(子运行时对应命令行组装参数)

i.[–deploy-mode] = cluster & [–master] = spark:* & useRest: 包含primaryResource与mainClass
ii.[–deploy-mode] = cluster & [–master] = spark:* & !useRest : 包含–supervise --memory --cores launch 【childArgs】, primaryResource, mainClass
iii.[–deploy-mode] = cluster & [–master] = yarn:–class --arg --jar/–primary-py-file/–primary-r-file
iv.[–deploy-mode] = cluster & [–master] = mesos:*: primaryResource
c)获取childClasspath
i.[–deploy-mode] = clent:读取–jars配置,与primaryResource信息(…/examples/jars/spark-examples_2.11-2.1.0.jar)
d)获取sysProps
i.将sparkPropertie中的所有配置封装成新的sysProps对象,另外还增加了一下额外的配置项目
e)将childClasspath通过当前的类加载器加载中
f)将sysProps设置到当前jvm环境中
g)最终反射执行childMainClass,传参为childArgs

sparkSubmitArguments
大数据学习笔记之Spark(六):Spark内核解析_第58张图片
可以看到他的属性,很多都是spark在submit的时候能够指定的选项
大数据学习笔记之Spark(六):Spark内核解析_第59张图片
master如果是yarn的,就初始化一些yarn的东西
大数据学习笔记之Spark(六):Spark内核解析_第60张图片
重写了tostring,把这些都打印出来了

然后看SparkSubmit这个类
大数据学习笔记之Spark(六):Spark内核解析_第61张图片
在这个方法中比较重要的prepareSubmitEnvironment,准备提交环境。
大数据学习笔记之Spark(六):Spark内核解析_第62张图片
读一下英文的注释,一个是元组,一个是依赖,一个是系统属性,一个是main class
大数据学习笔记之Spark(六):Spark内核解析_第63张图片
根据提交的不同,选择一些不同的模式
大数据学习笔记之Spark(六):Spark内核解析_第64张图片
初始化一些参数,然后简单浏览一遍
在刚才的submit方法中传入的参数是什么,不知道,怎么办
大数据学习笔记之Spark(六):Spark内核解析_第65张图片
看spark源码,除了下载源码直接看,也可以在项目中,打开源码,然后download sources,打断点,查看

这里定义了dorunmain 可以看到最后不论是if还是else都运行了dorunmain方法
大数据学习笔记之Spark(六):Spark内核解析_第66张图片
这里传入了一些参数
大数据学习笔记之Spark(六):Spark内核解析_第67张图片
可以看到这里是java的反射方法,也就是执行了main方法
这个main方法在哪里?
大数据学习笔记之Spark(六):Spark内核解析_第68张图片
clientpoint在哪里创建的呢?就在这个方法里面
大数据学习笔记之Spark(六):Spark内核解析_第69张图片
在这个方法中还是new sparkConf这些东西,看到倒数第二行,new ClientEndpoint

大数据学习笔记之Spark(六):Spark内核解析_第70张图片
同样,在这个ClientEndpoint里面要关注的方法有 onStart receive
onstart方法中
大数据学习笔记之Spark(六):Spark内核解析_第71张图片
大数据学习笔记之Spark(六):Spark内核解析_第72张图片
对应于上面的流程图中的箭头所指的地方

然后去master里面找
大数据学习笔记之Spark(六):Spark内核解析_第73张图片
如上,如果state!=ALIVE,则reply false,懂大概意思吧
相反,则createDriver,创建driver,create完了之后就保存下来
最后有reply方法
大数据学习笔记之Spark(六):Spark内核解析_第74张图片
对应于图中的箭头所指
再回到client方法中,如果submitDriverResponse是ok的
大数据学习笔记之Spark(六):Spark内核解析_第75张图片
注意这里的pollAndReportStatus方法
大数据学习笔记之Spark(六):Spark内核解析_第76张图片
在这个里面发送了一个RequestDriverStatus请求
大数据学习笔记之Spark(六):Spark内核解析_第77张图片
对应于图中箭头所指
发送过来之后要等待你的回复
大数据学习笔记之Spark(六):Spark内核解析_第78张图片
如果statusResponse没有任何的东西,就退出

RequestDriverStatus做了哪些东西呢?同样在mster里面找
大数据学习笔记之Spark(六):Spark内核解析_第79张图片
类似于上面,如果当前的状态不是ALIVE,就false
反之,就reply DriverStatusRsponse

6.3Client启动流程

Client的启动流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第80张图片
1)SparkConf:加载key以spark.开头的系统属性(Utils.getSystemProperties)
2)ClientArguments:
a)解析Client启动的参数
i.–cores -c --memory -m --supervise -s --verbose -v
ii.launch jarUrl master mainClass
iii.kill master driverId
b)将–properties-file(没有配置默认为conf/spark-defaults.conf)中spark.开头的配置存入SparkConf
c)在没有配置情况下,cores默认为1核
d)在没有配置情况下,memory默认为1G
e)NettyRpcEnv中的内部处理遵循RpcEndpoint统一处理,这里不再赘述
3)最终守护进程会一直存在等待结束信awaitTermination

6.4Client的OnStart监听事件

Client的启动完成后异步执行工作如下: 
大数据学习笔记之Spark(六):Spark内核解析_第81张图片
1)如果是发布任务(case launch),Client创建一个DriverDescription,并向Master发起RequestSubmitDriver请求
大数据学习笔记之Spark(六):Spark内核解析_第82张图片
a)Command中的mainClass为: org.apache.spark.deploy.worker.DriverWrapper
b)Command中的arguments为: Seq("{{WORKER_URL}}", “{{USER_JAR}}”, driverArgs.mainClass)
2)Master接受RequestSubmitDriver请求后,将DriverDescription封装为一个DriverInfo,
大数据学习笔记之Spark(六):Spark内核解析_第83张图片
a)startTime与submitDate都为当前时间
b)driverId格式为:driver-yyyyMMddHHmmss-nextId,nextId是全局唯一的
3)Master持久化DriverInfo,并加入待调度列表中(waitingDrivers),触发公共资源调度逻辑。
4)Master公共资源调度结束后,返回SubmitDriverResponse给Client

6.5RpcMessage处理(receiveAndReply)

消息实例 发起方 接收方 说明

6.6OneWayMessage处理(receive)

消息实例 发起方 接收方 说明
SubmitDriverResponse Master Client
KillDriverResponse Client

第7章Driver和DriverRunner

Client向Master发起RequestSubmitDriver请求,Master将DriverInfo添加待调度列表中(waitingDrivers),下面针对于Driver进一步梳理

大数据学习笔记之Spark(六):Spark内核解析_第84张图片
除了上面的异常情况外,正常情况下,是createDriver
createDriver里面new了一个DriverInfo
大数据学习笔记之Spark(六):Spark内核解析_第85张图片
driver整个的信息封装在这个类里面
可以看到核心的方法是在schedule()

大数据学习笔记之Spark(六):Spark内核解析_第86张图片
读一下上面的英文,意思是如果有需要部署的app的时候,这个方法是用于分配资源的,每次如果有新的app加入进来,或者当前的可用资源变更了,这个时候都要去调用一下schedule,他是一个公共方法,接下来看一下做了哪些事
大数据学习笔记之Spark(六):Spark内核解析_第87张图片
还是之前的,如果状态不对就return
woker如果满足driver的运行系统,就在woker里面去launch driver如果launch为true的时候,就直接跳出循环了,所以一旦检测到第一个driver就跳出来了。


在launchDriver里面有worker信息和driver信息
把driver添加到worker里面,然后看到worker的endpoint send了一个东西,launchDriver,对应于图中的箭头


当worker收到这个driver之后new了一个DriverRunner,然后Driverrunner 执行 start方法,然后这个worker更新了他的内存和cores

所以可以看到,方法集中到了driverRunner上

简单读一下英文的注释:包装了driver,失败之后自动重driver,如果是standlone用的是这个,如果是yarn就用其他的


在这个里面有一个start方法,里面new了一个线程,里面添加了addShutDowdHook的方法(退出jvm的钩子)
核心方法是prepareAndRunDriver

createWorkingDirectory创建了working目录
downLoadUserJar从远程把jar包从client里面download下来了
runDriver

通过processBuilderLike 和 initialize 进行runDriver
把命令封装到了command里面

通过command.start执行了一下,然后返回了这个进程


如果一直走到最后也没有抛出异常,worker会send一个东西 DriverStateChanged


之后如果worker接收到了driverStateChanged

如上,如果给的状态没有问题,会有一个sendToMaster方法,也就是把这个driverStateChanged返回给了master


如果没有错到这里就完了,如果有错,则执行相应的操作,这个时候driver启动就执行完了

7.1Master对Driver资源分配

大致流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第88张图片
waitingDrivers与aliveWorkers进行资源匹配,
1)在waitingDrivers循环内,轮询所有aliveWorker
2)如果aliveWorker满足当前waitingDriver资源要求,给Worker发送LaunchDriver指令并将 waitingDriver移除waitingDrivers,则进行下一次waitingDriver的轮询工作
3)如果轮询完所有aliveWorker都不满足waitingDriver资源要求,则进行下一次waitingDriver的轮询工作
4)所有发起的轮询开始点都上次轮询结束点的下一个点位开始

7.2Worker运行DriverRunner

Driver的启动,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第89张图片
1)当Worker遇到LaunchDriver指令时,创建并启动一个DriverRunner
2)DriverRunner启动一个线程【DriverRunner for [driverId]】处理Driver启动工作
3)【DriverRunner for [driverId]】:
a)添加JVM钩子,针对于每个diriverId创建一个临时目录
b)将DriverDesc.jarUrl通过Netty从Driver机器远程拷贝过来
c)根据DriverDesc.command模板构建本地执行的command命令,并启动该command对应的Process进程
d)将Process的输出流输出到文件stdout/stderror,如果Process启动失败,进行1-5的秒的反复启动工作,直到启动成功,在释放Worker节点的DriverRunner的资源

7.3DriverRunner创建并运行DriverWrapper

DriverWrapper的运行,流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第90张图片
1)DriverWapper创建了一个RpcEndpoint与RpcEnv
2)RpcEndpoint为WorkerWatcher,主要目的为监控Worker节点是否正常,如果出现异常就直接退出
3)然后当前的ClassLoader加载userJar,同时执行userMainClass
4)执行用户的main方法后关闭workerWatcher

第8章SparkContext解析

8.1SparkContext解析

SparkContext是用户通往Spark集群的唯一入口,任何需要使用Spark的地方都需要先创建SparkContext,那么SparkContext做了什么?
首先SparkContext是在Driver程序里面启动的,可以看做Driver程序和Spark集群的一个连接,SparkContext在初始化的时候,创建了很多对象:
大数据学习笔记之Spark(六):Spark内核解析_第91张图片
上图列出了SparkContext在初始化创建的时候的一些主要组件的构建。

上面sparkContext new了很多重要的东西,比如sparkenv就是spark的一些环境,sparkscheduler执行具体的任务,sparkBackerd具体去操作executor这些东西,DAGScheduler代码里面的RDD转化成task,然后转化的过程中呢,可能先转化成stage,然后转化成 task list,task list里面包含的是task。

然后入口是sparkContext
大数据学习笔记之Spark(六):Spark内核解析_第92张图片

大数据学习笔记之Spark(六):Spark内核解析_第93张图片
如果没有设置spark.master就报错,如果没有设置spark.app.name就报错
大数据学习笔记之Spark(六):Spark内核解析_第94张图片
如果master是yarn并且deploymode是client的时候,就添加上面代码中的环境变量
JobProgressListener监听整个任务执行的progress
CreateSparkEnv 创建spark的env,这个之前看过主要是sparkEnv.createDriverEnv
中间是一些初始化
有一个比较重要的方法createTaskScheduler
大数据学习笔记之Spark(六):Spark内核解析_第95张图片
从上面的图中能够看到右边的图中,第一个创建了sparkEnv,第二个创建了TaskScheduler

大数据学习笔记之Spark(六):Spark内核解析_第96张图片
点进去这个createTaskScheduler
通过master match一下,如果是local模式的,就新建一个localschdulerBackend
如果是LOCAL_N_REGEX这种模式的,就是localSchedulerBacked
如果是SPARK_REGEX这种模式,还是TaskSchedulerImpl,第二个变成了StandaloneSchedulerBackend
下面还有很多模式 最后返回的是StandaloneSchedulerBackend
大数据学习笔记之Spark(六):Spark内核解析_第97张图片
图中箭头所指,standaloneAppClient是这个中的主要东西,主要负责driver和master之间的通信

大数据学习笔记之Spark(六):Spark内核解析_第98张图片
它的start方法
大数据学习笔记之Spark(六):Spark内核解析_第99张图片
在这里new了一个standaloneAppClient
在这个里面有一个嵌套类叫ClientEndpoint
大数据学习笔记之Spark(六):Spark内核解析_第100张图片
它继承了ThreadSafeRpcEndpoint
大数据学习笔记之Spark(六):Spark内核解析_第101张图片
这里面也有一个onstart方法,然后里面有个registerWithMaster,然后也有receive、receiveAndReply
大数据学习笔记之Spark(六):Spark内核解析_第102张图片
对应于上面图中的第七个注册应用这个东西

然后去master里面看
大数据学习笔记之Spark(六):Spark内核解析_第103张图片
获取到registerApp之后,如果当前的master状态不对,则什么也不做,如果是active的,即多节点的master,之后createApplication、registerApplication,然后driver.send,然后schedule,对应于图中
这个schedule就是启动分配Executor
大数据学习笔记之Spark(六):Spark内核解析_第104张图片
然后看一下createApplication
大数据学习笔记之Spark(六):Spark内核解析_第105张图片
这个ApplicationInfo就是记录了一些状态

registerApplication
大数据学习笔记之Spark(六):Spark内核解析_第106张图片
把application的信息进行了记录

大数据学习笔记之Spark(六):Spark内核解析_第107张图片
然后这个方法进行完了,就完成了application的注册,然后driver.send(RegisteredApplication)
大数据学习笔记之Spark(六):Spark内核解析_第108张图片
对应于上面的箭头所以
master send之后谁去接受它呢?
就是刚才的StandaloneAppClient ClientEndpoint
RegisteredApplication
大数据学习笔记之Spark(六):Spark内核解析_第109张图片
具体没做什么事,在listener里面把appId包括了一下

先通过sparksubmit 提交应用,整个应用的时候master在某个worker上面创建driver的jvm,创建的过程中会从driver client里面下载jar包,下载完jar包之后从申请的jvm里面去运行,运行的过程中new 了sparkContext,sparkEnv,new了TaskScheduler,StandaloneSchedulerBackend,到这一步,整个应用已经运行完成了,但是还没有运行rdd的条件,因为我还没有得到我的executor,即整个运行的容器,这个时候通过ClientEndpoint向master注册,这个时候master注册之后,直接schedule(),然后调用executor

再看一下这张图
大数据学习笔记之Spark(六):Spark内核解析_第110张图片
那么接着看schedule这个方法
大数据学习笔记之Spark(六):Spark内核解析_第111张图片
大数据学习笔记之Spark(六):Spark内核解析_第112张图片
上面的driver已经launched了,所以不会走那个while循环,然后,直接走下面的startExecutorsOnWorkers
y因为上面的for循环里面其实是给driver分配资源,因为已经加载了,所以执行最后的startExecutorOnWorkers
大数据学习笔记之Spark(六):Spark内核解析_第113张图片
这里面比较核心的方法是scheduleExecutorsOnWorkers
同样能够看到还有一些逻辑,这些逻辑就是为executor运行在哪些worker上,运行多少等等
然后看一下scheduleExecutorsOnWorkers这个方法
大数据学习笔记之Spark(六):Spark内核解析_第114张图片
上面的流程中MasterEndpoint其实是给worker提交了一个消息,叫做executor,workerEndpoint收到这个launchExecutor之后呢就create()和start()ExecutorRunner,然后这个ExecutorRunner就负责创建了下面的Executor,创建了之后worker就返回了一个消息,叫做ExecutorStateChanged,意识就是Executor已经ok了,这个时候后master会告诉远程的driver“我给你分配额Executor已经ok了”,这是整个executor启动的进程
大数据学习笔记之Spark(六):Spark内核解析_第115张图片
大数据学习笔记之Spark(六):Spark内核解析_第116张图片
在这个方法里面定义了一些函数canLaunchExecutor,测试一下能不能启动成功,
大数据学习笔记之Spark(六):Spark内核解析_第117张图片
如果能运行成功,assignedExecutor,点进去,然后看
大数据学习笔记之Spark(六):Spark内核解析_第118张图片
大数据学习笔记之Spark(六):Spark内核解析_第119张图片
简单看一下注释,具体进行了资源分配,可以看到有一个方法叫做launchExecutor,真正的去加载Executor,是在for循环里面,说明在一个worker上可能有很多Executor

大数据学习笔记之Spark(六):Spark内核解析_第120张图片
在Master里面send LaunchExecutor,worker的所有信息都在master上进行,在master上把所有的哪个worker需要启动哪个进行匹配,直接对worker进行launchexecutor,发送完了之后,
大数据学习笔记之Spark(六):Spark内核解析_第121张图片
LaunchExecutor
大数据学习笔记之Spark(六):Spark内核解析_第122张图片
创建了一个Executor的运行路径,然后chmod700等等
再往下看
大数据学习笔记之Spark(六):Spark内核解析_第123张图片
创建了ExecutorRunner
大数据学习笔记之Spark(六):Spark内核解析_第124张图片
然后看到上面的代码,new ExecutorRunner返回一个manager,然后执行manager.start(),start完了之后大数据学习笔记之Spark(六):Spark内核解析_第125张图片
sendToMaster ExecutorStateChanged
大数据学习笔记之Spark(六):Spark内核解析_第126张图片
即回去告诉了master,我的Executor已经运行好了,可以继续往下走了。
然后看一下start方法

大数据学习笔记之Spark(六):Spark内核解析_第127张图片
这里面是new 了一个Thread,执行的是ferchAndRunExecutor,点进去
大数据学习笔记之Spark(六):Spark内核解析_第128张图片
里面有buildProcessBuilder,这里面同样有buildProcessBuilder,把launchedExecutor封装了,
大数据学习笔记之Spark(六):Spark内核解析_第129张图片
然后通过builder.start运行,运行完了之后给worker发送了一个ExecutorStateChange,在上面的流程图中没有把这步画出来
大数据学习笔记之Spark(六):Spark内核解析_第130张图片图中少了一步,如下,就是运行完了之后,在把这个消息返回回来

代码中ExecutorStateChange 是什么呢?就是Executor

Executor怎么启动的?
这个参数里面的command到底是什么?不知道,怎么办有两种办法,一种是debug一下,看看实时的参数是什么


会看到这个command是如图所示,也就是说在提交程序之前把CoarseGrainedExecutorBackend封装到里面了,这个command就是图中的

那么这个CoarseGrainedExecutorBackend是什么东西呢?其实走的就是CoarseGrainedExecutorBackend的main方法

在main方法里面做了一些什么呢?首先检测了一下参数,最终进入了run方法,在run方法里面创建了一个RpcEnv

同样创建一个RpcEnv之后,CoarseGrainedExecutorBackend,如下


然后发现继承了ThreadSafeRpcEndpoint,所以executor通信的端点就是CoarseGrainedExecutorBackend

往下看,这个类有一个Field叫做executor,然后在往下看,new 了一个Executor赋值给了executor

也就是图中的箭头所指

CoarseGrainedExecutorBackend会监听外面发送给它的任务的请求,然后用Executor进行run。

Executor里面

有一个TaskRunner,里面具体的run方法在这里执行


在Worker里面ExecutorRunner就是调用了CoarseGrainedExecutorBackend的main方法,声明了endpoint和rpcEnv还有相对应的Executor,这几个组合起来就是Executor的实例

申请完了之后在worker里面有一个sendToMaster

这个时候ExecutorStateChanged,然后把这个消息直接发送给master

在master中接收到这个消息之后,就会把这个ExecutorUpdate发送给Driver,说明这个时候所有的Executor已经分配完了,如下图所示这两步

然后ClientPoint接受到这个消息

在这个方法里面,接收到了目前为止所有的都已经finash了。

这个时候就需要,启动 Task执行了

这个时候发现入口在RDD的action方法里面

比如随便找一个RDD里面的方法reduce,在reduce方法的最后,其实调用了 sc.runJob,

那么图中下面的那些方法都是通过runjob完成的

点击进入runjob,然后在进入下一个runjob

然后在这里就发现了dagSchedules.runJob,然后继续点进去


可以看到submitJob,然后生成一个waiter,下面一段代码是等待这个job是否运行成功,然后再看submitJob

在submitJob里面有一个eventProcessLoop,就会把当前的job提交到eventProcessLoop,因为对于当前的application,如果有很多的action操作,那么每一个action操作都会当做一个job来执行

在当前的这个EventProcessLoop里面有一个timer,这个timer就是用于去轮询当前这个队列
如果遇到了当前的jobSubmit

如果遇到了JobSubmit,那么就会handleJobSubmit,然后进入这个方法,进入这个方法之后,就会真正的进入stage

会先获取到最终的stage,然后submitStage

点进去,然后发现submit这个函数里面有一个递归调用

如果stage 的parent是不存在的,就直接调它。
同样能看到submitMissingTasks,点进去

发现进行了区分,如果是shuffleMapstage后者是ResultStage去干不同的事情。
分局task类型的不同封装成不同的task,然后task数量是大于零的,taskScheduler.submitTasks,然后就会把所有的task封装成一个Taskset,然后去运行

然后找到submitTasks方法,里面有CreateTaskManager方法,管理所有的task

找到

到他的父类里面,有一个onstart方法

在父类启动的时候给自己发送了一个ReviveOffers这个消息

在这里插入图片描述
然后直接是makeOffers

大数据学习笔记之Spark(六):Spark内核解析_第131张图片
然后申请了当前任务的一些资源
申请了这些资源后,发现最后有一个方法叫做launchTask
大数据学习笔记之Spark(六):Spark内核解析_第132张图片
在这个方法里面往下看
大数据学习笔记之Spark(六):Spark内核解析_第133张图片

这个方法对应于图中所指的
大数据学习笔记之Spark(六):Spark内核解析_第134张图片
在executor中找到这个消息
大数据学习笔记之Spark(六):Spark内核解析_第135张图片
如果executor==null,则直接退出,否则用launchTask这个方法 ,对应于图中
大数据学习笔记之Spark(六):Spark内核解析_第136张图片
大数据学习笔记之Spark(六):Spark内核解析_第137张图片
在launchTask里面有一个threadPool.execute
到目前为止driver根据配置信息和消息信息,把他的code转化成了相应的rdd,然后rdd转换成了各个stage,然后每个stage转换成了taskset,然后taskset通过Driverendpoint处理消息给具体的executor发送了LaunchTask,然后在消息里面去写当前task需要处理的数据
处理完了之后会发送一个taskfinashed
大数据学习笔记之Spark(六):Spark内核解析_第138张图片
在executor的run方法里面有一个execBackend.statusUpdate
大数据学习笔记之Spark(六):Spark内核解析_第139张图片
大数据学习笔记之Spark(六):Spark内核解析_第140张图片
如果executor执行完了之后,直接调用了statusUpdate方法,而不是通过发消息的方式,然民下面调用了driverRed.send(msg),向driver发送了数据
也就是图中的绿色的线条1号,taskfinash不是通过消息发送的,而是通过直接调用的,调用statusupdate。
调用完了之后,通过消息转发,转发给了driver
大数据学习笔记之Spark(六):Spark内核解析_第141张图片
在driverEndpoint里面获得消息之后,更新了整个scheduler的状态,如下代码
如果TaskState执行完了,

大数据学习笔记之Spark(六):Spark内核解析_第142张图片
然后看一下statusupdate做了哪些事
大数据学习笔记之Spark(六):Spark内核解析_第143张图片
然后上面的statusupdate往下,如果stage是没有执行完的,会继续执行reviveOffers,再去执行stage的应用
大数据学习笔记之Spark(六):Spark内核解析_第144张图片

8.2SparkContext创建过程

创建过程如下:
大数据学习笔记之Spark(六):Spark内核解析_第145张图片
SparkContext在新建时
1)内部创建一个SparkEnv,SparkEnv内部创建一个RpcEnv
a)RpcEnv内部创建并注册一个MapOutputTrackerMasterEndpoint(该Endpoint暂不介绍)
2)接着创建DAGScheduler,TaskSchedulerImpl,SchedulerBackend
a)TaskSchedulerImpl创建时创建SchedulableBuilder,SchedulableBuilder根据类型分为FIFOSchedulableBuilder,FairSchedulableBuilder两类
3)最后启动TaskSchedulerImpl,TaskSchedulerImpl启动SchedulerBackend
a)SchedulerBackend启动时创建ApplicationDescription,DriverEndpoint, StandloneAppClient
b)StandloneAppClient内部包括一个ClientEndpoint

8.3SparkContext简易结构与交互关系

大数据学习笔记之Spark(六):Spark内核解析_第146张图片
1)SparkContext:是用户Spark执行任务的上下文,用户程序内部使用Spark提供的Api直接或间接创建一个SparkContext
2)SparkEnv:用户执行的环境信息,包括通信相关的端点
3)RpcEnv:SparkContext中远程通信环境
4)ApplicationDescription:应用程序描述信息,主要包含appName, maxCores, memoryPerExecutorMB, coresPerExecutor, Command(
CoarseGrainedExecutorBackend), appUiUrl等
5)ClientEndpoint:客户端端点,启动后向Master发起注册RegisterApplication请求
6)Master:接受RegisterApplication请求后,进行Worker资源分配,并向分配的资源发起LaunchExecutor指令
7)Worker:接受LaunchExecutor指令后,运行ExecutorRunner
8)ExecutorRunner:运行applicationDescription的Command命令,最终Executor,同时向DriverEndpoint注册Executor信息

8.4Master对Application资源分配

当Master接受Driver的RegisterApplication请求后,放入waitingDrivers队列中,在同一调度中进行资源分配,分配过程如下:
大数据学习笔记之Spark(六):Spark内核解析_第147张图片
waitingApps与aliveWorkers进行资源匹配
1)如果waitingApp配置了app.desc.coresPerExecutor:
a)轮询所有有效可分配的worker,每次分配一个executor,executor的核数为minCoresPerExecutor(app.desc.coresPerExecutor),直到不存在有效可分配资源或者app依赖的资源已全部被分配
2)如果waitingApp没有配置app.desc.coresPerExecutor:
a)轮询所有有效可分配的worker,每个worker分配一个executor,executor的核数为从minCoresPerExecutor(为固定值1)开始递增,直到不存在有效可分配资源或者app依赖的资源已全部被分配
3)其中有效可分配worker定义为满足一次资源分配的worker:
a)cores满足:usableWorkers(pos).coresFree - assignedCores(pos) >= minCoresPerExecutor,
b)memory满足(如果是新的Executor):usableWorkers(pos).memoryFree - assignedExecutors(pos) * memoryPerExecutor >= memoryPerExecutor
注意:Master针对于applicationInfo进行资源分配时,只有存在有效可用的资源就直接分配,而分配剩余的app.coresLeft则等下一次再进行分配

8.5Worker创建Executor

大数据学习笔记之Spark(六):Spark内核解析_第148张图片
(图解:橙色组件是Endpoint组件)
Worker启动Executor
1)在Worker的tempDir下面创建application以及executor的目录,并chmod700操作权限
2)创建并启动ExecutorRunner进行Executor的创建
3)向master发送Executor的状态情况
ExecutorRnner
1)新线程【ExecutorRunner for [executorId]】读取ApplicationDescription将其中Command转化为本地的Command命令
2)调用Command并将日志输出至executor目录下的stdout,stderr日志文件中,Command对应的java类为CoarseGrainedExecutorBackend
CoarseGrainedExecutorBackend
1)创建一个SparkEnv,创建ExecutorEndpoint(CoarseGrainedExecutorBackend),以及WorkerWatcher
2)ExecutorEndpoint创建并启动后,向DriverEndpoint发送RegisterExecutor请求并等待返回
3)DriverEndpoint处理RegisterExecutor请求,返回ExecutorEndpointRegister的结果
4)如果注册成功,ExecutorEndpoint内部再创建Executor的处理对象
至此,Spark运行任务的容器框架就搭建完成。

第9章Job提交和Task的拆分

在前面的章节Client的加载中,Spark的DriverRunner已开始执行用户任务类(比如:org.apache.spark.examples.SparkPi),下面我们开始针对于用户任务类(或者任务代码)进行分析

9.1整体预览

大数据学习笔记之Spark(六):Spark内核解析_第149张图片
1)Code:指的用户编写的代码
2)RDD:弹性分布式数据集,用户编码根据SparkContext与RDD的api能够很好的将Code转化为RDD数据结构(下文将做转化细节介绍)
3)DAGScheduler:有向无环图调度器,将RDD封装为JobSubmitted对象存入EventLoop(实现类DAGSchedulerEventProcessLoop)队列中
4)EventLoop: 定时扫描未处理JobSubmitted对象,将JobSubmitted对象提交给DAGScheduler
5)DAGScheduler:针对于JobSubmitted进行处理,最终将RDD转化为执行TaskSet,并将TaskSet提交至TaskScheduler
6)TaskScheduler: 根据TaskSet创建TaskSetManager对象存入SchedulableBuilder的数据池(Pool)中,并调用DriverEndpoint唤起消费(ReviveOffers)操作
7)DriverEndpoint:接受ReviveOffers指令后将TaskSet中的Tasks根据相关规则均匀分配给Executor
8)Executor:启动一个TaskRunner执行一个Task

9.2Code转化为初始RDDs

我们的用户代码通过调用Spark的Api(比如:SparkSession.builder.appName(“Spark Pi”).getOrCreate()),该Api会创建Spark的上下文(SparkContext),当我们调用transform类方法 (如:parallelize(),map())都会创建(或者装饰已有的) Spark数据结构(RDD), 如果是action类操作(如:reduce()),那么将最后封装的RDD作为一次Job提交,存入待调度队列中(DAGSchedulerEventProcessLoop )待后续异步处理。
如果多次调用action类操作,那么封装的多个RDD作为多个Job提交。
流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第150张图片
ExecuteEnv(执行环境 )
1)这里可以是通过spark-submit提交的MainClass,也可以是spark-shell脚本
2)MainClass : 代码中必定会创建或者获取一个SparkContext
3)spark-shell:默认会创建一个SparkContext
RDD(弹性分布式数据集)

1)create:可以直接创建(如:sc.parallelize(1 until n, slices) ),也可以在其他地方读取(如:sc.textFile(“README.md”))等
2)transformation:rdd提供了一组api可以进行对已有RDD进行反复封装成为新的RDD,这里采用的是装饰者设计模式,下面为部分装饰器类图
大数据学习笔记之Spark(六):Spark内核解析_第151张图片
3)action:当调用RDD的action类操作方法时(collect、reduce、lookup、save ),这触发DAGScheduler的Job提交
4)DAGScheduler:创建一个名为JobSubmitted的消息至DAGSchedulerEventProcessLoop阻塞消息队列(LinkedBlockingDeque)中
5)DAGSchedulerEventProcessLoop:启动名为【dag-scheduler-event-loop】的线程实时消费消息队列
6)【dag-scheduler-event-loop】处理完成后回调JobWaiter
7)DAGScheduler:打印Job执行结果
8)JobSubmitted:相关代码如下(其中jobId为DAGScheduler全局递增Id):

eventProcessLoop.post(JobSubmitted(
        jobId, rdd, func2, partitions.toArray, callSite, waiter,
        SerializationUtils.clone(properties)))

最终示例:
大数据学习笔记之Spark(六):Spark内核解析_第152张图片
最终转化的RDD分为四层,每层都依赖于上层RDD,将ShffleRDD封装为一个Job存入DAGSchedulerEventProcessLoop待处理,如果我们的代码中存在几段上面示例代码,那么就会创建对应对的几个ShffleRDD分别存入DAGSchedulerEventProcessLoop

9.3RDD分解为待执行任务集合(TaskSet)

Job提交后,DAGScheduler根据RDD层次关系解析为对应的Stages,同时维护Job与Stage的关系。
将最上层的Stage根据并发关系(findMissingPartitions )分解为多个Task,将这个多个Task封装为TaskSet提交给TaskScheduler。非最上层的Stage的存入处理的列表中(waitingStages += stage)
流程如下:
大数据学习笔记之Spark(六):Spark内核解析_第153张图片
1)DAGSchedulerEventProcessLoop中,线程【dag-scheduler-event-loop】处理到JobSubmitted
2)调用DAGScheduler进行handleJobSubmitted
a)首先根据RDD依赖关系依次创建Stage族,Stage分为ShuffleMapStage,ResultStage两类
大数据学习笔记之Spark(六):Spark内核解析_第154张图片
b)更新jobId与StageId关系Map
c)创建ActiveJob,调用LiveListenerBug,发送SparkListenerJobStart指令
d)找到最上层Stage进行提交,下层Stage存入waitingStage中待后续处理
i.调用OutputCommitCoordinator进行stageStart()处理
ii.调用LiveListenerBug, 发送 SparkListenerStageSubmitted指令
iii.调用SparkContext的broadcast方法获取Broadcast对象
根据Stage类型创建对应多个Task,一个Stage根据findMissingPartitions分为多个对应的Task,Task分为ShuffleMapTask,ResultTask
在这里插入图片描述
iv.将Task封装为TaskSet,调用TaskScheduler.submitTasks(taskSet)进行Task调度,关键代码如下:

taskScheduler.submitTasks(new TaskSet(
        tasks.toArray, stage.id, stage.latestInfo.attemptId, jobId, properties))

9.4TaskSet封装为TaskSetManager并提交至Driver

TaskScheduler将TaskSet封装为TaskSetManager(new TaskSetManager(this, taskSet, maxTaskFailures, blacklistTrackerOpt)),存入待处理任务池(Pool)中,发送DriverEndpoint唤起消费(ReviveOffers)指令
大数据学习笔记之Spark(六):Spark内核解析_第155张图片
1)DAGSheduler将TaskSet提交给TaskScheduler的实现类,这里是TaskChedulerImpl
2)TaskSchedulerImpl创建一个TaskSetManager管理TaskSet,关键代码如下:

new TaskSetManager(this, taskSet, maxTaskFailures, blacklistTrackerOpt)

3)同时将TaskSetManager添加SchedduableBuilder的任务池Poll中
4)调用SchedulerBackend的实现类进行reviveOffers,这里是standlone模式的实现类StandaloneSchedulerBackend
5)SchedulerBackend发送ReviveOffers指令至DriverEndpoint

9.5Driver将TaskSetManager分解为TaskDescriptions并发布任务到Executor

Driver接受唤起消费指令后,将所有待处理的TaskSetManager与Driver中注册的Executor资源进行匹配,最终一个TaskSetManager得到多个TaskDescription对象,按照TaskDescription想对应的Executor发送LaunchTask指令
大数据学习笔记之Spark(六):Spark内核解析_第156张图片

当Driver获取到ReviveOffers(请求消费)指令时
1)首先根据executorDataMap缓存信息得到可用的Executor资源信息(WorkerOffer),关键代码如下

val activeExecutors = executorDataMap.filterKeys(executorIsAlive)
val workOffers = activeExecutors.map { case (id, executorData) =>
  new WorkerOffer(id, executorData.executorHost, executorData.freeCores)
}.toIndexedSeq

2)接着调用TaskScheduler进行资源匹配,方法定义如下:

    def resourceOffers(offers: IndexedSeq[WorkerOffer]): Seq[Seq[TaskDescription]] = synchronized {..}

a)将WorkerOffer资源打乱(val shuffledOffers = Random.shuffle(offers))
b)将Poo中待处理的TaskSetManager取出(val sortedTaskSets = rootPool.getSortedTaskSetQueue),
c)并循环处理sortedTaskSets并与shuffledOffers循环匹配,如果shuffledOffers(i)有足够的Cpu资源( if (availableCpus(i) >= CPUS_PER_TASK) ),调用TaskSetManager创建TaskDescription对象(taskSet.resourceOffer(execId, host, maxLocality)),最终创建了多个TaskDescription,TaskDescription定义如下:

new TaskDescription(
        taskId,
        attemptNum,
        execId,
        taskName,
        index,
        sched.sc.addedFiles,
        sched.sc.addedJars,
        task.localProperties,
        serializedTask)

3)如果TaskDescriptions不为空,循环TaskDescriptions,序列化TaskDescription对象,并向ExecutorEndpoint发送LaunchTask指令,关键代码如下:

for (task <- taskDescriptions.flatten) {
        val serializedTask = TaskDescription.encode(task)
        val executorData = executorDataMap(task.executorId)
        executorData.freeCores -= scheduler.CPUS_PER_TASK
        executorData.executorEndpoint.send(LaunchTask(new SerializableBuffer(serializedTask)))
}

第10章Task执行和回执

DriverEndpoint最终生成多个可执行的TaskDescription对象,并向各个ExecutorEndpoint发送LaunchTask指令,本节内容将关注ExecutorEndpoint如何处理LaunchTask指令,处理完成后如何回馈给DriverEndpoint,以及整个job最终如何多次调度直至结束。

10.1Task的执行流程

Executor接受LaunchTask指令后,开启一个新线程TaskRunner解析RDD,并调用RDD的compute方法,归并函数得到最终任务执行结果
大数据学习笔记之Spark(六):Spark内核解析_第157张图片
1)ExecutorEndpoint接受到LaunchTask指令后,解码出TaskDescription,调用Executor的launchTask方法
2)Executor创建一个TaskRunner线程,并启动线程,同时将改线程添加到Executor的成员对象中,代码如下:

private val runningTasks = new ConcurrentHashMap[Long, TaskRunner]
runningTasks.put(taskDescription.taskId, taskRunner)

TaskRunner
1)首先向DriverEndpoint发送任务最新状态为RUNNING
2)从TaskDescription解析出Task,并调用Task的run方法
Task
1)创建TaskContext以及CallerContext(与HDFS交互的上下文对象)
2)执行Task的runTask方法
a)如果Task实例为ShuffleMapTask:解析出RDD以及ShuffleDependency信息,调用RDD的compute()方法将结果写Writer中(Writer这里不介绍,可以作为黑盒理解,比如写入一个文件中),返回MapStatus对象
b)如果Task实例为ResultTask:解析出RDD以及合并函数信息,调用函数将调用后的结果返回
TaskRunner将Task执行的结果序列化,再次向DriverEndpoint发送任务最新状态为FINISHED

10.2Task的回馈流程

TaskRunner执行结束后,都将执行状态发送至DriverEndpoint,DriverEndpoint最终反馈指令CompletionEvent至DAGSchedulerEventProcessLoop中
大数据学习笔记之Spark(六):Spark内核解析_第158张图片
1)DriverEndpoint接受到StatusUpdate消息后,调用TaskScheduler的statusUpdate(taskId, state, result)方法
2)TaskScheduler如果任务结果是完成,那么清除该任务处理中的状态,并调动TaskResultGetter相关方法,关键代码如下:

val taskSet = taskIdToTaskSetManager.get(tid)

taskIdToTaskSetManager.remove(tid)
        taskIdToExecutorId.remove(tid).foreach { executorId =>
    executorIdToRunningTaskIds.get(executorId).foreach { _.remove(tid) }
}
taskSet.removeRunningTask(tid)
if (state == TaskState.FINISHED) {
    taskResultGetter.enqueueSuccessfulTask(taskSet, tid, serializedData)
} else if (Set(TaskState.FAILED, TaskState.KILLED, TaskState.LOST).contains(state)) {
    taskResultGetter.enqueueFailedTask(taskSet, tid, state, serializedData)
}

TaskResultGetter启动线程启动线程【task-result-getter】进行相关处理
1)通过解析或者远程获取得到Task的TaskResult对象
2)调用TaskSet的handleSuccessfulTask方法,TaskSet的handleSuccessfulTask方法直接调用TaskSetManager的handleSuccessfulTask方法
TaskSetManager
1)更新内部TaskInfo对象状态,并将该Task从运行中Task的集合删除,代码如下:

val info = taskInfos(tid)
info.markFinished(TaskState.FINISHED, clock.getTimeMillis())
removeRunningTask(tid)

2)调用DAGScheduler的taskEnded方法,关键代码如下:
sched.dagScheduler.taskEnded(tasks(index), Success, result.value(), result.accumUpdates, info)
DAGScheduler向DAGSchedulerEventProcessLoop存入CompletionEvent指令,CompletionEvent对象定义如下:

private[scheduler] case class CompletionEvent(
task: Task[_],
reason: TaskEndReason,
result: Any,
accumUpdates: Seq[AccumulatorV2[_, _]],
taskInfo: TaskInfo) extends DAGSchedulerEvent

10.3Task的迭代流程

DAGSchedulerEventProcessLoop中针对于CompletionEvent指令,调用DAGScheduler进行处理,DAGScheduler更新Stage与该Task的关系状态,如果Stage下Task都返回,则做下一层Stage的任务拆解与运算工作,直至Job被执行完毕:
大数据学习笔记之Spark(六):Spark内核解析_第159张图片
1)DAGSchedulerEventProcessLoop接收到CompletionEvent指令后,调用DAGScheduler的handleTaskCompletion方法
2)DAGScheduler根据Task的类型分别处理
3)如果Task为ShuffleMapTask
a)待回馈的Partitions减取当前partitionId
b)如果所有task都返回,则markStageAsFinished(shuffleStage),同时向MapOutputTrackerMaster注册MapOutputs信息,且markMapStageJobAsFinished
c)调用submitWaitingChildStages(shuffleStage)进行下层Stages的处理,从而迭代处理最终处理到ResultTask,job结束,关键代码如下:

private def submitWaitingChildStages(parent: Stage) {
    ...
    val childStages = waitingStages.filter(_.parents.contains(parent)).toArray
    waitingStages --= childStages
    for (stage <- childStages.sortBy(_.firstJobId)) {
        submitStage(stage)
    }
}

4)如果Task为ResultTask
a)改job的partitions都已返回,则markStageAsFinished(resultStage),并cleanupStateForJobAndIndependentStages(job),关键代码如下

for (stage <- stageIdToStage.get(stageId)) {
    if (runningStages.contains(stage)) {
        logDebug("Removing running stage %d".format(stageId))
        runningStages -= stage
    }
    for ((k, v) <- shuffleIdToMapStage.find(_._2 == stage)) {
        shuffleIdToMapStage.remove(k)
    }
    if (waitingStages.contains(stage)) {
        logDebug("Removing stage %d from waiting set.".format(stageId))
        waitingStages -= stage
    }
    if (failedStages.contains(stage)) {
        logDebug("Removing stage %d from failed set.".format(stageId))
        failedStages -= stage
    }
}
// data structures based on StageId
stageIdToStage -= stageId
jobIdToStageIds -= job.jobId
jobIdToActiveJob -= job.jobId
activeJobs -= job

至此,用户编写的代码最终调用Spark分布式计算完毕。

第11章Spark的数据存储

Spark计算速度远胜于Hadoop的原因之一就在于中间结果是缓存在内存而不是直接写入到disk,本文尝试分析Spark中存储子系统的构成,并以数据写入和数据读取为例,讲述清楚存储子系统中各部件的交互关系。

11.1存储子系统概览

Storage模块主要分为两层:
1)通信层:storage模块采用的是master-slave结构来实现通信层,master和slave之间传输控制信息、状态信息,这些都是通过通信层来实现的。
2)存储层:storage模块需要把数据存储到disk或是memory上面,有可能还需replicate到远端,这都是由存储层来实现和提供相应接口。
而其他模块若要和storage模块进行交互,storage模块提供了统一的操作类BlockManager,外部类与storage模块打交道都需要通过调用BlockManager相应接口来实现。
大数据学习笔记之Spark(六):Spark内核解析_第160张图片
上图是Spark存储子系统中几个主要模块的关系示意图,现简要说明如下
1)CacheManager RDD在进行计算的时候,通过CacheManager来获取数据,并通过CacheManager来存储计算结果
2)BlockManager CacheManager在进行数据读取和存取的时候主要是依赖BlockManager接口来操作,BlockManager决定数据是从内存(MemoryStore)还是从磁盘(DiskStore)中获取
3)MemoryStore 负责将数据保存在内存或从内存读取
4)DiskStore 负责将数据写入磁盘或从磁盘读入
5)BlockManagerWorker 数据写入本地的MemoryStore或DiskStore是一个同步操作,为了容错还需要将数据复制到别的计算结点,以防止数据丢失的时候还能够恢复,数据复制的操作是异步完成,由BlockManagerWorker来处理这一部分事情
6)ConnectionManager 负责与其它计算结点建立连接,并负责数据的发送和接收
7)BlockManagerMaster 注意该模块只运行在Driver Application所在的Executor,功能是负责记录下所有BlockIds存储在哪个SlaveWorker上,比如RDD Task运行在机器A,所需要的BlockId为3,但在机器A上没有BlockId为3的数值,这个时候Slave worker需要通过BlockManager向BlockManagerMaster询问数据存储的位置,然后再通过ConnectionManager去获取。

11.2启动过程分析

上述的各个模块由SparkEnv来创建,创建过程在SparkEnv.create中完成

val blockManagerMaster = new BlockManagerMaster(registerOrLookup(
        "BlockManagerMaster",
        new BlockManagerMasterActor(isLocal, conf)), conf)
val blockManager = new BlockManager(executorId, actorSystem, blockManagerMaster, serializer, conf)

val connectionManager = blockManager.connectionManager
val broadcastManager = new BroadcastManager(isDriver, conf)
val cacheManager = new CacheManager(blockManager)

这段代码容易让人疑惑,看起来像是在所有的cluster node上都创建了BlockManagerMasterActor,其实不然,仔细看registerOrLookup函数的实现。如果当前节点是driver则创建这个actor,否则建立到driver的连接。

def registerOrLookup(name: String, newActor: => Actor): ActorRef = {
    if (isDriver) {
        logInfo("Registering " + name)
        actorSystem.actorOf(Props(newActor), name = name)
    } else {
        val driverHost: String = conf.get("spark.driver.host", "localhost")
        val driverPort: Int = conf.getInt("spark.driver.port", 7077)
        Utils.checkHost(driverHost, "Expected hostname")
        val url = s"akka.tcp://spark@$driverHost:$driverPort/user/$name"
        val timeout = AkkaUtils.lookupTimeout(conf)
        logInfo(s"Connecting to $name: $url")
        Await.result(actorSystem.actorSelection(url).resolveOne(timeout), timeout)
    }
}

初始化过程中一个主要的动作就是BlockManager需要向BlockManagerMaster发起注册

11.3通信层

大数据学习笔记之Spark(六):Spark内核解析_第161张图片
BlockManager包装了BlockManagerMaster,发送信息包装成BlockManagerInfo。Spark在Driver和Worker端都创建各自的BlockManager,并通过BlockManagerMaster进行通信,通过BlockManager对Storage模块进行操作。
BlockManager对象在SparkEnv.create函数中进行创建:

def registerOrLookupEndpoint(
        name: String, endpointCreator: => RpcEndpoint):
RpcEndpointRef = {
    if (isDriver) {
        logInfo("Registering " + name)
        rpcEnv.setupEndpoint(name, endpointCreator)
    } else {
        RpcUtils.makeDriverRef(name, conf, rpcEnv)
    }
}
…………
val blockManagerMaster = new BlockManagerMaster(registerOrLookupEndpoint(
        BlockManagerMaster.DRIVER_ENDPOINT_NAME,
        new BlockManagerMasterEndpoint(rpcEnv, isLocal, conf, listenerBus)),
        conf, isDriver)

// NB: blockManager is not valid until initialize() is called later.
val blockManager = new BlockManager(executorId, rpcEnv, blockManagerMaster,
        serializer, conf, mapOutputTracker, shuffleManager, blockTransferService,     securityManager,numUsableCores)

并且在创建之前对当前节点是否是Driver进行了判断。如果是,则创建这个Endpoint;否则,创建Driver的连接。
在创建BlockManager之后,BlockManager会调用initialize方法初始化自己。并且初始化的时候,会调用BlockManagerMaster向Driver注册自己,同时,在注册时也启动了Slave Endpoint。另外,向本地shuffle服务器注册Executor配置,如果存在的话。

def initialize(appId: String): Unit = {
…………
    master.registerBlockManager(blockManagerId, maxMemory, slaveEndpoint)

    // Register Executors' configuration with the local shuffle service, if one should exist.
    if (externalShuffleServiceEnabled && !blockManagerId.isDriver) {
        registerWithExternalShuffleServer()
    }
}

而BlockManagerMaster将注册请求包装成RegisterBlockManager注册到Driver。Driver的BlockManagerMasterEndpoint会调用register方法,通过对消息BlockManagerInfo检查,向Driver注册。

private def register(id: BlockManagerId, maxMemSize: Long, slaveEndpoint: RpcEndpointRef) {
    val time = System.currentTimeMillis()
    if (!blockManagerInfo.contains(id)) {
        blockManagerIdByExecutor.get(id.executorId) match {
            case Some(oldId) =>
                // A block manager of the same executor already exists, so remove it (assumed dead)
                logError("Got two different block manager registrations on same executor - "
                        + s" will replace old one $oldId with new one $id")
                removeExecutor(id.executorId)
            case None =>
        }
        logInfo("Registering block manager %s with %s RAM, %s".format(
                id.hostPort, Utils.bytesToString(maxMemSize), id))

        blockManagerIdByExecutor(id.executorId) = id

        blockManagerInfo(id) = new BlockManagerInfo(
                id, System.currentTimeMillis(), maxMemSize, slaveEndpoint)
    }
    listenerBus.post(SparkListenerBlockManagerAdded(time, id, maxMemSize))
}

不难发现BlockManagerInfo对象被保存到Map映射中。
在通信层中BlockManagerMaster控制着消息的流向,这里采用了模式匹配,所有的消息模式都在BlockManagerMessage中。

11.4存储层

大数据学习笔记之Spark(六):Spark内核解析_第162张图片
Spark Storage的最小存储单位是block,所有的操作都是以block为单位进行的。
在BlockManager被创建的时候MemoryStore和DiskStore对象就被创建出来了

val diskBlockManager = new DiskBlockManager(this, conf)
private[spark] val memoryStore = new MemoryStore(this, maxMemory)
private[spark] val diskStore = new DiskStore(this, diskBlockManager)

11.4.1Disk Store

由于当前的Spark版本对Disk Store进行了更细粒度的分工,把对文件的操作提取出来放到了DiskBlockManager中,DiskStore仅仅负责数据的存储和读取。
Disk Store会配置多个文件目录,Spark会在不同的文件目录下创建文件夹,其中文件夹的命名方式是:spark-UUID(随机UUID码)。Disk Store在存储的时候创建文件夹。并且根据“高内聚,低耦合”原则,这种服务型的工具代码就放到了Utils中(调用路径:DiskStore.putBytes—>DiskBlockManager.createLocalDirs—>Utils.createDirectory):

def createDirectory(root: String, namePrefix: String = "spark"): File = {
    var attempts = 0
    val maxAttempts = MAX_DIR_CREATION_ATTEMPTS
    var dir: File = null
    while (dir == null) {
        attempts += 1
        if (attempts > maxAttempts) {
            throw new IOException("Failed to create a temp directory (under " + root + ") after " +
                    maxAttempts + " attempts!")
        }
        try {
            dir = new File(root, namePrefix + "-" + UUID.randomUUID.toString)
            if (dir.exists() || !dir.mkdirs()) {
                dir = null
            }
        } catch { case e: SecurityException => dir = null; }
    }

    dir.getCanonicalFile
}

在DiskBlockManager里,每个block都被存储为一个file,通过计算blockId的hash值,将block映射到文件中。

def getFile(filename: String): File = {
    // Figure out which local directory it hashes to, and which subdirectory in that
    val hash = Utils.nonNegativeHash(filename)
    val dirId = hash % localDirs.length
    val subDirId = (hash / localDirs.length) % subDirsPerLocalDir

    // Create the subdirectory if it doesn't already exist
    val subDir = subDirs(dirId).synchronized {
        val old = subDirs(dirId)(subDirId)
        if (old != null) {
            old
        } else {
            val newDir = new File(localDirs(dirId), "%02x".format(subDirId))
            if (!newDir.exists() && !newDir.mkdir()) {
                throw new IOException(s"Failed to create local dir in $newDir.")
            }
            subDirs(dirId)(subDirId) = newDir
            newDir
        }
    }

    new File(subDir, filename)
}

def getFile(blockId: BlockId): File = getFile(blockId.name)

通过hash值的取模运算,求出dirId和subDirId。然后,在从subDirs中找到subDir,如果subDir不存在,则创建一个新subDir。最后,以subDir为路径,blockId的name属性为文件名,新建该文件。
文件创建完之后,那么Spark就会在DiskStore中向文件写与之映射的block:

override def putBytes(blockId: BlockId, _bytes: ByteBuffer, level: StorageLevel): PutResult = {
    val bytes = _bytes.duplicate()
    logDebug(s"Attempting to put block $blockId")
    val startTime = System.currentTimeMillis
    val file = diskManager.getFile(blockId)
    val channel = new FileOutputStream(file).getChannel
    Utils.tryWithSafeFinally {
        while (bytes.remaining > 0) {
            channel.write(bytes)
        }
    } {
        channel.close()
    }
    val finishTime = System.currentTimeMillis
    logDebug("Block %s stored as %s file on disk in %d ms".format(
            file.getName, Utils.bytesToString(bytes.limit), finishTime - startTime))
    PutResult(bytes.limit(), Right(bytes.duplicate()))
}

读取过程就简单了,DiskStore根据blockId读取与之映射的file内容,当然,这中间需要从DiskBlockManager中得到文件信息。

private def getBytes(file: File, offset: Long, length: Long): Option[ByteBuffer] = {
    val channel = new RandomAccessFile(file, "r").getChannel
    Utils.tryWithSafeFinally {
        // For small files, directly read rather than memory map
        if (length < minMemoryMapBytes) {
            val buf = ByteBuffer.allocate(length.toInt)
            channel.position(offset)
            while (buf.remaining() != 0) {
                if (channel.read(buf) == -1) {
                    throw new IOException("Reached EOF before filling buffer\n" +
                            s"offset=$offset\nfile=${file.getAbsolutePath}\nbuf.remaining=${buf.remaining}")
                }
            }
            buf.flip()
            Some(buf)
        } else {
            Some(channel.map(MapMode.READ_ONLY, offset, length))
        }
    } {
        channel.close()
    }
}

override def getBytes(blockId: BlockId): Option[ByteBuffer] = {
    val file = diskManager.getFile(blockId.name)
    getBytes(file, 0, file.length)
}

11.4.2Memory Store

相对Disk Store,Memory Store就显得容易很多。Memory Store用一个LinkedHashMap来管理,其中Key是blockId,Value是MemoryEntry样例类,MemoryEntry存储着数据信息。

private case class MemoryEntry(value: Any, size: Long, deserialized: Boolean)
private val entries = new LinkedHashMap[BlockId, MemoryEntry](32, 0.75f, true)

在MemoryStore中存储block的前提是当前内存有足够的空间存放。通过对tryToPut函数的调用对内存空间进行判断。

def putBytes(blockId: BlockId, size: Long, _bytes: () => ByteBuffer): PutResult = {
    // Work on a duplicate - since the original input might be used elsewhere.
    lazy val bytes = _bytes().duplicate().rewind().asInstanceOf[ByteBuffer]
    val putAttempt = tryToPut(blockId, () => bytes, size, deserialized = false)
    val data =
    if (putAttempt.success) {
        assert(bytes.limit == size)
        Right(bytes.duplicate())
    } else {
        null
    }
    PutResult(size, data, putAttempt.droppedBlocks)
}

在tryToPut函数中,通过调用enoughFreeSpace函数判断内存空间。如果内存空间足够,那么就把block放到LinkedHashMap中;如果内存不足,那么就告诉BlockManager内存不足,如果允许Disk Store,那么就把该block放到disk上。

private def tryToPut(blockId: BlockId, value: () => Any, size: Long, deserialized: Boolean): ResultWithDroppedBlocks = {
    var putSuccess = false
    val droppedBlocks = new ArrayBuffer[(BlockId, BlockStatus)]

    accountingLock.synchronized {
        val freeSpaceResult = ensureFreeSpace(blockId, size)
        val enoughFreeSpace = freeSpaceResult.success
        droppedBlocks ++= freeSpaceResult.droppedBlocks

        if (enoughFreeSpace) {
            val entry = new MemoryEntry(value(), size, deserialized)
            entries.synchronized {
                entries.put(blockId, entry)
                currentMemory += size
            }
            val valuesOrBytes = if (deserialized) "values" else "bytes"
            logInfo("Block %s stored as %s in memory (estimated size %s, free %s)".format(
                    blockId, valuesOrBytes, Utils.bytesToString(size), Utils.bytesToString(freeMemory)))
            putSuccess = true
        } else {
            lazy val data = if (deserialized) {
                Left(value().asInstanceOf[Array[Any]])
            } else {
                Right(value().asInstanceOf[ByteBuffer].duplicate())
            }
            val droppedBlockStatus = blockManager.dropFromMemory(blockId, () => data)
            droppedBlockStatus.foreach { status => droppedBlocks += ((blockId, status)) }
        }
        releasePendingUnrollMemoryForThisTask()
    }
    ResultWithDroppedBlocks(putSuccess, droppedBlocks)
}

Memory Store读取block也很简单,只需要从LinkedHashMap中取出blockId的Value即可。

override def getValues(blockId: BlockId): Option[Iterator[Any]] = {
    val entry = entries.synchronized {
        entries.get(blockId)
    }
    if (entry == null) {
        None
    } else if (entry.deserialized) {
        Some(entry.value.asInstanceOf[Array[Any]].iterator)
    } else {
        val buffer = entry.value.asInstanceOf[ByteBuffer].duplicate() // Doesn't actually copy data
        Some(blockManager.dataDeserialize(blockId, buffer))
    }
}

11.5数据写入过程分析

大数据学习笔记之Spark(六):Spark内核解析_第163张图片
数据写入的简要流程
1)RDD.iterator是与storage子系统交互的入口
2)CacheManager.getOrCompute调用BlockManager的put接口来写入数据
3)数据优先写入到MemoryStore即内存,如果MemoryStore中的数据已满则将最近使用次数不频繁的数据写入到磁盘
4)通知BlockManagerMaster有新的数据写入,在BlockManagerMaster中保存元数据
5)将写入的数据与其它slave worker进行同步,一般来说在本机写入的数据,都会另先一台机器来进行数据的备份,即replicanumber=1
其实,我们在put和get block的时候并没有那么复杂,前面的细节BlockManager都包装好了,我们只需要调用BlockManager中的put和get函数即可。

def putBytes(
           blockId: BlockId,
           bytes: ByteBuffer,
           level: StorageLevel,
           tellMaster: Boolean = true,
           effectiveStorageLevel: Option[StorageLevel] = None): Seq[(BlockId, BlockStatus)] = {
       require(bytes != null, "Bytes is null")
       doPut(blockId, ByteBufferValues(bytes), level, tellMaster, effectiveStorageLevel)
   }
   private def doPut(
           blockId: BlockId,
           data: BlockValues,
           level: StorageLevel,
           tellMaster: Boolean = true,
           effectiveStorageLevel: Option[StorageLevel] = None)
: Seq[(BlockId, BlockStatus)] = {

       require(blockId != null, "BlockId is null")
       require(level != null && level.isValid, "StorageLevel is null or invalid")
       effectiveStorageLevel.foreach { level =>
           require(level != null && level.isValid, "Effective StorageLevel is null or invalid")
       }

       val updatedBlocks = new ArrayBuffer[(BlockId, BlockStatus)]

       val putBlockInfo = {
               val tinfo = new BlockInfo(level, tellMaster)
               val oldBlockOpt = blockInfo.putIfAbsent(blockId, tinfo)
       if (oldBlockOpt.isDefined) {
           if (oldBlockOpt.get.waitForReady()) {
               logWarning(s"Block $blockId already exists on this machine; not re-adding it")
               return updatedBlocks
           }
           oldBlockOpt.get
       } else {
           tinfo
       }
}

       val startTimeMs = System.currentTimeMillis

       var valuesAfterPut: Iterator[Any] = null

       var bytesAfterPut: ByteBuffer = null

       var size = 0L

       val putLevel = effectiveStorageLevel.getOrElse(level)

       val replicationFuture = data match {
           case b: ByteBufferValues if putLevel.replication > 1 =>
               // Duplicate doesn't copy the bytes, but just creates a wrapper
               val bufferView = b.buffer.duplicate()
               Future {
               replicate(blockId, bufferView, putLevel)
           }(futureExecutionContext)
           case _ => null
       }

       putBlockInfo.synchronized {
           logTrace("Put for block %s took %s to get into synchronized block"
                   .format(blockId, Utils.getUsedTimeMs(startTimeMs)))

           var marked = false
           try {
               val (returnValues, blockStore: BlockStore) = {
                   if (putLevel.useMemory) {
                       (true, memoryStore)
                   } else if (putLevel.useOffHeap) {
                       (false, externalBlockStore)
                   } else if (putLevel.useDisk) {
                       (putLevel.replication > 1, diskStore)
                   } else {
                       assert(putLevel == StorageLevel.NONE)
                       throw new BlockException(
                               blockId, s"Attempted to put block $blockId without specifying storage level!")
                   }
               }

               val result = data match {
                   case IteratorValues(iterator) =>
                       blockStore.putIterator(blockId, iterator, putLevel, returnValues)
                   case ArrayValues(array) =>
                       blockStore.putArray(blockId, array, putLevel, returnValues)
                   case ByteBufferValues(bytes) =>
                       bytes.rewind()
                       blockStore.putBytes(blockId, bytes, putLevel)
               }
               size = result.size
               result.data match {
                   case Left (newIterator) if putLevel.useMemory => valuesAfterPut = newIterator
                   case Right (newBytes) => bytesAfterPut = newBytes
                   case _ =>
               }

               if (putLevel.useMemory) {
                   result.droppedBlocks.foreach { updatedBlocks += _ }
               }

               val putBlockStatus = getCurrentBlockStatus(blockId, putBlockInfo)
               if (putBlockStatus.storageLevel != StorageLevel.NONE) {
                   marked = true
                   putBlockInfo.markReady(size)
                   if (tellMaster) {
                       reportBlockStatus(blockId, putBlockInfo, putBlockStatus)
                   }
                   updatedBlocks += ((blockId, putBlockStatus))
               }
           } finally {
               if (!marked) {
                   blockInfo.remove(blockId)
                   putBlockInfo.markFailure()
                   logWarning(s"Putting block $blockId failed")
               }
           }
       }
       logDebug("Put block %s locally took %s".format(blockId, Utils.getUsedTimeMs(startTimeMs)))

       if (putLevel.replication > 1) {
           data match {
               case ByteBufferValues(bytes) =>
                   if (replicationFuture != null) {
                       Await.ready(replicationFuture, Duration.Inf)
                   }
               case _ =>
                   val remoteStartTime = System.currentTimeMillis
                   if (bytesAfterPut == null) {
                       if (valuesAfterPut == null) {
                           throw new SparkException(
                                   "Underlying put returned neither an Iterator nor bytes! This shouldn't happen.")
                       }
                       bytesAfterPut = dataSerialize(blockId, valuesAfterPut)
                   }
                   replicate(blockId, bytesAfterPut, putLevel)
                   logDebug("Put block %s remotely took %s"
                           .format(blockId, Utils.getUsedTimeMs(remoteStartTime)))
           }
       }

       BlockManager.dispose(bytesAfterPut)

       if (putLevel.replication > 1) {
           logDebug("Putting block %s with replication took %s"
                   .format(blockId, Utils.getUsedTimeMs(startTimeMs)))
       } else {
           logDebug("Putting block %s without replication took %s"
                   .format(blockId, Utils.getUsedTimeMs(startTimeMs)))
       }

       updatedBlocks
   }
对于doPut函数,主要做了以下几个操作

创建BlockInfo对象存储block信息;
将BlockInfo加锁,然后根据Storage Level判断存储到Memory还是Disk。同时,对于已经准备好读的BlockInfo要进行解锁。
根据block的副本数量决定是否向远程发送副本。

11.5.1序列化与否

写入的具体内容可以是序列化之后的bytes也可以是没有序列化的value. 此处有一个对scala的语法中Either, Left, Right关键字的理解。

11.6数据读取过程分析

def get(blockId: BlockId): Option[Iterator[Any]] = {
    val local = getLocal(blockId)
    if (local.isDefined) {
        logInfo("Found block %s locally".format(blockId))
        return local
    }
    val remote = getRemote(blockId)
    if (remote.isDefined) {
        logInfo("Found block %s remotely".format(blockId))
        return remote
    }
    None
}

11.6.1本地读取

首先在查询本机的MemoryStore和DiskStore中是否有所需要的block数据存在,如果没有则发起远程数据获取。
11.6.2远程读取
远程获取调用路径, getRemote->doGetRemote, 在doGetRemote中最主要的就是调用BlockManagerWorker.syncGetBlock来从远程获得数据

def syncGetBlock(msg: GetBlock, toConnManagerId: ConnectionManagerId): ByteBuffer = {
    val blockManager = blockManagerWorker.blockManager
    val connectionManager = blockManager.connectionManager
    val blockMessage = BlockMessage.fromGetBlock(msg)
    val blockMessageArray = new BlockMessageArray(blockMessage)
    val responseMessage = connectionManager.sendMessageReliablySync(
            toConnManagerId, blockMessageArray.toBufferMessage)
    responseMessage match {
        case Some(message) => {
            val bufferMessage = message.asInstanceOf[BufferMessage]
            logDebug("Response message received " + bufferMessage)
            BlockMessageArray.fromBufferMessage(bufferMessage).foreach(blockMessage => {
                    logDebug("Found " + blockMessage)
            return blockMessage.getData
      })
        }
        case None => logDebug("No response message received")
    }
    null
}

上述这段代码中最有意思的莫过于sendMessageReliablySync,远程数据读取毫无疑问是一个异步i/o操作,这里的代码怎么写起来就像是在进行同步的操作一样呢。也就是说如何知道对方发送回来响应的呢?
别急,继续去看看sendMessageReliablySync的定义

def sendMessageReliably(connectionManagerId: ConnectionManagerId, message: Message)
  : Future[Option[Message]] = {
    val promise = Promise[Option[Message]]
    val status = new MessageStatus(
            message, connectionManagerId, s => promise.success(s.ackMessage))
    messageStatuses.synchronized {
        messageStatuses += ((message.id, status))
    }
    sendMessage(connectionManagerId, message)
    promise.future
}

要是我说秘密在这里,你肯定会说我在扯淡,但确实在此处。注意到关键字Promise和Future没。
如果这个future执行完毕,返回s.ackMessage。我们再看看这个ackMessage是在什么地方被写入的呢。看一看ConnectionManager.handleMessage中的代码片段

case bufferMessage: BufferMessage =>

{
    if (authEnabled) {
        val res = handleAuthentication(connection, bufferMessage)
        if (res == true) {
            // message was security negotiation so skip the rest
            logDebug("After handleAuth result was true, returning")
            return
        }
    }
    if (bufferMessage.hasAckId) {
        val sentMessageStatus = messageStatuses. synchronized {
            messageStatuses.get(bufferMessage.ackId) match {
                case Some(status) =>{
                    messageStatuses -= bufferMessage.ackId
                    status
                }
                case None =>{
                    throw new Exception("Could not find reference for received ack message " +
                            message.id)
                    null
                }
            }
        }
        sentMessageStatus. synchronized {
            sentMessageStatus.ackMessage = Some(message)
            sentMessageStatus.attempted = true
            sentMessageStatus.acked = true
            sentMessageStaus.markDone()
        }
    }
}

注意,此处的所调用的sentMessageStatus.markDone就会调用在sendMessageReliablySync中定义的promise.Success. 不妨看看MessageStatus的定义。

class MessageStatus(
val message: Message,
val connectionManagerId: ConnectionManagerId,
completionHandler: MessageStatus => Unit) {

    var ackMessage: Option[Message] = None
    var attempted = false
    var acked = false

    def markDone() { completionHandler(this) }
}

11.7Partition如何转化为Block

在storage模块里面所有的操作都是和block相关的,但是在RDD里面所有的运算都是基于partition的,那么partition是如何与block对应上的呢?
RDD计算的核心函数是iterator()函数:

final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
    if (storageLevel != StorageLevel.NONE) {
        SparkEnv.get.cacheManager.getOrCompute(this, split, context, storageLevel)
    } else {
        computeOrReadCheckpoint(split, context)
    }
}

如果当前RDD的storage level不是NONE的话,表示该RDD在BlockManager中有存储,那么调用CacheManager中的getOrCompute()函数计算RDD,在这个函数中partition和block发生了关系:
首先根据RDD id和partition index构造出block id (rdd_xx_xx),接着从BlockManager中取出相应的block。
如果该block存在,表示此RDD在之前已经被计算过和存储在BlockManager中,因此取出即可,无需再重新计算。
如果该block不存在则需要调用RDD的computeOrReadCheckpoint()函数计算出新的block,并将其存储到BlockManager中。
需要注意的是block的计算和存储是阻塞的,若另一线程也需要用到此block则需等到该线程block的loading结束。

def getOrCompute[T](rdd:RDD[T],split:Partition,context:TaskContext,storageLevel:StorageLevel):Iterator[T]=
{
    val key = "rdd_%d_%d".format(rdd.id, split.index)
    logDebug("Looking for partition " + key)
    blockManager.get(key) match {
    case Some(values) =>
        // Partition is already materialized, so just return its values
        return values.asInstanceOf[Iterator[T]]

    case None =>
        // Mark the split as loading (unless someone else marks it first)
        loading. synchronized {
        if (loading.contains(key)) {
            logInfo("Another thread is loading %s, waiting for it to finish...".format(key))
            while (loading.contains(key)) {
                try {
                    loading.wait()
                } catch {
                    case _:
                        Throwable =>}
            }
            logInfo("Finished waiting for %s".format(key))
            // See whether someone else has successfully loaded it. The main way this would fail
            // is for the RDD-level cache eviction policy if someone else has loaded the same RDD
            // partition but we didn't want to make space for it. However, that case is unlikely
            // because it's unlikely that two threads would work on the same RDD partition. One
            // downside of the current code is that threads wait serially if this does happen.
            blockManager.get(key) match {
                case Some(values) =>
                    return values.asInstanceOf[Iterator[T]]
                case None =>
                    logInfo("Whoever was loading %s failed; we'll try it ourselves".format(key))
                    loading.add(key)
            }
        } else {
            loading.add(key)
        }
    }
    try {
        // If we got here, we have to load the split
        logInfo("Partition %s not found, computing it".format(key))
        val computedValues = rdd.computeOrReadCheckpoint(split, context)
        // Persist the result, so long as the task is not running locally
        if (context.runningLocally) {
            return computedValues
        }
        val elements = new ArrayBuffer[Any]
        elements++ = computedValues
        blockManager.put(key, elements, storageLevel, true)
        return elements.iterator.asInstanceOf[Iterator[T]]
    } finally {
        loading. synchronized {
            loading.remove(key)
            loading.notifyAll()
        }
    }
}

这样RDD的transformation、action就和block数据建立了联系,虽然抽象上我们的操作是在partition层面上进行的,但是partition最终还是被映射成为block,因此实际上我们的所有操作都是对block的处理和存取。

11.8partition和block的对应关系

在RDD中,核心的函数是iterator:

final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
    if (storageLevel != StorageLevel.NONE) {
        SparkEnv.get.cacheManager.getOrCompute(this, split, context, storageLevel)
    } else {
        computeOrReadCheckpoint(split, context)
    }
}

如果当前RDD的storage level不是NONE的话,表示该RDD在BlockManager中有存储,那么调用CacheManager中的getOrCompute函数计算RDD,在这个函数中partition和block就对应起来了:
getOrCompute函数会先构造RDDBlockId,其中RDDBlockId就把block和partition联系起来了,RDDBlockId产生的name就是BlockId的name属性,形式是:rdd_rdd.id_partition.index。

def getOrCompute[T](
rdd: RDD[T],
partition: Partition,
context: TaskContext,
storageLevel: StorageLevel): Iterator[T] = {

    val key = RDDBlockId(rdd.id, partition.index)
    logDebug(s"Looking for partition $key")
    blockManager.get(key) match {
        case Some(blockResult) =>
            val existingMetrics = context.taskMetrics
                    .getInputMetricsForReadMethod(blockResult.readMethod)
            existingMetrics.incBytesRead(blockResult.bytes)

            val iter = blockResult.data.asInstanceOf[Iterator[T]]
            new InterruptibleIterator[T](context, iter) {
            override def next(): T = {
                    existingMetrics.incRecordsRead(1)
                    delegate.next()
            }
        }
        case None =>
            val storedValues = acquireLockForPartition[T](key)
            if (storedValues.isDefined) {
                return new InterruptibleIterator[T](context, storedValues.get)
            }

            try {
                logInfo(s"Partition $key not found, computing it")
                val computedValues = rdd.computeOrReadCheckpoint(partition, context)

                if (context.isRunningLocally) {
                    return computedValues
                }

                val updatedBlocks = new ArrayBuffer[(BlockId, BlockStatus)]
                val cachedValues = putInBlockManager(key, computedValues, storageLevel, updatedBlocks)
                val metrics = context.taskMetrics
                val lastUpdatedBlocks = metrics.updatedBlocks.getOrElse(Seq[(BlockId, BlockStatus)]())
                metrics.updatedBlocks = Some(lastUpdatedBlocks ++ updatedBlocks.toSeq)
                new InterruptibleIterator(context, cachedValues)

            } finally {
                loading.synchronized {
                    loading.remove(key)
                    loading.notifyAll()
                }
            }
    }
}

同时getOrCompute函数会对block进行判断:
如果该block存在,表示此RDD在之前已经被计算过和存储在BlockManager中,因此取出即可,无需再重新计算。
如果该block不存在则需要调用RDD的computeOrReadCheckpoint()函数计算出新的block,并将其存储到BlockManager中。
需要注意的是block的计算和存储是阻塞的,若另一线程也需要用到此block则需等到该线程block的loading结束。

第12章Spark Shuffle过程(重点看)

12.1MapReduce的Shuffle过程介绍

Shuffle的本义是洗牌、混洗,把一组有一定规则的数据尽量转换成一组无规则的数据,越随机越好。MapReduce中的Shuffle更像是洗牌的逆过程,把一组无规则的数据尽量转换成一组具有一定规则的数据。
为什么MapReduce计算模型需要Shuffle过程?我们都知道MapReduce计算模型一般包括两个重要的阶段:Map是映射,负责数据的过滤分发;Reduce是规约,负责数据的计算归并。Reduce的数据来源于Map,Map的输出即是Reduce的输入,Reduce需要通过Shuffle来获取数据。
从Map输出到Reduce输入的整个过程可以广义地称为Shuffle。Shuffle横跨Map端和Reduce端,在Map端包括Spill过程,在Reduce端包括copy和sort过程,如图所示:
大数据学习笔记之Spark(六):Spark内核解析_第164张图片
其中shuffle主要包括的过程是
大数据学习笔记之Spark(六):Spark内核解析_第165张图片
即spill copy sort

12.1.1Spill过程

Spill过程包括输出、排序、溢写、合并等步骤,如图所示:
大数据学习笔记之Spark(六):Spark内核解析_第166张图片
大数据学习笔记之Spark(六):Spark内核解析_第167张图片
Map的输出会不断的写入到kvbuffer这样一个环形缓存区里面,在写的时候,看下面
写写写,一直写到里面还剩下20%内存的时候,这个时候启动线程之后,如果再有数据进来,就不再写了
大数据学习笔记之Spark(六):Spark内核解析_第168张图片
如上图,看第二个圆圈,写到剩下20%,就在剩下的20%的中间,重新开个口,即图中的equator,然后数据信息还是往里面写,如上的黄色箭头,kvmeta信息也还是往里面写,如上的蓝色箭头。这个时候因为上面的80%都不动了,都不动之后,启动一个sortAndSpilt现成,先对kvmeta进行排序,因为他是索引信息,所以他的排序并不涉及到kv具体数据的移动,只是移动了他的索引信息,排序完了之后,对于每一次spill都会有两个文件
大数据学习笔记之Spark(六):Spark内核解析_第169张图片
一个叫spill文件,一个叫out文件,index是一个索引文件,out是直接存储数据的文件
因为在index里面保存了partition的一个偏移值,所以如果直接访问index文件,可以直接通过偏移值,获取到具体信息的
在每次spill的时候都会生成这两个文件,因为可能在MapReduce的过程中要spill多次,这个时候就会生成很多spill index 和 out文件
spill完成之后再shuffle过程中还有一个merge过程,如上有三个小三角的图片,就会将spill过程的索引文件和具体数据文件合并到一个大文件里面,这个大文件里面是partition有序的,然后这个大文件还有一个相对应的索引文件 。

这就是mapreduce的shuffle过程

12.1.1.1 Collect

每个Map任务不断地以对的形式把数据输出到在内存中构造的一个环形数据结构中。使用环形数据结构是为了更有效地使用内存空间,在内存中放置尽可能多的数据。
这个数据结构其实就是个字节数组,叫Kvbuffer,名如其义,但是这里面不光放置了数据,还放置了一些索引数据,给放置索引数据的区域起了一个Kvmeta的别名,在Kvbuffer的一块区域上穿了一个IntBuffer(字节序采用的是平台自身的字节序)的马甲。数据区域和索引数据区域在Kvbuffer中是相邻不重叠的两个区域,用一个分界点来划分两者,分界点不是亘古不变的,而是每次Spill之后都会更新一次。初始的分界点是0,数据的存储方向是向上增长,索引数据的存储方向是向下增长,如图所示:
大数据学习笔记之Spark(六):Spark内核解析_第170张图片
在这里插入图片描述
写的时候带上kvmeta元数据信息(里面的分区等信息)、kv真正的数据信息

Kvbuffer的存放指针bufindex是一直闷着头地向上增长,比如bufindex初始值为0,一个Int型的key写完之后,bufindex增长为4,一个Int型的value写完之后,bufindex增长为8。
索引是对在kvbuffer中的索引,是个四元组,包括:value的起始位置、key的起始位置、partition值、value的长度,占用四个Int长度,Kvmeta的存放指针Kvindex每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组的数据。比如Kvindex初始位置是-4,当第一个写完之后,(Kvindex+0)的位置存放value的起始位置、(Kvindex+1)的位置存放key的起始位置、(Kvindex+2)的位置存放partition的值、(Kvindex+3)的位置存放value的长度,然后Kvindex跳到-8位置,等第二个和索引写完之后,Kvindex跳到-32位置。
Kvbuffer的大小虽然可以通过参数设置,但是总共就那么大,和索引不断地增加,加着加着,Kvbuffer总有不够用的那天,那怎么办?把数据从内存刷到磁盘上再接着往内存写数据,把Kvbuffer中的数据刷到磁盘上的过程就叫Spill,多么明了的叫法,内存中的数据满了就自动地spill到具有更大空间的磁盘。
关于Spill触发的条件,也就是Kvbuffer用到什么程度开始Spill,还是要讲究一下的。如果把Kvbuffer用得死死得,一点缝都不剩的时候再开始Spill,那Map任务就需要等Spill完成腾出空间之后才能继续写数据;如果Kvbuffer只是满到一定程度,比如80%的时候就开始Spill,那在Spill的同时,Map任务还能继续写数据,如果Spill够快,Map可能都不需要为空闲空间而发愁。两利相衡取其大,一般选择后者。
Spill这个重要的过程是由Spill线程承担,Spill线程从Map任务接到“命令”之后就开始正式干活,干的活叫SortAndSpill,原来不仅仅是Spill,在Spill之前还有个颇具争议性的Sort。

12.1.1.2 Sort

先把Kvbuffer中的数据按照partition值和key两个关键字升序排序,移动的只是索引数据,排序结果是Kvmeta中数据按照partition为单位聚集在一起,同一partition内的按照key有序。

12.1.1.3 Spill

Spill线程为这次Spill过程创建一个磁盘文件:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out”的文件。Spill线程根据排过序的Kvmeta挨个partition的把数据吐到这个文件中,一个partition对应的数据吐完之后顺序地吐下个partition,直到把所有的partition遍历完。一个partition在文件中对应的数据也叫段(segment)。
所有的partition对应的数据都放在这个文件里,虽然是顺序存放的,但是怎么直接知道某个partition在这个文件中存放的起始位置呢?强大的索引又出场了。有一个三元组记录某个partition对应的数据在这个文件中的索引:起始位置、原始数据长度、压缩之后的数据长度,一个partition对应一个三元组。然后把这些索引信息存放在内存中,如果内存中放不下了,后续的索引信息就需要写到磁盘文件中了:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out.index”的文件,文件中不光存储了索引数据,还存储了crc32的校验数据。(spill12.out.index不一定在磁盘上创建,如果内存(默认1M空间)中能放得下就放在内存中,即使在磁盘上创建了,和spill12.out文件也不一定在同一个目录下。)
每一次Spill过程就会最少生成一个out文件,有时还会生成index文件,Spill的次数也烙印在文件名中。索引文件和数据文件的对应关系如下图所示:
在这里插入图片描述
在Spill线程如火如荼的进行SortAndSpill工作的同时,Map任务不会因此而停歇,而是一无既往地进行着数据输出。Map还是把数据写到kvbuffer中,那问题就来了:只顾着闷头按照bufindex指针向上增长,kvmeta只顾着按照Kvindex向下增长,是保持指针起始位置不变继续跑呢,还是另谋它路?如果保持指针起始位置不变,很快bufindex和Kvindex就碰头了,碰头之后再重新开始或者移动内存都比较麻烦,不可取。Map取kvbuffer中剩余空间的中间位置,用这个位置设置为新的分界点,bufindex指针移动到这个分界点,Kvindex移动到这个分界点的-16位置,然后两者就可以和谐地按照自己既定的轨迹放置数据了,当Spill完成,空间腾出之后,不需要做任何改动继续前进。分界点的转换如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第171张图片
Map任务总要把输出的数据写到磁盘上,即使输出数据量很小在内存中全部能装得下,在最后也会把数据刷到磁盘上。

12.1.2Merge

大数据学习笔记之Spark(六):Spark内核解析_第172张图片
Map任务如果输出数据量很大,可能会进行好几次Spill,out文件和Index文件会产生很多,分布在不同的磁盘上。最后把这些文件进行合并的merge过程闪亮登场。
Merge过程怎么知道产生的Spill文件都在哪了呢?从所有的本地目录上扫描得到产生的Spill文件,然后把路径存储在一个数组里。Merge过程又怎么知道Spill的索引信息呢?没错,也是从所有的本地目录上扫描得到Index文件,然后把索引信息存储在一个列表里。到这里,又遇到了一个值得纳闷的地方。在之前Spill过程中的时候为什么不直接把这些信息存储在内存中呢,何必又多了这步扫描的操作?特别是Spill的索引数据,之前当内存超限之后就把数据写到磁盘,现在又要从磁盘把这些数据读出来,还是需要装到更多的内存中。之所以多此一举,是因为这时kvbuffer这个内存大户已经不再使用可以回收,有内存空间来装这些数据了。(对于内存空间较大的土豪来说,用内存来省却这两个io步骤还是值得考虑的。)
然后为merge过程创建一个叫file.out的文件和一个叫file.out.Index的文件用来存储最终的输出和索引。
一个partition一个partition的进行合并输出。对于某个partition来说,从索引列表中查询这个partition对应的所有索引信息,每个对应一个段插入到段列表中。也就是这个partition对应一个段列表,记录所有的Spill文件中对应的这个partition那段数据的文件名、起始位置、长度等等。
然后对这个partition对应的所有的segment进行合并,目标是合并成一个segment。当这个partition对应很多个segment时,会分批地进行合并:先从segment列表中把第一批取出来,以key为关键字放置成最小堆,然后从最小堆中每次取出最小的输出到一个临时文件中,这样就把这一批段合并成一个临时的段,把它加回到segment列表中;再从segment列表中把第二批取出来合并输出到一个临时segment,把其加入到列表中;这样往复执行,直到剩下的段是一批,输出到最终的文件中。
最终的索引数据仍然输出到Index文件中。
Map端的Shuffle过程到此结束。

12.1.3Copy

大数据学习笔记之Spark(六):Spark内核解析_第173张图片
在这里插入图片描述
上面的map阶段完成了,就到了reduce,并不是copy完成了才会去sort,是一边去copy,一边去sort,在这里的sort,因为前面的map过程里面的已经是partition有序了,所以这次只是对前面的相同的做一次快速排序,就像都是1的,所有的1里面都是有序的了,这就保证了最终的文件是有序的,然后交给reduce去处理。

到就完成了mapreduce的shuffle过程。

Reduce任务通过HTTP向各个Map任务拖取它所需要的数据。每个节点都会启动一个常驻的HTTP server,其中一项服务就是响应Reduce拖取Map数据。当有MapOutput的HTTP请求过来的时候,HTTP server就读取相应的Map输出文件中对应这个Reduce部分的数据通过网络流输出给Reduce。
Reduce任务拖取某个Map对应的数据,如果在内存中能放得下这次数据的话就直接把数据写到内存中。Reduce要向每个Map去拖取数据,在内存中每个Map对应一块数据,当内存中存储的Map数据占用空间达到一定程度的时候,开始启动内存中merge,把内存中的数据merge输出到磁盘上一个文件中。
如果在内存中不能放得下这个Map的数据的话,直接把Map数据写到磁盘上,在本地目录创建一个文件,从HTTP流中读取数据然后写到磁盘,使用的缓存区大小是64K。拖一个Map数据过来就会创建一个文件,当文件数量达到一定阈值时,开始启动磁盘文件merge,把这些文件合并输出到一个文件。
有些Map的数据较小是可以放在内存中的,有些Map的数据较大需要放在磁盘上,这样最后Reduce任务拖过来的数据有些放在内存中了有些放在磁盘上,最后会对这些来一个全局合并。

12.1.4Merge Sort

这里使用的Merge和Map端使用的Merge过程一样。Map的输出数据已经是有序的,Merge进行一次合并排序,所谓Reduce端的sort过程就是这个合并的过程。一般Reduce是一边copy一边sort,即copy和sort两个阶段是重叠而不是完全分开的。
Reduce端的Shuffle过程至此结束。

12.2 Spark的Shuffle过程介绍 - HashShuffle

大数据学习笔记之Spark(六):Spark内核解析_第174张图片
每一个tadk根据reduce数的不同,都会有一个bucket,一个bucket对应了一个reduce任务,所以对于一个task,比如当前有三个reduce任务,有三个maptask,对于每个task来说,都会对reduce生成一个bucket,bucket是内存里面的缓存区,然后他会根据输出的信息到底是存在哪个缓存区里面,经过hash这种方式,确认了bucket之后,直接把数据输出给bucket,这个bucket输出之后,会把这个数据写到文件,这个文件叫做blockfile,所以如果这里面有两个task,有三个reduce,就会生成六个文件,在每个文件里面,就是一个partition所有的数据,这个时候read就从map的输出文件把需要读的文件全部读过来,在这里是没有kv buffer缓冲区的,是直接写到文件里面。
map任务会为每个reduce创建对应的bucket,这个bucket是一个内存缓冲区,通过不同的partition通过hash方式,得到bucketId然后填充到相应的bucket上面去,对于每个任务来说,如果有r个reduce,就会创建r个bucket,这个时候Map个Map总共会创建M*r个bucket
在HashShuffle的过程中,最大的弊端就是文件生成过多,首先文件打开数是一个问题,然后文件的随机读写是一个问题,因为每一个都是一个小文件,小文件特别多,如果任务数特别大的时候,文件就会特别多,就会导致性能非常低,对于这个过程,就有了hashshuffle的改进
大数据学习笔记之Spark(六):Spark内核解析_第175张图片
consolidation Shuffle 这个是一个配置,如果把这个配置为true,HashShuffle的过程就会变成如上的图示,有一个新的概念ShuffleFIleGroup,如果上面的两个task是一前一后调度的,假如左边的task先调度,那么会生成三个bucket,这个时候如果这个任务运行完了,然后bucket相应的东西也写到了shuffleFile里面,但是如果这个时候,前面的map任务运行完了,再启动一个task的时候,后面的task会复用刚才的三个bucket,所以第二个任务的东西还是会写到shuffleFile文件里面,但是有另外一种情况,就是假如core去调度第二个task,但是前面的哪个task还在运行,所以对下面的shuffleFile的控制权还没有回收,所以第二个task会像之前的task一样新建三个文件,也就是说不复用了新的东西还是写到了不同的文件里面。
如果task调度的好的话,假如现在有50个maptask,有3个reduce,就会产生50个输出文件,因为会把所有的输出文件都合并到一块,将来在read的时候,对于每一个executor里面的数据,就会直接根据索引文件去读整个的shuffleFile。
这就是hashShuffle的改进,即在文件的数量上缩小了很多。

Spark丰富了任务类型,有些任务之间数据流转不需要通过Shuffle,但是有些任务之间还是需要通过Shuffle来传递数据,比如wide dependency的group by key。
Spark中需要Shuffle输出的Map任务会为每个Reduce创建对应的bucket,Map产生的结果会根据设置的partitioner得到对应的bucketId,然后填充到相应的bucket中去。每个Map的输出结果可能包含所有的Reduce所需要的数据,所以每个Map会创建R个bucket(R是reduce的个数),M个Map总共会创建M*R个bucket。
Map创建的bucket其实对应磁盘上的一个文件,Map的结果写到每个bucket中其实就是写到那个磁盘文件中,这个文件也被称为blockFile,是Disk Block Manager管理器通过文件名的Hash值对应到本地目录的子目录中创建的。每个Map要在节点上创建R个磁盘文件用于结果输出,Map的结果是直接输出到磁盘文件上的,100KB的内存缓冲是用来创建Fast Buffered OutputStream输出流。这种方式一个问题就是Shuffle文件过多。
大数据学习笔记之Spark(六):Spark内核解析_第176张图片
1)每一个Mapper创建出和Reducer数目相同的bucket,bucket实际上是一个buffer,其大小为spark.shuffle.file.buffer.kb(默认32KB)。
2)Mapper产生的结果会根据设置的partition算法填充到每个bucket中去,然后再写入到磁盘文件。
3)Reducer从远端或是本地的block manager中找到相应的文件读取数据。
针对上述Shuffle过程产生的文件过多问题,Spark有另外一种改进的Shuffle过程:consolidation Shuffle,以期显著减少Shuffle文件的数量。在consolidation Shuffle中每个bucket并非对应一个文件,而是对应文件中的一个segment部分。Job的map在某个节点上第一次执行,为每个reduce创建bucket对应的输出文件,把这些文件组织成ShuffleFileGroup,当这次map执行完之后,这个ShuffleFileGroup可以释放为下次循环利用;当又有map在这个节点上执行时,不需要创建新的bucket文件,而是在上次的ShuffleFileGroup中取得已经创建的文件继续追加写一个segment;当前次map还没执行完,ShuffleFileGroup还没有释放,这时如果有新的map在这个节点上执行,无法循环利用这个ShuffleFileGroup,而是只能创建新的bucket文件组成新的ShuffleFileGroup来写输出。
大数据学习笔记之Spark(六):Spark内核解析_第177张图片
比如一个Job有3个Map和2个reduce:(1) 如果此时集群有3个节点有空槽,每个节点空闲了一个core,则3个Map会调度到这3个节点上执行,每个Map都会创建2个Shuffle文件,总共创建6个Shuffle文件;(2) 如果此时集群有2个节点有空槽,每个节点空闲了一个core,则2个Map先调度到这2个节点上执行,每个Map都会创建2个Shuffle文件,然后其中一个节点执行完Map之后又调度执行另一个Map,则这个Map不会创建新的Shuffle文件,而是把结果输出追加到之前Map创建的Shuffle文件中;总共创建4个Shuffle文件;(3) 如果此时集群有2个节点有空槽,一个节点有2个空core一个节点有1个空core,则一个节点调度2个Map一个节点调度1个Map,调度2个Map的节点上,一个Map创建了Shuffle文件,后面的Map还是会创建新的Shuffle文件,因为上一个Map还正在写,它创建的ShuffleFileGroup还没有释放;总共创建6个Shuffle文件。
优点
1)快-不需要排序,也不需要维持hash表
2)不需要额外空间用作排序
3)不需要额外IO-数据写入磁盘只需一次,读取也只需一次
缺点
1)当partitions大时,输出大量的文件(cores * R),性能开始降低
2)大量的文件写入,使文件系统开始变为随机写,性能比顺序写要降低100倍
3)缓存空间占用比较大
Reduce去拖Map的输出数据,Spark提供了两套不同的拉取数据框架:通过socket连接去取数据;使用netty框架去取数据。
每个节点的Executor会创建一个BlockManager,其中会创建一个BlockManagerWorker用于响应请求。当Reduce的GET_BLOCK的请求过来时,读取本地文件将这个blockId的数据返回给Reduce。如果使用的是Netty框架,BlockManager会创建ShuffleSender用于发送Shuffle数据。
并不是所有的数据都是通过网络读取,对于在本节点的Map数据,Reduce直接去磁盘上读取而不再通过网络框架。
Reduce拖过来数据之后以什么方式存储呢?Spark Map输出的数据没有经过排序,Spark Shuffle过来的数据也不会进行排序,Spark认为Shuffle过程中的排序不是必须的,并不是所有类型的Reduce需要的数据都需要排序,强制地进行排序只会增加Shuffle的负担。Reduce拖过来的数据会放在一个HashMap中,HashMap中存储的也是对,key是Map输出的key,Map输出对应这个key的所有value组成HashMap的value。Spark将Shuffle取过来的每一个对插入或者更新到HashMap中,来一个处理一个。HashMap全部放在内存中。
Shuffle取过来的数据全部存放在内存中,对于数据量比较小或者已经在Map端做过合并处理的Shuffle数据,占用内存空间不会太大,但是对于比如group by key这样的操作,Reduce需要得到key对应的所有value,并将这些value组一个数组放在内存中,这样当数据量较大时,就需要较多内存。
当内存不够时,要不就失败,要不就用老办法把内存中的数据移到磁盘上放着。Spark意识到在处理数据规模远远大于内存空间时所带来的不足,引入了一个具有外部排序的方案。Shuffle过来的数据先放在内存中,当内存中存储的对超过1000并且内存使用超过70%时,判断节点上可用内存如果还足够,则把内存缓冲区大小翻倍,如果可用内存不再够了,则把内存中的对排序然后写到磁盘文件中。最后把内存缓冲区中的数据排序之后和那些磁盘文件组成一个最小堆,每次从最小堆中读取最小的数据,这个和MapReduce中的merge过程类似。

12.3Spark的Shuffle过程介绍 - SortShuffle


sortShuffle和MapReduce的shuffle非常的相似,它用了一个AppendOnlyMap ,也会用到sort spill ,然后把数据写到OutputFile

sortShuffle也有两种方式,一种叫传统SortShuffle,一种叫ByPassSortShuffle。
传统SortShuffle还是从内存缓冲写到磁盘文件,然后对磁盘文件进行合并,同样有一个索引文件。 这个过程和MapReduce是非常类似的
ByPassSortShuffle,当reduce的数量比较少的时候,hashShuffle的效率是要比sortShuffle高的,所以在spark里面有一个ByPass参数,用来设置reduce的数量,如果reduce的数量少于ByPass的数量的时候,就会默认启动ByPassSortShuffle ,这种模式在内存缓冲到磁盘文件的时候,就不会通过AppendOnlyMap,而是直接类似于hashshuffle直接输出文件,而不会走sort spill这些过程,输出文件后也会有索引文件和文件聚合。

在现在的spark中,默认用的是sortshuffle,如果想用hashshuffle的时候,最好把hashshuffle的改进打开

从1.2.0开始默认为sort shuffle(spark.shuffle.manager = sort),实现逻辑类似于Hadoop MapReduce,Hash Shuffle每一个reducers产生一个文件,但是Sort Shuffle只是产生一个按照reducer id排序可索引的文件,这样,只需获取有关文件中的相关数据块的位置信息,并fseek就可以读取指定reducer的数据。但对于rueducer数比较少的情况,Hash Shuffle明显要比Sort Shuffle快,因此Sort Shuffle有个“fallback”计划,对于reducers数少于 “spark.shuffle.sort.bypassMergeThreshold” (200 by default),我们使用fallback计划,hashing相关数据到分开的文件,然后合并这些文件为一个,具体实现为BypassMergeSortShuffleWriter。
大数据学习笔记之Spark(六):Spark内核解析_第178张图片
在map进行排序,在reduce端应用Timsort[1]进行合并。map端是否容许spill,通过spark.shuffle.spill来设置,默认是true。设置为false,如果没有足够的内存来存储map的输出,那么就会导致OOM错误,因此要慎用。
用于存储map输出的内存为:“JVM Heap Size” * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction,默认为“JVM Heap Size” * 0.2 * 0.8 = “JVM Heap Size” * 0.16。如果你在同一个执行程序中运行多个线程(设定spark.executor.cores/ spark.task.cpus超过1),每个map任务存储的空间为“JVM Heap Size” * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction / spark.executor.cores * spark.task.cpus, 默认2个cores,那么为0.08 * “JVM Heap Size”。
spark使用AppendOnlyMap存储map输出的数据,利用开源hash函数MurmurHash3和平方探测法把key和value保存在相同的array中。这种保存方法可以是spark进行combine。如果spill为true,会在spill前sort。
与hash shuffle相比,sort shuffle中每个Mapper只产生一个数据文件和一个索引文件,数据文件中的数据按照Reducer排序,但属于同一个Reducer的数据不排序。Mapper产生的数据先放到AppendOnlyMap这个数据结构中,如果内存不够,数据则会spill到磁盘,最后合并成一个文件。
与Hash shuffle相比,shuffle文件数量减少,内存使用更加可控。但排序会影响速度。
优点
1)map创建文件量较少
2)少量的IO随机操作,大部分是顺序读写
缺点
1)要比Hash Shuffle要慢,需要自己通过spark.shuffle.sort.bypassMergeThreshold来设置合适的值。
2)如果使用SSD盘存储shuffle数据,那么Hash Shuffle可能更合适。

12.4TungstenShuffle过程介绍

这个TungstenShuffle用到了堆外排序,但是这种适用面非常窄,所以不用过多的了解。

Tungsten-sort 算不得一个全新的shuffle 方案,它在特定场景下基于类似现有的Sort Based Shuffle处理流程,对内存/CPU/Cache使用做了非常大的优化。带来高效的同时,也就限定了自己的使用场景。如果Tungsten-sort 发现自己无法处理,则会自动使用 Sort Based Shuffle进行处理。Tungsten 中文是钨丝的意思。 Tungsten Project 是 Databricks 公司提出的对Spark优化内存和CPU使用的计划,该计划初期似乎对Spark SQL优化的最多。不过部分RDD API 还有Shuffle也因此受益。
Tungsten-sort优化点主要在三个方面:
1)直接在serialized binary data上sort而不是java objects,减少了memory的开销和GC的overhead。
2)提供cache-efficient sorter,使用一个8bytes的指针,把排序转化成了一个指针数组的排序。
3)spill的merge过程也无需反序列化即可完成
这些优化的实现导致引入了一个新的内存管理模型,类似OS的Page,对应的实际数据结构为MemoryBlock,支持off-heap 以及 in-heap 两种模式。为了能够对Record 在这些MemoryBlock进行定位,引入了Pointer(指针)的概念。
如果你还记得Sort Based Shuffle里存储数据的对象PartitionedAppendOnlyMap,这是一个放在JVM heap里普通对象,在Tungsten-sort中,他被替换成了类似操作系统内存页的对象。如果你无法申请到新的Page,这个时候就要执行spill操作,也就是写入到磁盘的操作。具体触发条件,和Sort Based Shuffle 也是类似的。
Spark 默认开启的是Sort Based Shuffle,想要打开Tungsten-sort ,请设置
spark.shuffle.manager=tungsten-sort
对应的实现类是:
org.apache.spark.shuffle.unsafe.UnsafeShuffleManager
名字的来源是因为使用了大量JDK Sun Unsafe API。
当且仅当下面条件都满足时,才会使用新的Shuffle方式:
1)Shuffle dependency 不能带有aggregation 或者输出需要排序
2)Shuffle 的序列化器需要是 KryoSerializer 或者 Spark SQL’s 自定义的一些序列化方式.
3)Shuffle 文件的数量不能大于 16777216
4)序列化时,单条记录不能大于 128 MB
可以看到,能使用的条件还是挺苛刻的。
这些限制来源于哪里
参看如下代码,page的大小:

this.pageSizeBytes = (int) Math.min(PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES,shuffleMemoryManager.pageSizeBytes());

这就保证了页大小不超过PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES 的值,该值就被定义成了128M。
而产生这个限制的具体设计原因,我们还要仔细分析下Tungsten的内存模型:
大数据学习笔记之Spark(六):Spark内核解析_第179张图片
这张图其实画的是 on-heap 的内存逻辑图,其中 #Page 部分为13bit, Offset 为51bit,你会发现 2^51 >>128M的。但是在Shuffle的过程中,对51bit 做了压缩,使用了27bit,具体如下:
[24 bit partition number][13 bit memory page number][27 bit offset in page]
这里预留出的24bit给了partition number,为了后面的排序用。上面的好几个限制其实都是因为这个指针引起的:
一个是partition 的限制,前面的数字 16777216 就是来源于partition number 使用24bit 表示的。
第二个是page number
第三个是偏移量,最大能表示到2^27=128M。那一个task 能管理到的内存是受限于这个指针的,最多是 2^13 * 128M 也就是1TB左右。
有了这个指针,我们就可以定位和管理到off-heap 或者 on-heap里的内存了。这个模型还是很漂亮的,内存管理也非常高效,记得之前的预估PartitionedAppendOnlyMap的内存是非常困难的,但是通过现在的内存管理机制,是非常快速并且精确的。
对于第一个限制,那是因为后续Shuffle Write的sort 部分,只对前面24bit的partiton number 进行排序,key的值没有被编码到这个指针,所以没办法进行ordering
同时,因为整个过程是追求不反序列化的,所以不能做aggregation。
Shuffle Write
核心类:
org.apache.spark.shuffle.unsafe.UnsafeShuffleWriter
数据会通过 UnsafeShuffleExternalSorter.insertRecordIntoSorter 一条一条写入到 serOutputStream 序列化输出流。
这里消耗内存的地方是
serBuffer = new MyByteArrayOutputStream(1024 * 1024)
默认是1M,类似于Sort Based Shuffle 中的ExternalSorter,在Tungsten Sort 对应的为UnsafeShuffleExternalSorter,记录序列化后就通过sorter.insertRecord方法放到sorter里去了。
这里sorter 负责申请Page,释放Page,判断是否要进行spill都这个类里完成。代码的架子其实和Sort Based 是一样的。
大数据学习笔记之Spark(六):Spark内核解析_第180张图片
(另外,值得注意的是,这张图里进行spill操作的同时检查内存可用而导致的Exeception 的bug 已经在1.5.1版本被修复了,忽略那条路径)
内存是否充足的条件依然shuffleMemoryManager 来决定,也就是所有task shuffle 申请的Page内存总和不能大于下面的值:
ExecutorHeapMemeory * 0.2 * 0.8
上面的数字可通过下面两个配置来更改:
spark.shuffle.memoryFraction=0.2
spark.shuffle.safetyFraction=0.8
UnsafeShuffleExternalSorter 负责申请内存,并且会生成该条记录最后的逻辑地址,也就前面提到的 Pointer。
接着Record 会继续流转到UnsafeShuffleInMemorySorter中,这个对象维护了一个指针数组:
private long[] pointerArray;
数组的初始大小为 4096,后续如果不够了,则按每次两倍大小进行扩充。
假设100万条记录,那么该数组大约是8M 左右,所以其实还是很小的。一旦spill后该UnsafeShuffleInMemorySorter就会被赋为null,被回收掉。
我们回过头来看spill,其实逻辑上也异常简单了,UnsafeShuffleInMemorySorter 会返回一个迭代器,该迭代器粒度每个元素就是一个指针,然后到根据该指针可以拿到真实的record,然后写入到磁盘,因为这些record 在一开始进入UnsafeShuffleExternalSorter 就已经被序列化了,所以在这里就纯粹变成写字节数组了。形成的结构依然和Sort Based Shuffle 一致,一个文件里不同的partiton的数据用fileSegment来表示,对应的信息存在一个index文件里。
另外写文件的时候也需要一个 buffer :
spark.shuffle.file.buffer = 32k
另外从内存里拿到数据放到DiskWriter,这中间还要有个中转,是通过
final byte[] writeBuffer = new byte[DISK_WRITE_BUFFER_SIZE=1024 * 1024];
来完成的,都是内存,所以很快。
Task结束前,我们要做一次mergeSpills操作,然后形成一个shuffle 文件。这里面其实也挺复杂的,
如果开启了
spark.shuffle.unsafe.fastMergeEnabled=true
并且没有开启
spark.shuffle.compress=true
或者压缩方式为:
LZFCompressionCodec
则可以非常高效的进行合并,叫做transferTo。不过无论是什么合并,都不需要进行反序列化。
Shuffle Read
Shuffle Read 完全复用HashShuffleReader,具体参看 Sort-Based Shuffle。

12.5MapReduce与Spark过程对比

MapReduce和Spark的Shuffle过程对比如下:

对比方向 MapReduce Spark Hash Shuffle
collect collect collect
在内存中构造了一块数据结构用于map输出的缓冲 没有在内存中构造一块数据结构用于map输出的缓冲,而是直接把输出写到磁盘文件
sort map输出的数据有排序 map输出的数据没有排序
merge 对磁盘上的多个spill文件最后进行合并成一个输出文件 在map端没有merge过程,在输出时直接是对应一个reduce的数据写到一个文件中,这些文件同时存在并发写,最后不需要合并成一个
copy 框架 jetty netty或者直接socket流
本地文件 仍然是通过网络框架拖取数据 不通过网络框架,对于在本节点上的map输出文件,采用本地读取的方式
copy 过来的数据存放位置 先放在内存,内存放不下时写到磁盘 一种方式全部放在内存;另一种方式先放在内存,放不下时写到磁盘
merge sort 最后会对磁盘文件和内存中的数据进行合并排序 对于采用另一种方式时也会有合并排序的过程

第13章Spark内存管理 重点看

大数据学习笔记之Spark(六):Spark内核解析_第181张图片
spark的内存管理,主要看的是executor的内存管理,executor中主要涉及到两块内存,on-heap memory(堆内内存)、off-heap memory(堆外内存),堆内内存是建立在整个jvm的基础之上,内存的申请和回收都需要jvm去设定,可以通过 -executor -memory指定Executor内存,在堆内内存中是不能准确记录实际可用的堆内内存的,因为整个的分配不再spark上进行,堆外内存是通过java unsafe API,堆外内存是把对象做二进制之后,直接存在堆外内存里面,所以可以有一个很细粒度的划分,可以通过spark.memory.offHeap.size参数设置堆外内存

Spark 作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中扮演着非常重要的角色。理解 Spark 内存管理的基本原理,有助于更好地开发 Spark 应用程序和进行性能调优。本文中阐述的原理基于 Spark 2.1 版本。
在执行 Spark 的应用程序时,Spark 集群会启动 Driver 和 Executor 两种 JVM 进程,前者为主控进程,负责创建 Spark 上下文,提交 Spark 作业(Job),并将作业转化为计算任务(Task),在各个 Executor 进程间协调任务的调度,后者负责在工作节点上执行具体的计算任务,并将结果返回给 Driver,同时为需要持久化的 RDD 提供存储功能。由于 Driver 的内存管理相对来说较为简单,本文主要对 Executor 的内存管理进行分析,下文中的 Spark 内存均特指 Executor 的内存。

13.1堆内和堆外内存规划

作为一个 JVM 进程,Executor 的内存管理建立在 JVM 的内存管理之上,Spark 对 JVM 的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存。同时,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用.堆内和堆外内存示意图如下:
大数据学习笔记之Spark(六):Spark内核解析_第182张图片

13.1.1堆内内存

堆内内存的大小,由 Spark 应用程序启动时的 –executor-memory 或 spark.executor.memory 参数配置。Executor 内运行的并发任务共享 JVM 堆内内存,这些任务在缓存 RDD 数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存,而这些任务在执行 Shuffle 时占用的内存被规划为执行(Execution)内存,剩余的部分不做特殊规划,那些 Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例,均占用剩余的空间。不同的管理模式下,这三部分占用的空间大小各不相同(下面第 2 小节会进行介绍)。
Spark 对堆内内存的管理是一种逻辑上的"规划式"的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请后和释放前记录这些内存,我们来看其具体流程:
申请内存:
1)Spark 在代码中 new 一个对象实例
2)JVM 从堆内内存分配空间,创建对象并返回对象引用
3)Spark 保存该对象的引用,记录该对象占用的内存
释放内存:
1)Spark 记录该对象释放的内存,删除该对象的引用
2)等待 JVM 的垃圾回收机制释放该对象占用的堆内内存
我们知道,JVM 的对象可以以序列化的方式存储,序列化的过程是将对象转换为二进制字节流,本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储,在访问时则需要进行序列化的逆过程——反序列化,将字节流转化为对象,序列化的方式可以节省存储空间,但增加了存储和读取时候的计算开销。
对于 Spark 中序列化的对象,由于是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能误差较大,导致某一时刻的实际内存有可能远远超出预期[2]。此外,在被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常。
虽然不能精准控制堆内内存的申请和释放,但 Spark 通过对存储内存和执行内存各自独立的规划管理,可以决定是否要在存储内存里缓存新的 RDD,以及是否为新的任务分配执行内存,在一定程度上可以提升内存的利用率,减少异常的出现。

13.1.2堆外内存

为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。利用 JDK Unsafe API(从 Spark 2.0 开始,在管理堆外的存储内存时不再基于 Tachyon,而是与堆外的执行内存一样,基于 JDK Unsafe API 实现[3]),Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。
在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用,并由 spark.memory.offHeap.size 参数设定堆外空间的大小。除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存。

13.1.3内存管理接口

Spark 为存储内存和执行内存的管理提供了统一的接口——MemoryManager,同一个 Executor 内的任务都调用这个接口的方法来申请或释放内存:
内存管理接口的主要方法:
//申请存储内存
def acquireStorageMemory(blockId: BlockId, numBytes: Long, memoryMode: MemoryMode): Boolean
//申请展开内存
def acquireUnrollMemory(blockId: BlockId, numBytes: Long, memoryMode: MemoryMode): Boolean
//申请执行内存
def acquireExecutionMemory(numBytes: Long, taskAttemptId: Long, memoryMode: MemoryMode): Long
//释放存储内存
def releaseStorageMemory(numBytes: Long, memoryMode: MemoryMode): Unit
//释放执行内存
def releaseExecutionMemory(numBytes: Long, taskAttemptId: Long, memoryMode: MemoryMode): Unit
//释放展开内存
def releaseUnrollMemory(numBytes: Long, memoryMode: MemoryMode): Unit
我们看到,在调用这些方法时都需要指定其内存模式(MemoryMode),这个参数决定了是在堆内还是堆外完成这次操作。
MemoryManager 的具体实现上,Spark 1.6 之后默认为统一管理(Unified Memory Manager)方式,1.6 之前采用的静态管理(Static Memory Manager)方式仍被保留,可通过配置 spark.memory.useLegacyMode 参数启用。两种方式的区别在于对空间分配的方式,下面的第 2 小节会分别对这两种方式进行介绍。

13.2内存空间分配

13.2.1静态内存管理

在 Spark 最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置,堆内内存的分配如图 2 所示:
图 2 . 静态内存管理图示——堆内
大数据学习笔记之Spark(六):Spark内核解析_第183张图片
Other 主要是运行程序用的,因为executor也是需要运行程序的
Execution 主要用于shuffle操作,Executor在shuffle的过程中,为了shuffle空间的稳定性,会预留出来一块区域,Reserved,防止OOM
Storage 用于RDD缓存,也就是RDD.cache操作的时候,

可以看到,可用的堆内内存的大小需要按照下面的方式计算:
可用堆内内存空间:
1
2 可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction
可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction
其中 systemMaxMemory 取决于当前 JVM 堆内内存的大小,最后可用的执行内存或者存储内存要在此基础上与各自的 memoryFraction 参数和 safetyFraction 参数相乘得出。上述计算公式中的两个 safetyFraction 参数,其意义在于在逻辑上预留出 1-safetyFraction 这么一块保险区域,降低因实际内存超出当前预设范围而导致 OOM 的风险(上文提到,对于非序列化对象的内存采样估算会产生误差)。值得注意的是,这个预留的保险区域仅仅是一种逻辑上的规划,在具体使用时 Spark 并没有区别对待,和"其它内存"一样交给了 JVM 去管理。
堆外的空间分配较为简单,只有存储内存和执行内存,如图 3 所示。可用的执行内存和存储内存占用的空间大小直接由参数 spark.memory.storageFraction 决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域。
图 3 . 静态内存管理图示——堆外
大数据学习笔记之Spark(六):Spark内核解析_第184张图片
这里面没有others因为other是运行的,所以放在堆内内存里面,因为需要申请对象、变更对象等等,所以这块单纯是用于数据存储的,包括RDD缓存和shuffle运行

静态内存管理机制实现起来较为简单,但如果用户不熟悉 Spark 的存储机制,或没有根据具体的数据规模和计算任务或做相应的配置,很容易造成"一半海水,一半火焰"的局面,即存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容。由于新的内存管理机制的出现,这种方式目前已经很少有开发者使用,出于兼容旧版本的应用程序的目的,Spark 仍然保留了它的实现。

13.2.2统一内存管理

Spark 1.6 之后引入的统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域,如图 4 和图 5 所
图 4 . 统一内存管理图示——堆内
大数据学习笔记之Spark(六):Spark内核解析_第185张图片

这个和静态的不一样,静态的就算shuffle的内存不够用了,也不会去侵占storage。
但是这个同一内存管理,如果Execution不够用了,就会去占用storage的内存,叫做动态占用机制,同样如果 storage内存不够了,回去占用Execution
这里面还包括了预留空间,不过不是主要的。

图 5 . 统一内存管理图示——堆外
大数据学习笔记之Spark(六):Spark内核解析_第186张图片
这里同样是动态占用机制

其中最重要的优化在于动态占用机制,其规则如下:
1)设定基本的存储内存和执行内存区域(spark.storage.storageFraction 参数),该设定确定了双方各自拥有的空间的范围
2)双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的 Block)
3)执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后"归还"借用的空间
4)存储内存的空间被对方占用后,无法让对方"归还",因为需要考虑 Shuffle 过程中的很多因素,实现起来较为复杂[4
图 6 . 动态占用机制图示
大数据学习笔记之Spark(六):Spark内核解析_第187张图片
大数据学习笔记之Spark(六):Spark内核解析_第188张图片
当双方的空间都被占满了,有新的内容,都需要存储到磁盘,因为谁都扩展不了了
己方不足,则占用另一方的
当前(execution)占用了另一方(storage)的,但是另一方的此时也要了,但是Execution还没有释放呢,这种情况只能等待释放

凭借统一内存管理机制,Spark 在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护 Spark 内存的难度,但并不意味着开发者可以高枕无忧。譬如,所以如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的 RDD 数据通常都是长期驻留内存的 [5] 。所以要想充分发挥 Spark 的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。


申请展开内存,RDD缓存的过程就叫做展开,
这个类就为spark屏蔽了底层的申请和释放

RDD的缓存
可以通过StorageLevel设置
如果没有设置缓存的时候,默认是在other这个空间里面,然后每个数据都是一个record,都是不连续的,这个时候如果要缓存,就会有一条unroll这条线,这个过程叫做unroll,缓存后每一个record都会有一个blockid,然后会存到一块。
这样完成了RDD的缓存操作。

13.3存储内存管理

13.3.1RDD 的持久化机制

弹性分布式数据集(RDD)作为 Spark 最根本的数据抽象,是只读的分区记录(Partition)的集合,只能基于在稳定物理存储中的数据集上创建,或者在其他已有的 RDD 上执行转换(Transformation)操作产生一个新的 RDD。转换后的 RDD 与原始的 RDD 之间产生的依赖关系,构成了血统(Lineage)。凭借血统,Spark 保证了每一个 RDD 都可以被重新恢复。但 RDD 的所有转换都是惰性的,即只有当一个返回结果给 Driver 的行动(Action)发生时,Spark 才会创建任务读取 RDD,然后真正触发转换的执行。
Task 在启动之初读取一个分区时,会先判断这个分区是否已经被持久化,如果没有则需要检查 Checkpoint 或按照血统重新计算。所以如果一个 RDD 上要执行多次行动,可以在第一次行动中使用 persist 或 cache 方法,在内存或磁盘中持久化或缓存这个 RDD,从而在后面的行动时提升计算速度。事实上,cache 方法是使用默认的 MEMORY_ONLY 的存储级别将 RDD 持久化到内存,故缓存是一种特殊的持久化。 堆内和堆外存储内存的设计,便可以对缓存 RDD 时使用的内存做统一的规划和管 理 (存储内存的其他应用场景,如缓存 broadcast 数据,暂时不在本文的讨论范围之内)。
RDD 的持久化由 Spark 的 Storage 模块 [7] 负责,实现了 RDD 与物理存储的解耦合。Storage 模块负责管理 Spark 在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构,即 Driver 端的 BlockManager 为 Master,Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位,RDD 的每个 Partition 经过处理后唯一对应一个 Block(BlockId 的格式为 rdd_RDD-ID_PARTITION-ID )。Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护,而 Slave 需要将 Block 的更新等状态上报到 Master,同时接收 Master 的命令,例如新增或删除一个 RDD。
图 7 . Storage 模块示意图
大数据学习笔记之Spark(六):Spark内核解析_第189张图片

在对 RDD 持久化时,Spark 规定了 MEMORY_ONLY、MEMORY_AND_DISK 等 7 种不同的 存储级别 ,而存储级别是以下 5 个变量的组合:
清单 3 . 存储级别

class StorageLevel private(
  private var _useDisk: Boolean, //磁盘
  private var _useMemory: Boolean, //这里其实是指堆内内存
  private var _useOffHeap: Boolean, //堆外内存
  private var _deserialized: Boolean, //是否为非序列化
  private var _replication: Int = 1 //副本个数
)

通过对数据结构的分析,可以看出存储级别从三个维度定义了 RDD 的 Partition(同时也就是 Block)的存储方式:
1)存储位置:磁盘/堆内内存/堆外内存。如 MEMORY_AND_DISK 是同时在磁盘和堆内内存上存储,实现了冗余备份。OFF_HEAP 则是只在堆外内存存储,目前选择堆外内存时不能同时存储到其他位置。
2)存储形式:Block 缓存到存储内存后,是否为非序列化的形式。如 MEMORY_ONLY 是非序列化方式存储,OFF_HEAP 是序列化方式存储。
3)副本数量:大于 1 时需要远程冗余备份到其他节点。如 DISK_ONLY_2 需要远程备份 1 个副本。

13.3.2RDD 缓存的过程

RDD 在缓存到存储内存之前,Partition 中的数据一般以迭代器(Iterator)的数据结构来访问,这是 Scala 语言中一种遍历数据集合的方法。通过 Iterator 可以获取分区中每一条序列化或者非序列化的数据项(Record),这些 Record 的对象实例在逻辑上占用了 JVM 堆内内存的 other 部分的空间,同一 Partition 的不同 Record 的空间并不连续。
RDD 在缓存到存储内存之后,Partition 被转换成 Block,Record 在堆内或堆外存储内存中占用一块连续的空间。将Partition由不连续的存储空间转换为连续存储空间的过程,Spark称之为"展开"(Unroll)。Block 有序列化和非序列化两种存储格式,具体以哪种方式取决于该 RDD 的存储级别。非序列化的 Block 以一种 DeserializedMemoryEntry 的数据结构定义,用一个数组存储所有的对象实例,序列化的 Block 则以 SerializedMemoryEntry的数据结构定义,用字节缓冲区(ByteBuffer)来存储二进制数据。每个 Executor 的 Storage 模块用一个链式 Map 结构(LinkedHashMap)来管理堆内和堆外存储内存中所有的 Block 对象的实例[6],对这个 LinkedHashMap 新增和删除间接记录了内存的申请和释放。
因为不能保证存储空间可以一次容纳 Iterator 中的所有数据,当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。对于序列化的 Partition,其所需的 Unroll 空间可以直接累加计算,一次申请。而非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,采样估算其所需的 Unroll 空间并进行申请,空间不足时可以中断,释放已占用的 Unroll 空间。如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间,如下图 8 所示。
图 8. Spark Unroll 示意图
大数据学习笔记之Spark(六):Spark内核解析_第190张图片
在图 3 和图 5 中可以看到,在静态内存管理时,Spark 在存储内存中专门划分了一块 Unroll 空间,其大小是固定的,统一内存管理时则没有对 Unroll 空间进行特别区分,当存储空间不足时会根据动态占用机制进行处理。

13.3.3淘汰和落盘

由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰(Eviction),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该 Block。
存储内存的淘汰规则为:
1)被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同,即同属于堆外或堆内内存
2)新旧 Block 不能属于同一个 RDD,避免循环淘汰
3)旧 Block 所属 RDD 不能处于被读状态,避免引发一致性问题
4)遍历 LinkedHashMap 中 Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新 Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。
落盘的流程则比较简单,如果其存储级别符合_useDisk 为 true 的条件,再根据其_deserialized 判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage 模块中更新其信息。

13.4执行内存管理

13.4.1多任务间内存分配

Executor 内运行的任务同样共享执行内存,Spark 用一个 HashMap 结构保存了任务到内存耗费的映射。每个任务可占用的执行内存大小的范围为 1/2N ~ 1/N,其中 N 为当前 Executor 内正在运行的任务的个数。每个任务在启动之时,要向 MemoryManager 请求申请最少为 1/2N 的执行内存,如果不能被满足要求则该任务被阻塞,直到有其他任务释放了足够的执行内存,该任务才可以被唤醒。

13.4.2Shuffle 的内存占用

执行内存主要用来存储任务在执行 Shuffle 时占用的内存,Shuffle 是按照一定规则对 RDD 数据重新分区的过程,我们来看 Shuffle 的 Write 和 Read 两阶段对执行内存的使用:
Shuffle Write
1)若在 map 端选择普通的排序方式,会采用 ExternalSorter 进行外排,在内存中存储数据时主要占用堆内执行空间。
2)若在 map 端选择 Tungsten 的排序方式,则采用 ShuffleExternalSorter 直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够。
Shuffle Read
1)在对 reduce 端的数据进行聚合时,要将数据交给 Aggregator 处理,在内存中存储数据时占用堆内执行空间。
2)如果需要进行最终结果排序,则要将再次将数据交给 ExternalSorter 处理,占用堆内执行空间。
在 ExternalSorter 和 Aggregator 中,Spark 会使用一种叫 AppendOnlyMap 的哈希表在堆内执行内存中存储数据,但在 Shuffle 过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用的内存会进行周期性地采样估算,当其大到一定程度,无法再从 MemoryManager 申请到新的执行内存时,Spark 就会将其全部内容存储到磁盘文件中,这个过程被称为溢存(Spill),溢存到磁盘的文件最后会被归并(Merge)。
Shuffle Write 阶段中用到的 Tungsten 是 Databricks 公司提出的对 Spark 优化内存和 CPU 使用的计划[9],解决了一些 JVM 在性能上的限制和弊端。Spark 会根据 Shuffle 的情况来自动选择是否采用 Tungsten 排序。Tungsten 采用的页式内存管理机制建立在 MemoryManager 之上,即 Tungsten 对执行内存的使用进行了一步的抽象,这样在 Shuffle 过程中无需关心数据具体存储在堆内还是堆外。每个内存页用一个 MemoryBlock 来定义,并用 Object obj 和 long offset 这两个变量统一标识一个内存页在系统内存中的地址。堆内的 MemoryBlock 是以 long 型数组的形式分配的内存,其 obj 的值为是这个数组的对象引用,offset 是 long 型数组的在 JVM 中的初始偏移地址,两者配合使用可以定位这个数组在堆内的绝对地址;堆外的 MemoryBlock 是直接申请到的内存块,其 obj 为 null,offset 是这个内存块在系统内存中的 64 位绝对地址。Spark 用 MemoryBlock 巧妙地将堆内和堆外内存页统一抽象封装,并用页表(pageTable)管理每个 Task 申请到的内存页。
Tungsten 页式管理下的所有内存用 64 位的逻辑地址表示,由页号和页内偏移量组成:
页号:占 13 位,唯一标识一个内存页,Spark 在申请内存页之前要先申请空闲页号。
页内偏移量:占 51 位,是在使用内存页存储数据时,数据在页内的偏移地址。
有了统一的寻址方式,Spark 可以用 64 位逻辑地址的指针定位到堆内或堆外的内存,整个 Shuffle Write 排序的过程只需要对指针进行排序,并且无需反序列化,整个过程非常高效,对于内存访问效率和 CPU 使用效率带来了明显的提升[10]。
Spark 的存储内存和执行内存有着截然不同的管理方式:对于存储内存来说,Spark 用一个 LinkedHashMap 来集中管理所有的 Block,Block 由需要缓存的 RDD 的 Partition 转化而成;而对于执行内存,Spark 用 AppendOnlyMap 来存储 Shuffle 过程中的数据,在 Tungsten 排序中甚至抽象成为页式内存管理,开辟了全新的 JVM 内存管理机制。

第14章部署模式解析

大数据学习笔记之Spark(六):Spark内核解析_第191张图片
对于client模式,有一个driver的启动过程,这个过程是直接在client里面启动的(上图中红色的线)
对于cluster模式,会通过master选择一个worker,然后在这个worker里面去启动一个driver进程
大数据学习笔记之Spark(六):Spark内核解析_第192张图片
master和worker主要是用于资源分配,比如driver也是master去发命令,然后worker做driver的部署,在executor里面也是master去发命令,worker去帮你进行executor的创建和输出。
做完这两个之后worker目前就没有太大的用了,接下来的用处就是对executor进行一些监控,进行一些心跳,剩下的就会直接去driver进行分配了。
这里需要注意,这三种不同类型的节点各自运行与自己的jvm进程中

大数据学习笔记之Spark(六):Spark内核解析_第193张图片
大数据学习笔记之Spark(六):Spark内核解析_第194张图片
如果部署yarn的时候,就不需要master

14.1部署模式概述

Spark支持的主要的三种分布式部署方式分别是standalone、spark on mesos和 spark on YARN。standalone模式,即独立模式,自带完整的服务,可单独部署到一个集群中,无需依赖任何其他资源管理系统。它是Spark实现的资源调度框架,其主要的节点有Client节点、Master节点和Worker节点。而yarn是统一的资源管理机制,在上面可以运行多套计算框架,如map reduce、storm等根据driver在集群中的位置不同,分为yarn client和yarn cluster。而mesos是一个更强大的分布式资源管理框架,它允许多种不同的框架部署在其上,包括yarn。基本上,Spark的运行模式取决于传递给SparkContext的MASTER环境变量的值,个别模式还需要辅助的程序接口来配合使用,目前支持的Master字符串及URL包括:

Master URL Meaning
local 在本地运行,只有一个工作进程,无并行计算能力。
local[K] 在本地运行,有K个工作进程,通常设置K为机器的CPU核心数量。
local[*] 在本地运行,工作进程数量等于机器的CPU核心数量。
spark://HOST:PORT 以Standalone模式运行,这是Spark自身提供的集群运行模式,默认端口号: 7077。详细文档见:Spark standalone cluster。
mesos://HOST:PORT 在Mesos集群上运行,Driver进程和Worker进程运行在Mesos集群上,部署模式必须使用固定值:–deploy-mode cluster。详细文档见:MesosClusterDispatcher.
yarn-client 在Yarn集群上运行,Driver进程在本地,Work进程在Yarn集群上,部署模式必须使用固定值:–deploy-mode client。Yarn集群地址必须在HADOOP_CONF_DIRorYARN_CONF_DIR变量里定义。
yarn-cluster 在Yarn集群上运行,Driver进程在Yarn集群上,Work进程也在Yarn集群上,部署模式必须使用固定值:–deploy-mode cluster。Yarn集群地址必须在HADOOP_CONF_DIRorYARN_CONF_DIR变量里定义。

用户在提交任务给Spark处理时,以下两个参数共同决定了Spark的运行方式。
· –master MASTER_URL :决定了Spark任务提交给哪种集群处理。
· –deploy-mode DEPLOY_MODE:决定了Driver的运行方式,可选值为Client或者Cluster。

14.2standalone框架

standalone集群由三个不同级别的节点组成,分别是
1)Master 主控节点,可以类比为董事长或总舵主,在整个集群之中,最多只有一个Master处在Active状态
2)Worker 工作节点 ,这个是manager,是分舵主, 在整个集群中,可以有多个worker,如果worker为零,什么事也做不了
3)Executor 干苦力活的,直接受worker掌控,一个worker可以启动多个executor,启动的个数受限于机器中的cpu核数
这三种不同类型的节点各自运行于自己的JVM进程之中。
Standalone模式下,集群启动时包括Master与Worker,其中Master负责接收客户端提交的作业,管理Worker。根据作业提交的方式不同,分为driver on client 和drvier on worker。如下图7所示,上图为driver on work模式,下图为driver on client模式。两种模式的主要不同点在于driver所在的位置。
在standalone部署模式下又分为client模式和cluster模式,其中client模式下,driver和client运行于同一JVM中,不由worker启动,该JVM进程直到spark application计算完成返回结果后才退出。如下图所示。
大数据学习笔记之Spark(六):Spark内核解析_第195张图片
而在cluster模式下,driver由worker启动,client在确认spark application成功提交给cluster后直接退出,并不等待spark application运行结果返回。如下图所示
大数据学习笔记之Spark(六):Spark内核解析_第196张图片
从部署图来进行分析,每个JVM进程在启动时的文件依赖如何得到满足。
1)Master进程最为简单,除了spark jar包之外,不存在第三方库依赖
2)Driver和Executor在运行的时候都有可能存在第三方包依赖,分开来讲
3)Driver比较简单,spark-submit在提交的时候会指定所要依赖的jar文件从哪里读取
4)Executor由worker来启动,worker需要下载Executor启动时所需要的jar文件,那么从哪里下载呢。

大数据学习笔记之Spark(六):Spark内核解析_第197张图片
大数据学习笔记之Spark(六):Spark内核解析_第198张图片
Spark Standalone模式,即独立模式,自带完整的服务,可单独部署到一个集群中,无需依赖其他资源管理系统。在该模式下,用户可以通过手动启动Master和Worker来启动一个独立的集群。其中,Master充当了资源管理的角色,Workder充当了计算节点的角色。在该模式下,Spark Driver程序在客户端(Client)运行,而Executor则在Worker节点上运行。
以下是一个运行在Standalone模式下,包含一个Master节点,两个Worker节点的Spark任务调度交互部署架构图。
大数据学习笔记之Spark(六):Spark内核解析_第199张图片
从上面的Spark任务调度过程可以看到:
1)整个集群分为Master节点和Worker节点,其中Driver程序运行在客户端。Master节点负责为任务分配Worker节点上的计算资源,两者会通过相互通信来同步资源状态,见途中红色双向箭头。
2)客户端启动任务后会运行Driver程序,Driver程序中会完成SparkContext对象的初始化,并向Master进行注册。
3)每个Workder节点上会存在一个或者多个ExecutorBackend进程。每个进程包含一个Executor对象,该对象持有一个线程池,每个线程池可以执行一个任务(task)。ExecutorBackend进程还负责跟客户端节点上的Driver程序进行通信,上报任务状态。

14.2.1Standalone模式下任务运行过程

​上面的过程反映了Spark在standalone模式下,整体上客户端、Master和Workder节点之间的交互。对于一个任务的具体运行过程需要更细致的分解,分解运行过程见图中的小字。
1.用户通过bin/spark-submit部署工具或者bin/spark-class启动应用程序的Driver进程,Driver进程会初始化SparkContext对象,并向Master节点进行注册。
1.Master节点接受Driver程序的注册,检查它所管理的Worker节点,为该Driver程序分配需要的计算资源Executor。Worker节点完成Executor的分配后,向Master报告Executor的状态。
2.Worker节点上的ExecutorBackend进程启动后,向Driver进程注册。
2.Driver进程内部通过DAG Schaduler,Stage Schaduler,Task Schaduler等过程完成任务的划分后,向Worker节点上的ExecutorBackend分配TASK。
1.ExecutorBackend进行TASK计算,并向Driver报告TASK状态,直至结束。
2.Driver进程在所有TASK都处理完成后,向Master注销。

14.2.2总结

Spark能够以standalone模式运行,这是Spark自身提供的运行模式,用户可以通过手动启动master和worker进程来启动一个独立的集群,也可以在一台机器上运行这些守护进程进行测试。standalone模式可以用在生产环境,它有效的降低了用户学习、测试Spark框架的成本。
standalone模式目前只支持跨应用程序的简单FIFO调度。然而,为了允许多个并发用户,你可以控制每个应用使用的资源的最大数。默认情况下,它会请求使用集群的全部CUP内核。
缺省情况下,standalone任务调度允许worker的失败(在这种情况下它可以将失败的任务转移给其他的worker)。但是,调度器使用master来做调度,这会产生一个单点问题:如果master崩溃,新的应用不会被创建。为了解决这个问题,可以zookeeper的选举机制在集群中启动多个master,也可以使用本地文件实现单节点恢复。
14.3yarn集群模式
Apache yarn是apache Hadoop开源项目的一部分。设计之初是为了解决mapreduce计算框架资源管理的问题。到haodoop 2.0使用yarn将mapreduce的分布式计算和资源管理区分开来。它的引入使得Hadoop分布式计算系统进入了平台化时代,即各种计算框架可以运行在一个集群中,由资源管理系统YRAN进行统一的管理和调度,从而共享整个集群资源、提高资源利用率。
  YARN总体上也Master/slave架构——ResourceManager/NodeManager。前者(RM)负责对各个NodeManager(NM)上的资源进行统一管理和调度。而container是资源分配和调度的基本单位,其中封装了机器资源,如内存、CPU、磁盘和网络等,每个任务会被分配一个Container,该任务只能在该Container中执行,并使用该Container封装的资源。NodeManager的作用则是负责接收并启动应用的container、而向RM回报本节点上的应用Container运行状态和资源使用情况。ApplicationMaster与具体的Application相关,主要负责同ResourceManager协商以获取合适的Container,并跟踪这些Container的状态和监控其进度。如下图所示为yarn集群的一般模型。
大数据学习笔记之Spark(六):Spark内核解析_第200张图片
Spark在yarn集群上的部署方式分为两种,yarn client(driver运行在客户端)和yarn cluster(driver运行在master上),driver on master如下图所示。
大数据学习笔记之Spark(六):Spark内核解析_第201张图片
(1) Spark Yarn Client向YARN中提交应用程序,包括Application Master程序、启动Application Master的命令、需要在Executor中运行的程序等;
(2) Resource manager收到请求后,在其中一个node manager中为应用程序分配一个container,要求它在container中启动应用程序的Application Master,Application master初始化sparkContext以及创建DAG Scheduler和Task Scheduler。
(3) Application master根据sparkContext中的配置,向resource manager申请container,同时,Application master向Resource manager注册,这样用户可通过Resource manager查看应用程序的运行状态
(4) Resource manager 在集群中寻找符合条件的node manager,在node manager启动container,要求container启动executor,
(5) Executor启动后向Application master注册,并接收Application master分配的task
(6) 应用程序运行完成后,Application Master向Resource Manager申请注销并关闭自己。
Driver on client如下图所示:
大数据学习笔记之Spark(六):Spark内核解析_第202张图片
(1) Spark Yarn Client向YARN的Resource Manager申请启动Application Master。同时在SparkContent初始化中将创建DAG Scheduler和TASK Scheduler等
(2) ResourceManager收到请求后,在集群中选择一个NodeManager,为该应用程序分配第一个Container,要求它在这个Container中启动应用程序的ApplicationMaster,与YARN-Cluster区别的是在该ApplicationMaster不运行SparkContext,只与SparkContext进行联系进行资源的分派
(3) Client中的SparkContext初始化完毕后,与Application Master建立通讯,向Resource Manager注册,根据任务信息向Resource Manager申请资源(Container)
(4) 当application master申请到资源后,便与node manager通信,要求它启动container
(5) Container启动后向driver中的sparkContext注册,并申请task
(6) 应用程序运行完成后,Client的SparkContext向ResourceManager申请注销并关闭自己。
  从下图11:Yarn-client和Yarn cluster模式对比可以看出,在Yarn-client(Driver on client)中,Application Master仅仅从Yarn中申请资源给Executor,之后client会跟container通信进行作业的调度。如果client离集群距离较远,建议不要采用此方式,不过此方式有利于交互式的作业。
大数据学习笔记之Spark(六):Spark内核解析_第203张图片
图11 Yarn-client和Yarn cluster模式对比
Spark能够以集群的形式运行,可用的集群管理系统有Yarn,Mesos等。集群管理器的核心功能是资源管理和任务调度。以Yarn为例,Yarn以Master/Slave模式工作,在Master节点运行的是Resource Manager(RM),负责管理整个集群的资源和资源分配。在Slave节点运行的Node Manager(NM),是集群中实际拥有资源的工作节点。我们提交Job以后,会将组成Job的多个Task调度到对应的Node Manager上进行执行。另外,在Node Manager上将资源以Container的形式进行抽象,Container包括两种资源内存和CPU。
以下是一个运行在Yarn集群上,包含一个Resource Manager节点,三个Node Manager节点(其中,两个是Worker节点,一个Master节点)的Spark任务调度交换部署架构图。
大数据学习笔记之Spark(六):Spark内核解析_第204张图片
从上面的Spark任务调度过程图可以看到:
1)整个集群分为Master节点和Worker节点,它们都存在于Node Manager节点上,在客户端提交任务时由Resource Manager统一分配,运行Driver程序的节点被称为Master节点,执行具体任务的节点被称为Worder节点。Node Manager节点上资源的变化都需要及时更新给Resource Manager,见图中红色双向箭头。
2)Master节点上常驻Master守护进程 —— Driver程序,Driver程序中会创建SparkContext对象,并负责跟各个Worker节点上的ExecutorBackend进程进行通信,管理Worker节点上的任务,同步任务进度。实际上,在Yarn中Node Manager之间的关系是平等的,因此Driver程序会被调度到任何一个Node Manager节点。
3)每个Worker节点上会存在一个或者多个ExecutorBackend进程。每个进程包含一个Executor对象,该对象持有一个线程池,每个线程池可以执行一个任务(task)。ExecutorBackend进程还负责跟Master节点上的Driver程序进行通信,上报任务状态。

14.3.1集群下任务运行过程

上面的过程反映出了Spark在集群模式下,整体上Resource Manager和Node Manager节点间的交互,Master和Worker之间的交互。对于一个任务的具体运行过程需要更细致的分解,分解运行过程见图中的小字。
1)用户通过bin/spark-submit部署工具或者bin/spark-class向Yarn集群提交应用程序。
2)Yarn集群的Resource Manager为提交的应用程序选择一个Node Manager节点并分配第一个container,并在该节点的container上启动SparkContext对象。
3)SparkContext对象向Yarn集群的Resource Manager申请资源以运行Executor。
4)Yarn集群的Resource Manager分配container给SparkContext对象,SparkContext和相关的Node Manager通讯,在获得的container上启动ExecutorBackend守护进程,ExecutorBackend启动后开始向SparkContext注册并申请Task。
5)SparkContext分配Task给ExecutorBackend执行。
6)ExecutorBackend开始执行Task,并及时向SparkContext汇报运行状况。
Task运行完毕,SparkContext归还资源给Node Manager,并注销退。

14.4mesos集群模式

Mesos是apache下的开源分布式资源管理框架。起源于加州大学伯克利分校,后被twitter推广使用。Mesos上可以部署多种分布式框架,Mesos的架构图如下图12所示,其中Framework是指外部的计算框架,如Hadoop,Mesos等,这些计算框架可通过注册的方式接入mesos,以便mesos进行统一管理和资源分配。
大数据学习笔记之Spark(六):Spark内核解析_第205张图片
图12 mesos一般部署图
  在 Mesos 上运行的 framework 由两部分组成:一个是 scheduler ,通过注册到master 来获取集群资源。另一个是在 slave 节点上运行的executor进程,它可以执行 framework 的 task 。 Master 决定为每个framework 提供多少资源,framework 的 scheduler来选择其中提供的资源。当 framework同意了提供的资源,它通过master将 task发送到提供资源的slaves 上运行。Mesos的资源分配图如下图13。
大数据学习笔记之Spark(六):Spark内核解析_第206张图片
图13 mesos资源分配图
(1) Slave1 向 Master 报告,有4个CPU和4 GB内存可用
(2) Master 发送一个 Resource Offer 给 Framework1 来描述 Slave1 有多少可用资源
(3) FrameWork1 中的 FW Scheduler会答复 Master,我有两个 Task 需要运行在 Slave1,一个 Task 需要<2个CPU,1 GB内存="">,另外一个Task需要<1个CPU,2 GB内存="">
(4) 最后,Master 发送这些 Tasks 给 Slave1。然后,Slave1还有1个CPU和1 GB内存没有使用,所以分配模块可以把这些资源提供给 Framework2
  Spark可作为其中一个分布式框架部署在mesos上,部署图与mesos的一般框架部署图类似,如下图14,这里不再重述。
大数据学习笔记之Spark(六):Spark内核解析_第207张图片

14.5spark 三种部署模式的区别

在这三种部署模式中,standalone作为spark自带的分布式部署模式,是最简单也是最基本的spark应用程序部署模式,这里就不再赘述。这里就讲一下yarn和mesos的区别:
(1) 就两种框架本身而言,mesos上可部署yarn框架。而yarn是更通用的一种部署框架,而且技术较成熟。
(2) mesos双层调度机制,能支持多种调度模式,而Yarn通过Resource Mananger管理集群资源,只能使用一种调度模式。Mesos 的双层调度机制为:mesos可接入如yarn一般的分布式部署框架,但Mesos要求可接入的框架必须有一个调度器模块,该调度器负责框架内部的任务调度。当一个framework想要接入mesos时,需要修改自己的调度器,以便向mesos注册,并获取mesos分配给自己的资源, 这样再由自己的调度器将这些资源分配给框架中的任务,也就是说,整个mesos系统采用了双层调度框架:第一层,由mesos将资源分配给框架;第二层,框架自己的调度器将资源分配给自己内部的任务。
(3) mesos可实现粗、细粒度资源调度,可动态分配资源,而Yarn只能实现静态资源分配。其中粗粒度和细粒度调度定义如下:
  粗粒度模式(Coarse-grained Mode):程序运行之前就要把所需要的各种资源(每个executor占用多少资源,内部可运行多少个executor)申请好,运行过程中不能改变。
  细粒度模式(Fine-grained Mode):为了防止资源浪费,对资源进行按需分配。与粗粒度模式一样,应用程序启动时,先会启动executor,但每个executor占用资源仅仅是自己运行所需的资源,不需要考虑将来要运行的任务,之后,mesos会为每个executor动态分配资源,每分配一些,便可以运行一个新任务,单个Task运行完之后可以马上释放对应的资源。每个Task会汇报状态给Mesos slave和Mesos Master,便于更加细粒度管理和容错,这种调度模式类似于MapReduce调度模式,每个Task完全独立,优点是便于资源控制和隔离,但缺点也很明显,短作业运行延迟大。
  从yarn和mesos的区别可看出,它们各自有优缺点。因此实际使用中,选择哪种框架,要根据本公司的实际需要而定,可考虑现有的大数据生态环境。如我司采用yarn部署spark,原因是,我司早已有较成熟的hadoop的框架,考虑到使用的方便性,采用了yarn模式的部署。

14.6异常场景分析

上面说明的是正常情况下,各节点的消息分发细节。那么如果在运行中,集群中的某些节点出现了问题,整个集群是否还能够正常处理Application中的任务呢?

14.6.1异常分析1: worker异常退出

大数据学习笔记之Spark(六):Spark内核解析_第208张图片

worker异常退出之后,当前的worker相对应的executor也就异常退出了,这个时候driver层面会把整个的executor都给移除掉,然后重新运行他的任务到其他的executor上。

在Spark运行过程中,经常碰到的问题就是worker异常退出,当worker退出时,整个集群会有哪些故事发生呢? 请看下面的具体描述
1)worker异常退出,比如说有意识的通过kill指令将worker杀死
2)worker在退出之前,会将自己所管控的所有小弟executor全干掉
3)worker需要定期向master改善心跳消息的,现在worker进程都已经玩完了,哪有心跳消息,所以Master会在超时处理中意识到有一个“分舵”离开了
4)Master非常伤心,伤心的Master将情况汇报给了相应的Driver
5)Driver通过两方面确认分配给自己的Executor不幸离开了,一是Master发送过来的通知,二是Driver没有在规定时间内收到Executor的StatusUpdate,于是Driver会将注册的Executor移除

14.6.1.1 后果分析

worker异常退出会带来哪些影响
1)executor退出导致提交的task无法正常结束,会被再一次提交运行
2)如果所有的worker都异常退出,则整个集群不可用
3)需要有相应的程序来重启worker进程,比如使用supervisord或runit

14.6.1.2 测试步骤

1)启动Master
2)启动worker
3)启动spark-shell
4)手工kill掉worker进程
5)用jps或ps -ef|grep -i java来查看启动着的java进程

14.6.1.3 异常退出的代码处理

定义于ExecutorRunner.scala的start函数

def start() {
  workerThread = new Thread("ExecutorRunner for " + fullId) {
    override def run() { fetchAndRunExecutor() }
  }
  workerThread.start()
  // Shutdown hook that kills actors on shutdown.
  shutdownHook = new Thread() {
    override def run() {
      killProcess(Some("Worker shutting down"))
    }
  }
  Runtime.getRuntime.addShutdownHook(shutdownHook)
}

killProcess的过程就是停止相应CoarseGrainedExecutorBackend的过程。
worker停止的时候,一定要先将自己启动的Executor停止掉。这是不是很像水浒中宋江的手段,李逵就是这样不明不白的把命给丢了。

14.6.1.4 小结

需要特别指出的是,当worker在启动Executor的时候,是通过ExecutorRunner来完成的,ExecutorRunner是一个独立的线程,和Executor是一对一的关系,这很重要。Executor作为一个独立的进程在运行,但会受到ExecutorRunner的严密监控。

14.6.2异常分析2: executor异常退出

大数据学习笔记之Spark(六):Spark内核解析_第209张图片
大数据学习笔记之Spark(六):Spark内核解析_第210张图片
其实executor在申请完之后,Executor既是属于当前worker的,也是属于当前的driver的,也就是说executor的心跳信息,一部分是传给worker的,一部分是传给driver的,这个时候如果executor坏了,这个时候首先driver会发现,然后worker也会发现,这个时候worker通知master之后,master会告诉worker重启这个executor,重启完这个executor之后,这个资源会新加入到driver中,对于上一个executor没有运行完的任务,这个driver会帮你去重新调度,所以在整个driver的运行过程中,executor的失败对于用户是透明的。

Executor作为Standalone集群部署方式下的最底层员工,一旦异常退出,其后果会是什么呢?
executor异常退出,ExecutorRunner注意到异常,将情况通过ExecutorStateChanged汇报给Master
Master收到通知之后,非常不高兴,尽然有小弟要跑路,那还了得,要求Executor所属的worker再次启动
Worker收到LaunchExecutor指令,再次启动executor

14.6.2.1 测试步骤

1)启动Master
2)启动Worker
3)启动spark-shell
4)手工kill掉CoarseGrainedExecutorBackend

14.6.2.2 fetchAndRunExecutor

fetchAndRunExecutor负责启动具体的Executor,并监控其运行状态,具体代码逻辑如下所示

def fetchAndRunExecutor() {
  try {
    // Create the executor's working directory
    val executorDir = new File(workDir, appId + "/" + execId)
    if (!executorDir.mkdirs()) {
      throw new IOException("Failed to create directory " + executorDir)
    }

    // Launch the process
    val command = getCommandSeq
    logInfo("Launch command: " + command.mkString("\"", "\" \"", "\""))
    val builder = new ProcessBuilder(command: _*).directory(executorDir)
    val env = builder.environment()
    for ((key, value)  {
      logInfo("Runner thread for executor " + fullId + " interrupted")
      state = ExecutorState.KILLED
      killProcess(None)
    }
    case e: Exception => {
      logError("Error running executor", e)
      state = ExecutorState.FAILED
      killProcess(Some(e.toString))
    }
  }
}

14.6.3异常分析3: master 异常退出

大数据学习笔记之Spark(六):Spark内核解析_第211张图片
大数据学习笔记之Spark(六):Spark内核解析_第212张图片
在master挂掉之后,首先master的作用只是分配,如果driver已经分配了资源,这个时候,任务,还是正常运行的,如果driver是没有分配资源,这个时候master整个就挂掉了,driver也就挂掉了,但是最终会有一个问题,executor运行完之后,不知道怎么关闭,也不知道怎么清除资源,这个时候,整个的情况,就会比较糟糕,启动master之后,需要重新run整个应用

worker和executor异常退出的场景都讲到了,我们剩下最后一种情况了,master挂掉了怎么办?
带头大哥如果不在了,会是什么后果呢?
1)worker没有汇报的对象了,也就是如果executor再次跑飞,worker是不会将executor启动起来的,大哥没给指令
2)无法向集群提交新的任务
3)老的任务即便结束了,占用的资源也无法清除,因为资源清除的指令是Master发出的
第15章wordcount程序运行原理窥探
15.1spark之scala实现wordcount
在spark中使用scala来实现wordcount(统计单词出现次数模型)更加简单,相对java代码上更加简洁,其函数式编程的思维逻辑也更加直观。

package com.spark.firstApp

import org.apache.spark.{SparkContext, SparkConf}

/**
  * Created by atguigu,scala实现wordcount
  */
object WordCount1 {
  def main(args: Array[String]) {
    if (args.length == 0) {
      System.err.println("Usage: WordCount1 ")
      System.exit(1)
    }
    /**
      * 1、实例化SparkConf;
      * 2、构建SparkContext,SparkContext是spark应用程序的唯一入口
      * 3. 通过SparkContext的textFile方法读取文本文件
      */
    val conf = new SparkConf().setAppName("WordCount1").setMaster("local")
    val sc = new SparkContext(conf)

    /**
      * 4、通过flatMap对文本中每一行的单词进行拆分(分割符号为空格),并利用map进行函数转换形成(K,V)形式,再进行reduceByKey,打印输出10个结果
      *    函数式编程更加直观的反映思维逻辑
      */
    sc.textFile(args(0)).flatMap(_.split(" ")).map(x => (x, 1)).reduceByKey(_ + _).take(10).foreach(println)
    sc.stop()
  }
}

15.2原理

在spark集群中运行wordcount程序其主要业务逻辑比较简单,涵盖一下3个过程:
1)读取存储介质上的文本文件(一般存储在hdfs上);
2)对文本文件内容进行解析,按照单词进行分组统计汇总;
3)将过程2的分组结果保存到存储介质上。(一般存储在hdfs或者RMDB上)
虽然wordcount的业务逻辑非常简单,但其应用程序在spark中的运行过程却巧妙得体现了spark的核心精髓——分布式弹性数据集、内存迭代以及函数式编程等特点。下图对spark集群中wordcount的运行过程进行剖析,加深对spark技术原理窥探。
大数据学习笔记之Spark(六):Spark内核解析_第213张图片
该图横向分割下面给出了wordcount的scala核心程序实现,该程序在spark集群的运行过程涉及几个核心的RDD,主要有textFileRDD、flatMapRDD、mapToPairRDD、shuffleRDD(reduceByKey)等。
应用程序通过textFile方法读取hdfs上的文本文件,数据分片的形式以RDD为统一模式将数据加载到不同的物理节点上,如上图所示的节点1、节点2到节点n;并通过一系列的数据转换,如利用flatMap将文本文件中对应每行数据进行拆分(文本文件中单词以空格为分割符号),形成一个以每个单词为核心新的数据集合RDD;之后通过MapRDD继续转换形成形成(K,V)数据形式,以便进一步使用reduceByKey方法,该方法会触发shuffle行为,促使不同的单词到对应的节点上进行汇聚统计(实际上在夸节点进行数据shuffle之前会在本地先对相同单词进行合并累加),形成wordcount的统计结果;最终通过saveAsTextFile方法将数据保存到hdfs上。具体的运行逻辑原理以及过程上图给出了详细的示意说明。

分配逻辑(重点看下,后面的视频中进行的补充)


执行这个registerApplication的时候,最后有一个schedule方法,在这个方法中进行分配

里面还是看自己的一个状态

再往下面分两块,第一块是对driver资源的一个分配,当前的schedule这个方法,不仅是在刚才的方法registeApp里面用,在requestSubmitDriver的方法里面也用了,所以在schedule的时候,他先会对这个叫driver的去分配资源
看一下之前的图

在spark-submit的时候driver client会给master endpoint发一个叫,requestSubmitDriver,master收到这个消息之后,会有一个luanchDriver的过程。
可以看到在master里面多处调用了这个schedule方法,就是一个公用的资源的分配方法 ,包括remove方法,requestSubmitDriver方法,在调requestSubmitDriver的时候,是调用了schedule的前半段

当我真正分配资源的时候,上面的launched其实已经是true了,所以不会走下面的while,然后就走到了最后的startExecutersOnWorkers,这个里面是具体的分配

所有的app注册进来之后都会放到waitingApps里面,corelest就是app申请的总的核数,但是这个总的核数,在没有分配资源之前,总的核数就是app提交的时候需要的总的核数,但是说没有分配资源之前这个总的核数就是app提交的时候需要的总的核数,但是说如果分配了一批资源,这个总的核数会减小,对每一个app来说,获取了一个coresPerExecutor,这个coresPerExecutor就是刚才的/bin/spark-shell的其中一个参数

即指定一个Executor里面用多少core,Executor里面如果没有指定的话,会返回一
往下看上面的注释,Fileter out workers that dont have enough resources to launch an executor ,对于所有的works,这个works就是master目前所持有的所有worker他的一个信息,转化成一个数组,然后filter,首先这个woker的状态是活着的,第二个worker的内存资源是要大于等于app申请的时候每一个executor的内存资源,即如下如:

如果不传参数的话,可以看到后面的default是1g,如果不传executor core的时候默认值是1,这个1的值体现在上面代码中的

同时还有一个worker.coreFree,他也应该是大于等于coresexecutor

上面的workers有两个数组,一个是usableWorkers,一个是assignedCores ,这两个数组是有下标的,如果下标相同的话,说明是在一个worker,加入两个下标都取的是1,这两个其实说的是一个worker,assignedCores说的是worker最终承诺给app分配的核数。

然后下面有一个循环,如果分配的核数大于0,首先把useableWorker里面不能分配核数的给去掉了,然后对于每一个useableWorker执行了一个allocateWorkerResourceToExecutors方法,这个方法视具体的分配资源的

发现下面还有一个循环,许多博客上说一个woker只能分配一个executor对于一个应用来说,但是通过看真实的代码,这里的for循环能看看到其实是支持一个worker分配多个Executor的,但是会出现问题
上面代码中的第一行,coresPerExecutor比如说是大于一的,指明了,假如是五,这个时候assignedCores是10,这个时候numberExecutor就是2,按照这个逻辑就可以在当前的woker上分配两个executor,但是一般不会出现这种情况,这个限制会在刚才的assignedCores里面,也就是说最核心的是,往上翻

先点到scheduleExecutorsOnWorkers这个方法

sparedOutApp,有两种方式,two modes,比如要了十个core,有五个worker,会把这十个core怎么分配呢?先分配到,如果sparedOutApp是true的情况,会把第一个core分配到第一个worker上,然后第二个core第三个core分别分配到234个worker上,第五个core也分配到第五个worker上,当遇到第六个core的时候再把第六个core分配到第一个worker上,所以会尽量的把这十个core打散到以这种轮询的方式每一个worker上面,如果sparedOutApp为false,这个时候会,比如第一个work,有三个空闲的core,直接把三个空闲的core全部拿走了,然后再往下轮询,比如worke2 有四个空闲的core,那么就把四个空闲的core都拿走,worker3有三个,然后全部拿走,拿走完了之后总共的core数就已经够了,所以不会再轮询work4和work5了,就是这两种分配算法
这两种分配算法会体现在哪里呢?

会体现在这两个for 循环里面大数据学习笔记之Spark(六):Spark内核解析_第214张图片
如果在/bin/spark-shell里面配置–num-executors,oneExecutorPerWorker就是true的,也就是说允许 生成Executor,但是如果没有指定,就不可以了

获得usableWorkers的长度,通过这个长度,创建了两个数组,所以这里是分配的cores和分配的Executor
coresToAssign取了最小app coresLeft即当前的app还没有分配的core的总数usableWorkers,能分配的cores总数加了一下。也就是说/bin/spark-shell在申请的时候,参数–total-executor-cores填了一万,但是真正有的cores才5,会默认分配5,不会分配10000,所以这里相当于是一个保险

再往下面看得话,可以看到定义了一个函数叫做canLaunchExecutor,就是为了检测当前的worker是不是能够启动一个Executor,往下走的一个很重要的东西叫做keepScheduling,就是当前的core要>=还没有分配的cores。
再往下能够看到enoughCores,就是当前的worker剩余的core-分配出去的core,因为对于这个canLaunchExecutor来说是支持一个worker分配多个Executor的,第一遍的时候能够发现assignCores其实是0,因为他还没有分配,

再往下

首先检测了下是不是能够launchExecutor

往下,能够看到嵌套的两个循环
大数据学习笔记之Spark(六):Spark内核解析_第215张图片
如果freeWorkers是不空的,也就是有这个freeworkers,那么就满足了,对于freeworkers里面的每一个都做了什么操作呢?进来之后把keepScheduling设置为true,然后canLuanchExecutor
是true,keepScheduling也是true,就会进入下面的循环,进入这个循环的时候,coresToassign-minCoresPerExecutor,minCoresPerExecutor就是你所需要的Executor当前的核数,为什么减掉他呢?因为既然能进来,worker就已经能分配了,既然能够分配了,需要把分配的core加入到assignedCore里面,然后总数减去他。
往下再看,如果OneExecutorPerWorker是true的话,也就是上面的哪个参数,即/bin/spark-shell中没有配置,这个时候assignexecutor就恒等于1,否则就++

如果spread为true的时候,这个keepSchedule就是false,因为是false了所以会跳出如下这个循环

跳出这个循环了就会执行下面这个freeworks,如果spread是false的话,就会一直循环,直到canLaunchedExecutor为false,也就是当前的workers可用的core已经不足了。
在方法的最后返回了assignedCores,即所有可用的usableWorkers,最终返回的这个cores,大数据学习笔记之Spark(六):Spark内核解析_第216张图片

举个
大数据学习笔记之Spark(六):Spark内核解析_第217张图片
看下会怎么分配
大数据学习笔记之Spark(六):Spark内核解析_第218张图片
可以看到两个Executor,被分别分配在两个worker上,每个两个core,1024

这个时候假如设定executor memory 500m


可以看到在每个worker上生成两个executor,但是虽然生成了,但是是不能用的。

然后点击Executor发现里面的Executor是空
大数据学习笔记之Spark(六):Spark内核解析_第219张图片
这个时候如果执行一个Rdd,会得到一个错误

意思是没有足够的资源
平常在操作的时候,可能就会出现,退出了之后core还是被占用的的,这个时候启动shell的时候就会宝上面的错误,告诉你没有足够的资源分配,虽然说你的shell终端已经能够起来了。

还有一个需要注意的就是最终提交stage的时候,是在runJob

这个方法会再调用一个runjob方法,是一个重载

可以看到还是调用了DagSchedule的runJob方法

点进去之后,通过submitJob方法返回一个waiter,可以看到是一个回调机制,然后进入这个submitjob,进入这个submitjob

先不管前边做了哪些操作

这个jobSubmit就是一个job的封装,这个eventProcessLoop是什么?
再点进去
继承了eventLoop

点进去这个eventLoop
大数据学习笔记之Spark(六):Spark内核解析_第220张图片
上面看到把进来的数据放到阻塞队列里面,然后开启一个线程,然后从队列里面取元素,然后后调用onRecieve方法。
所以刚才在submit的时候已经把消息提交到了阻塞队列中,所以会被onRecieve处理

之后会被doOnRecieve处理,

看到第一种情况,进入相应的handle方法,所以刚才所以submit的对象是被handle这个方法处理的
会看到首先 有一个随后的stage,即最后调用action的stage,然后submitStage

看到这边会有一个递归

递归的条件是当前的stage没有父stage,怎么找的呢?可以看到上面的代码中有个方法getMissingParentStage。父stage的意思就是当前的rdd的依赖不应该是宽依赖了,因为有宽依赖的时候,就会打散成一个新的stage
所以进入这个方法getMissingParentStage

在这个visit里面做了一个查询,如果说当前的stage,他的RDD是shuffleDependency,即宽依赖的时候,说明事由parentstage的,如果是窄依赖的时候是没有parentstage的,因为如果是宽依赖的时候,上面可以看到getOrCreateShuffleMapStage就直接放到了missing里面,就获得了parentStage,然后就返回missing了

然后我们回来了

因为这个missing其实是直接父的stage,没有祖父stage,祖父stage就是通过下面的for循环有submitStage的递归。知道没有了父stage就执行submitMissingTasks,点进去这个方法,然后往下先走。

把每个分片的任务,都封装成了shuffleMapTask,这是一个task集合,最后这个task集合,最后会被,再往下翻
直接提交,提交的时候封装成了taskSet

你可能感兴趣的:(Big,Data,Cloud,Technology❤️,#,BigData,------,Spark)