1.Spark 底层逻辑
导读
- 从部署图了解
Spark
部署了什么, 有什么组件运行在集群中 - 通过对
WordCount
案例的解剖, 来理解执行逻辑计划的生成 - 通过对逻辑执行计划的细化, 理解如何生成物理计划
如无特殊说明, 以下部分均针对于 Spark Standalone 进行介绍 |
---|
部署情况
在 Spark
部分的底层执行逻辑开始之前, 还是要先认识一下 Spark
的部署情况, 根据部署情况, 从而理解如何调度.
针对于上图, 首先可以看到整体上在集群中运行的角色有如下几个:
-
Master Daemon
负责管理
Master
节点, 协调资源的获取, 以及连接Worker
节点来运行Executor
, 是 Spark 集群中的协调节点 -
Worker Daemon
Workers
也称之为叫Slaves
, 是 Spark 集群中的计算节点, 用于和 Master 交互并管理Executor
.当一个
Spark Job
提交后, 会创建SparkContext
, 后Worker
会启动对应的Executor
. -
Executor Backend
上面有提到
Worker
用于控制Executor
的启停, 其实Worker
是通过Executor Backend
来进行控制的,Executor Backend
是一个进程(是一个JVM
实例), 持有一个Executor
对象
另外在启动程序的时候, 有三种程序需要运行在集群上:
-
Driver
Driver
是一个JVM
实例, 是一个进程, 是Spark Application
运行时候的领导者, 其中运行了SparkContext
.Driver
控制Job
和Task
, 并且提供WebUI
. -
Executor
Executor
对象中通过线程池来运行Task
, 一个Executor
中只会运行一个Spark Application
的Task
, 不同的Spark Application
的Task
会由不同的Executor
来运行
案例
因为要理解执行计划, 重点不在案例, 所以本节以一个非常简单的案例作为入门, 就是我们第一个案例 WordCount
val sc = ...
val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")
println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))
整个案例的运行过程大致如下:
- 通过代码的运行, 生成对应的
RDD
逻辑执行图 - 通过
Action
操作, 根据逻辑执行图生成对应的物理执行图, 也就是Stage
和Task
- 将物理执行图运行在集群中
逻辑执行图
对于上面代码中的 reduceRDD
如果使用 toDebugString
打印调试信息的话, 会显式如下内容
(6) MapPartitionsRDD[4] at map at WordCount.scala:20 []
| ShuffledRDD[3] at reduceByKey at WordCount.scala:19 []
+-(6) MapPartitionsRDD[2] at map at WordCount.scala:18 []
| MapPartitionsRDD[1] at flatMap at WordCount.scala:17 []
| ParallelCollectionRDD[0] at parallelize at WordCount.scala:16 []
根据这段内容, 大致能得到这样的一张逻辑执行图
其实 RDD 并没有什么严格的逻辑执行图和物理执行图的概念, 这里也只是借用这个概念, 从而让整个 RDD 的原理可以解释, 好理解.
对于 RDD 的逻辑执行图, 起始于第一个入口 RDD 的创建, 结束于 Action 算子执行之前, 主要的过程就是生成一组互相有依赖关系的 RDD, 其并不会真的执行, 只是表示 RDD 之间的关系, 数据的流转过程.
物理执行图
当触发 Action 执行的时候, 这一组互相依赖的 RDD 要被处理, 所以要转化为可运行的物理执行图, 调度到集群中执行.
因为大部分 RDD 是不真正存放数据的, 只是数据从中流转, 所以, 不能直接在集群中运行 RDD, 要有一种 Pipeline 的思想, 需要将这组 RDD 转为 Stage 和 Task, 从而运行 Task, 优化整体执行速度.
以上的逻辑执行图会生成如下的物理执行图, 这一切发生在 Action 操作被执行时.
从上图可以总结如下几个点
- 在第一个
Stage
中, 每一个这样的执行流程是一个Task
, 也就是在同一个 Stage 中的所有 RDD 的对应分区, 在同一个 Task 中执行 - Stage 的划分是由 Shuffle 操作来确定的, 有 Shuffle 的地方, Stage 断开
1.1. 逻辑执行图生成
导读
- 如何生成 RDD
- 如何控制 RDD 之间的关系
6.1.1. RDD 的生成
重点内容
本章要回答如下三个问题
- 如何生成 RDD
- 生成什么 RDD
- 如何计算 RDD 中的数据
val sc = ...
val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")
println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))
明确逻辑计划的边界
在 Action
调用之前, 会生成一系列的 RDD
, 这些 RDD
之间的关系, 其实就是整个逻辑计划
例如上述代码, 如果生成逻辑计划的, 会生成如下一些 RDD
, 这些 RDD
是相互关联的, 这些 RDD
之间, 其实本质上生成的就是一个 计算链
接下来, 采用迭代渐进式的方式, 一步一步的查看一下整体上的生成过程
textFile
算子的背后
研究 RDD
的功能或者表现的时候, 其实本质上研究的就是 RDD
中的五大属性, 因为 RDD
透过五大属性来提供功能和表现, 所以如果要研究 textFile
这个算子, 应该从五大属性着手, 那么第一步就要看看生成的 RDD
是什么类型的 RDD
-
textFile
生成的是HadoopRDD
除了上面这一个步骤以外, 后续步骤将不再直接基于代码进行讲解, 因为从代码的角度着手容易迷失逻辑, 这个章节的初心有两个, 一个是希望大家了解 Spark 的内部逻辑和原理, 另外一个是希望大家能够通过本章学习具有代码分析的能力 -
HadoopRDD
的Partitions
对应了HDFS
的Blocks
其实本质上每个
HadoopRDD
的Partition
都是对应了一个Hadoop
的Block
, 通过InputFormat
来确定Hadoop
中的Block
的位置和边界, 从而可以供一些算子使用 -
HadoopRDD
的compute
函数就是在读取HDFS
中的Block
本质上,
compute
还是依然使用InputFormat
来读取HDFS
中对应分区的Block
-
textFile
这个算子生成的其实是一个MapPartitionsRDD
textFile
这个算子的作用是读取HDFS
上的文件, 但是HadoopRDD
中存放是一个元组, 其Key
是行号, 其Value
是Hadoop
中定义的Text
对象, 这一点和MapReduce
程序中的行为是一致的但是并不适合
Spark
的场景, 所以最终会通过一个map
算子, 将(LineNum, Text)
转为String
形式的一行一行的数据, 所以最终textFile
这个算子生成的RDD
并不是HadoopRDD
, 而是一个MapPartitionsRDD
map
算子的背后
-
map
算子生成了MapPartitionsRDD
由源码可知, 当
val rdd2 = rdd1.map()
的时候, 其实生成的新RDD
是rdd2
,rdd2
的类型是MapPartitionsRDD
, 每个RDD
中的五大属性都会有一些不同, 由map
算子生成的RDD
中的计算函数, 本质上就是遍历对应分区的数据, 将每一个数据转成另外的形式 -
MapPartitionsRDD
的计算函数是collection.map( function )
真正运行的集群中的处理单元是
Task
, 每个Task
对应一个RDD
的分区, 所以collection
对应一个RDD
分区的所有数据, 而这个计算的含义就是将一个RDD
的分区上所有数据当作一个集合, 通过这个Scala
集合的map
算子, 来执行一个转换操作, 其转换操作的函数就是传入map
算子的function
-
传入
map
算子的函数会被清理这个清理主要是处理闭包中的依赖, 使得这个闭包可以被序列化发往不同的集群节点运行
flatMap
算子的背后
flatMap
和 map
算子其实本质上是一样的, 其步骤和生成的 RDD
都是一样, 只是对于传入函数的处理不同, map
是 collect.map( function )
而 flatMap
是 collect.flatMap( function )
从侧面印证了, 其实 Spark
中的 flatMap
和 Scala
基础中的 flatMap
其实是一样的
textRDD
→ splitRDD
→ tupleRDD
由 textRDD
到 splitRDD
再到 tupleRDD
的过程, 其实就是调用 map
和 flatMap
算子生成新的 RDD
的过程, 所以如下图所示, 就是这个阶段所生成的逻辑计划
总结
如何生成 RDD
?
生成 RDD
的常见方式有三种
- 从本地集合创建
- 从外部数据集创建
- 从其它
RDD
衍生
通过外部数据集创建 RDD
, 是通过 Hadoop
或者其它外部数据源的 SDK
来进行数据读取, 同时如果外部数据源是有分片的话, RDD
会将分区与其分片进行对照
通过其它 RDD
衍生的话, 其实本质上就是通过不同的算子生成不同的 RDD
的子类对象, 从而控制 compute
函数的行为来实现算子功能
生成哪些 RDD
?
不同的算子生成不同的 RDD
, 生成 RDD
的类型取决于算子, 例如 map
和 flatMap
都会生成 RDD
的子类 MapPartitions
的对象
如何计算 RDD
中的数据 ?
虽然前面我们提到过 RDD
是偏向计算的, 但是其实 RDD
还只是表示数据, 纵观 RDD
的五大属性中有三个是必须的, 分别如下
Partitions List
分区列表Compute function
计算函数Dependencies
依赖
虽然计算函数是和计算有关的, 但是只有调用了这个函数才会进行计算, RDD
显然不会自己调用自己的 Compute
函数, 一定是由外部调用的, 所以 RDD
更多的意义是用于表示数据集以及其来源, 和针对于数据的计算
所以如何计算 RDD
中的数据呢? 一定是通过其它的组件来计算的, 而计算的规则, 由 RDD
中的 Compute
函数来指定, 不同类型的 RDD
子类有不同的 Compute
函数