Spark学习笔记
一、Spark是什么?
Spark是一种基于内存的快速、通用、可扩展的大数据分析计算引擎。(不做存储,对比Hadoop来看,Hadoop是海量数据的存储和计算工具,主要是能够用来存储,计算的话Hadoop中的MapReduce框架比较简单,计算速度慢,基于硬盘。)
但是,正是由于Spark基于内存,所以在实际的生产环境中,由于内存的限制,可能会由于内存资源不够导致 Job 执行失败,此时,MapReduce 其实是一个更好的选择,所以 Spark 并不能完全替代 MR。
1 Spark的核心组件
- 1)Spark Core 中提供了 Spark 最基础与最核心的功能,Spark 其他的功能如:Spark SQL,Spark Streaming,GraphX, MLlib 都是在 Spark Core 的基础上进行扩展的.
- 2)Spark SQL 是 Spark 用来操作结构化数据的组件。通过 Spark SQL,用户可以使用 SQL 或者 Apache Hive 版本的 SQL 方言(HQL)来查询数据。
- 3)Spark Streaming 是 Spark 平台上针对实时数据进行流式计算的组件,提供了丰富的处理数据流的 API。
- 4)Spark MLlib 是 Spark 提供的一个机器学习算法库。MLlib 不仅提供了模型评估、数据导入等额外的功能,还提供了一些更底层的机器学习原语。
- 5)Spark GraphX 是 Spark 面向图计算提供的框架与算法库。
2 Spark 运行环境
2.1 Local 模式
所谓的 Local 模式,就是不需要其他任何节点资源就可以在本地执行 Spark 代码的环境,一般用于教学,调试,演示等,之前在 IDEA 中运行代码的环境我们称之为开发环境,不太一样。
这个模式也就是指,并没有运行在集群环境上,而是直接在服务器本机上运行。
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master local[2] \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
- –class 表示要执行程序的主类,此处可以更换为咱们自己写的应用程序
- –master local[2] 部署模式,默认为本地模式,数字表示分配的虚拟 CPU 核数量
- spark-examples_2.12-3.0.0.jar 运行的应用类所在的 jar 包,实际使用时,可以设定为咱们自己打的 jar 包
- 数字 10 表示程序的入口参数,用于设定当前应用的任务数量
2.2 Standalone 模式
本地模式是用来进行练习演示的,真实工作中还是要将应用提交到对应的集群中去执行,这里我们来看看只使用 Spark 自身节点运行的集群模式,也就是我们所谓的独立部署(Standalone)模式。Spark 的 Standalone 模式体现了经典的 master-slave 模式。
集群规划:
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://linux1:7077 \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
- –class 表示要执行程序的主类
- –master spark://linux1:7077 独立部署模式,连接到 Spark 集群,这里应该具体对应自己搭建的服务器地址
- spark-examples_2.12-3.0.0.jar 运行类所在的 jar 包
- 数字 10 表示程序的入口参数,用于设定当前应用的任务数量
任务提交时参数设置:
2.3 配置高可用(HA )
所谓的高可用是因为当前集群中的 Master 节点只有一个,类似于Hadoop集群会存在单点故障问题。所以为了解决单点故障问题,需要在集群中配置多个 Master 节点,一旦处于活动状态的 Master 发生故障时,由备用 Master 提供服务,保证作业可以继续执行。这里的高可用和Hadoop集群一样采用 Zookeeper 进行设置。Zookeeper中运行着一个检查程序,用于判断当前处于active的master节点是否运行正常,如果不正常则将另一台处于standby的master节点置为active状态。具体运行原理查看Hadoop学习笔记中的第六章NameNode的HA高可用。
在配置高可用之后,我们在提交任务时也会有所变化。
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://linux1:7077,linux2:7077 \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
- master 后面要写上你所配置了HA的两个节点地址。
2.4 Yarn 模式
独立部署(Standalone)模式由 Spark 自身提供计算资源,无需其他框架提供资源。这种方式降低了和其他第三方资源框架的耦合性,独立性非常强。但是你也要记住,Spark 主要是计算框架,而不是资源调度框架,所以本身提供的资源调度并不是它的强项,所以还是和其他专业的资源调度框架集成会更靠谱一些。所以接下来我们来学习在强大的 Yarn 环境下 Spark 是如何工作的(其实是因为在国内工作中,Yarn 使用的非常多)。
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode client \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
2.5 K8S & Mesos 模式
Mesos 是 Apache 下的开源分布式资源管理框架,它被称为是分布式系统的内核,在Twitter 得到广泛使用,管理着 Twitter 超过 30,0000 台服务器上的应用部署,但是在国内,依然使用着传统的 Hadoop 大数据框架,所以国内使用 Mesos 框架的并不多,但是原理其实都差不多,这里我们就不做过多讲解了。
容器化部署是目前业界很流行的一项技术,基于 Docker 镜像运行能够让用户更加方便地对应用进行管理和运维。容器管理工具中最为流行的就是 Kubernetes(k8s),而 Spark 也在最近的版本中支持了 k8s 部署模式。
2.6 Windows 模式
Spark 提供了可以在 windows 系统下启动本地集群的方式,这样,在不使用虚拟机的情况下,也能学习 Spark 的基本使用。
3 Spark 运行架构
3.1 运行架构
Spark 框架的核心是一个计算引擎,整体来说,它采用了标准 master-slave 的结构。
如下图所示,它展示了一个 Spark 执行时的基本结构。图形中的 Driver 表示 master,负责管理整个集群中的作业任务调度。图形中的 Executor 则是 slave,负责实际执行任务。
3.2 核心组件
由上图可以看出,对于 Spark 框架有两个核心组件:
(下面这部分可以结合Hadoop学习笔记中的第四章Yarn调度器的调度机制进行学习。)
3.2.1 Driver
Spark 驱动器节点,用于执行 Spark 任务中的 main 方法,负责实际代码的执行工作。Driver 在 Spark 作业执行时主要负责:
- 将用户程序转化为作业(job)
- 在 Executor 之间调度任务(task)
- 跟踪 Executor 的执行情况
- 通过 UI 展示查询运行情况
实际上,我们无法准确地描述 Driver 的定义,因为在整个的编程过程中没有看到任何有关Driver 的字眼。所以简单理解,所谓的 Driver 就是驱使整个应用运行起来的程序,也称之为Driver 类。
3.2.2 Executor
Spark Executor 是集群中工作节点(Worker)中的一个 JVM 进程,负责在 Spark 作业中运行具体任务(Task),任务彼此之间相互独立。Spark Application 启动时,Executor 节点被同时启动,并且始终伴随着整个 Spark 应用的生命周期而存在。如果有 Executor 节点发生了故障或崩溃,Spark 应用也可以继续执行,会将出错节点上的任务调度到其他 Executor 节点上继续运行。
Executor 有两个核心功能:
- 负责运行组成 Spark Application 的任务,并将结果返回给驱动器进程。
- 它们通过自身的块管理器(Block Manager)为用户程序中要求缓存的 RDD 提供内存式存储。RDD 是直接缓存在 Executor 进程内的,因此任务可以在运行时充分利用缓存数据加速运算。
3.2.3 Master & Worker
Spark 集群的独立部署环境中,不需要依赖其他的资源调度框架,自身就实现了资源调度的功能,所以环境中还有其他两个核心组件:Master 和 Worker,这里的 Master 是一个进程,主要负责资源的调度和分配,并进行集群的监控等职责,类似于 Yarn 环境中的 RM,而Worker 也是进程,一个 Worker 运行在集群中的一台服务器上,由 Master 分配资源对数据进行并行的处理和计算,类似于 Yarn 环境中 NM。
3.2.4 ApplicationMaster
Hadoop 用户向 YARN 集群提交应用程序时,提交程序中应该包含 ApplicationMaster,用于向资源调度器申请执行任务的资源容器 Container,运行用户自己的程序任务 job,监控整个任务的执行,跟踪整个任务的状态,处理任务失败等异常情况。说的简单点就是,ResourceManager(资源)和 Driver(计算)之间的解耦合靠的就是 ApplicationMaster。
二、SprakCore 核心编程
2.1 RDD
RDD(Resilient Distributed Dataset)叫做弹性分布式数据集(RDD本身不保存数据,其中保存的是流经这个RDD的数据所要做的逻辑),是 Spark 中最基本的数据处理模型。代码中是一个抽象类,它代表一个弹性的、不可变、可分区、里面的元素可并行计算的集合。RDD的数据处理方式类似于IO流,也叫装饰者设计模式。但是RDD是不保存数据,没有缓存,只进行操作,而实际的IO过程有时会使用Buffer暂存一些数据。
RDD数据在调用collect()方法时,会将所有分布式的节点上的数据拉取到Driver所在的节点,然后将数据变成数组类型,这样不利于数据的分布式计算,所以在实际的集群模式中设计spark程序时,推荐不使用collect()方法。
- 弹性
- 存储的弹性:内存与磁盘的自动切换;
- 容错的弹性:数据丢失可以自动恢复;
- 计算的弹性:计算出错重试机制;
- 分片的弹性:可根据需要重新分片。
- 分布式:数据存储在大数据集群不同节点上
- 数据集:RDD 封装了计算逻辑,并不保存数据
- 数据抽象:RDD 是一个抽象类,需要子类具体实现
- 不可变:RDD 封装了计算逻辑,是不可以改变的,想要改变,只能产生新的 RDD,在新的 RDD 里面封装计算逻辑
- 可分区、并行计算
2.1.1 RDD核心属性
- 1)分区列表:RDD 数据结构中存在分区列表,用于执行任务时并行计算,是实现分布式计算的重要属性。
- 2)分区计算函数:Spark 在计算时,是使用分区函数对每一个分区进行计算,对于同一个任务中的所有分区来说,分区计算函数是相同的。
- 3)RDD间的依赖关系:RDD 是计算模型的封装,当需求中需要将多个计算模型进行组合时,就需要将多个 RDD 建立依赖关系
- 4)分区器(可选):用于将一个 Task 中涉及到的数据进行分区,类似于Hadoop中的getPartitioner()方法。
- 5)首选位置(可选):计算数据时,可以根据计算节点的状态选择不同的节点位置进行计算。类似于Hadoop中的机架感知。移动数据不如移动计算。
2.1.2 执行原理
从计算的角度来讲,数据处理过程中需要计算资源(内存 & CPU)和计算模型(逻辑)。执行时,需要将计算资源和计算模型进行协调和整合。
Spark 框架在执行时,先申请资源,然后将应用程序的数据处理逻辑分解成一个一个的计算任务。然后将任务发到已经分配资源的计算节点上, 然后各个节点根据数据分区拉取自己需要处理的数据,按照指定的计算模型进行数据计算,最后得到计算结果。
一个Spark任务的执行过程如下:(以YARN环境为例)
在YARN中,RM是整个集群的老大,管理着所有的节点资源(可能也包括自己)。
一个Spark任务被提交后,RM会给其分配一个NM或者说在某一个NM(具体位置应该也会涉及到机架感知、首选位置等因素的影响)中开启一个Executor,来运行这个任务或者叫任务的Driver,Driver在运行的过程中就会知道,自己的数据要怎么进行分区,分成多少个区,每个区需要什么样的数据(但是这个过程还没有具体地进行数据的处理),并根据分区的数目进行子任务的划分,然后将任务放到任务池中,等待本任务调度节点的调度。
Driver会申请分区数量+1个Executor来进行数据处理。分区数量对应计算节点,加的1代表调度节点。(此处的节点和物理的节点可以是一一对应的,也可以不是,因为一个物理节点如果计算性能比较强,可以同时开启多个Exector,Executor才是真正进行代码运算的“节点”。)
调度节点在调度任务时,会考虑节点的工作状态以及这个任务所需的实际数据所在物理存储位置(机架感知\首选位置),尽可能的避免大量数据在网络上的传输,减少任务的处理时间。
2.1.3 Spark的分区操作
1.如果采取的是makeRDD()方法创建的RDD,则分区方式是下面这种:
如果spark运行的环境是local的话,分区数是通过获取本地 CPU 核数来确定的(即使是集群环境也是通过获取 executor 的核数来确定的):
// local
def defaultParallelism: Int = {
assertNotStopped()
taskScheduler.defaultParallelism // 这个数值就是totalCores
}
// distributed
override def defaultParallelism(): Int = {
conf.getInt("spark.default.parallelism", math.max(totalCoreCount.get(), 2))
}
与Hadoop的默认的进行数据分区的方法不同,Hadoop使用数据的Hash值进行取余运算的到的不同的分区号。而Spark则是首先对要处理的数据的类型进行判断:
seq match {
case r: Range => ...... \\ seq的数据类型是Range范围?
case nr: NumericRange[_] => ...... \\ seq的数据类型是NumericRange?
case _ =>
val array = seq.toArray // To prevent O(n^2) operations for List etc
positions(array.length, numSlices).map { case (start, end) =>
array.slice(start, end).toSeq
}.toSeq
}
然后通过下面 positions() 函数对数据进行的切分:
def positions(length: Long, numSlices: Int): Iterator[(Int, Int)] = {
(0 until numSlices).iterator.map { i =>
val start = ((i * length) / numSlices).toInt
val end = (((i + 1) * length) / numSlices).toInt
(start, end)
}
}
2.如果是从文件中加载数据使用的是textFile()方法创建RDD,则其分区的方法与Hadoop中MapTask阶段分片的方法比较类似。
// 这是默认的分区的数目,取的是 defaultParallelism(代表totalCores当前spark运行环境中可以使用的核数) 和 2 中较小的一个。
def defaultMinPartitions: Int = math.min(defaultParallelism, 2)
而通过上面的方法spark获取到的是 minPartitions ,实际的分区数有可能比这个数值要大。原因是在源码中spark对文件的读取与分片方式与 Hadoop 中 MapTask 阶段的分片方式是一样的,有个 1.1 倍的概念,也就是说,当前剩余等待切片的数据如果大于分片大小的 1.1 倍时才会进行分片,否则不会在进行分片处理,而是将剩余的 < 1.1 倍切片大小的数据划分为一块。
(参考Hadoop中MapTask的并行度决定方式中的切片原则)
比如说,我们读取到的文件的大小为 7B ,我们获取到的 minPartitions = 2 ,也就是最小分区数为2。所以应当说,我们将文件分成 2 个分区,每一个分区为 3.5B 大小,但是不能有小数,所以实际结果是分区大小为 3B ,7 - 3 = 4 。经过次一分区的划分后还余下 4B。而这一部分的数据由于 4B / 3B = 133.3% > 110%,所以根据Hadoop的分片原则,需要再进行一次分区 4 - 3 = 1B。现在 1B / 3B = 33.3% > 10% 所以,不再进行分区,剩余的数据单独算做一片。在spark中也就是对应单独的一个分区。所以最终的结果是:7B 大小的文件,在minPartitions = 2 的设置下最终分成了 3 个分区。
也可以这样理解,读到的文件大小为7B,minPartitions = 2,所以分区大小应为 7 / 2 = 3.5B,向下取整为 3B,7B大小的文件进行两次分区操作后剩余了 1B 大小的数据,又由于 1B / 3B = 33.3% > 10%,所以它不能与上一个分区进行合并,必须自己换分成单独的一各分区。假设,剩下的数据为 0.1B / 3B = 3.33% < 10% ,这时剩余的这些数据就可以和上一个分区合拼成一个分区了。
2.1.4 RDD 方法又叫做 RDD 算子
RDD算子的叫法由来:在认知心理学中认为问题的解决其实就将问题从初始状态进行改变直到问题变成完成状态。而中间经过的这个“操作”就叫做算子(Operator)。
问题(初始) => 经过某个操作(算子) => 问题(审核中) => 经过某个操作(算子) => 问题(完成)
- 1)转换算子
- 功能的补充和封装,将旧的RDD包装成新的RDD
- 比如:flatMap() 方法和 map() 方法
- 2)行动算子
- 触发RDD转换方法的调度和作业的执行
- 比如: collect() 方法
2.1.4.1 RDD 转换算子
RDD根据数据处理方式的不同将算子整体上分为单 Value 类型、双 Value类型和 Key-Value 类型。
- 单 Value类型(对一个rdd进行操作)
- 1)map
- 函数说明:将处理的数据逐条进行映射转换,这里的转换可以是类型的转换,也可以是值的转换。
- 函数签名
def map[U:ClassTag](f: T => U): RDD[U]
- 2)mapPartitions
- 函数说明:将待处理的数据以分区为单位发送到计算节点进行处理,这里的处理是指可以进行任意的处理,哪怕是过滤数据。但是由于是通过迭代器来获取每一条数据,所以在处理数据时比较占用内存。
- 函数签名
def mapPartitions[U: ClassTag](
f: Iterator[T] =>
Iterator[U], preservesPartitioning: Boolean = false): RDD[U]
map 和 mapPartitions 的区别?
➢ 数据处理角度
map 算子是分区内一个数据一个数据的执行,类似于串行操作。而 mapPartitions 算子是以分区为单位进行批处理操作。
➢ 功能的角度
map 算子主要目的将数据源中的数据进行转换和改变。但是不会减少或增多数据。mapPartitions 算子需要传递一个迭代器,返回一个迭代器,没有要求的元素的个数保持不变,所以可以增加或减少数据。
➢ 性能的角度
map 算子因为类似于串行操作,所以性能比较低,而是 mapPartitions 算子类似于批处理,所以性能较高。但是 mapPartitions 算子会长时间占用内存,那么这样会导致内存可能不够用,出现内存溢出的错误。所以在内存有限的情况下,不推荐使用。使用 map 操作。
- 3)mapPartitionsWithIndex
- 函数说明:将待处理的数据以分区为单位发送到计算节点进行处理,这里的处理是指可以进行任意的处理,哪怕是过滤数据,在处理时同时可以获取当前分区索引。
- 函数签名
def mapPartitionsWithIndex[U: ClassTag](
f: (Int, Iterator[T]) =>
Iterator[U], preservesPartitioning: Boolean = false): RDD[U]
- 4)flatMap
- 函数说明:将处理的数据进行扁平化后再进行映射处理,所以此算子也称之为扁平映射。
- 函数签名
def flatMap[U: ClassTag](
f: T => TraversableOnce[U]): RDD[U]
- 5)glom
- 函数说明:将同一个分区的数据直接转换为相同类型的内存数组进行处理,分区不变。
- 函数签名
def glom(): RDD[Array[T]]
- 6)groupBy
- 函数说明:将数据根据指定的规则进行分组, 分区数目默认不变,但是数据会被打乱重新组合,我们将这样的操作称之为 shuffle。极限情况下,数据可能被分在同一个分区中。一个组的数据在一个分区中,但是并不是说一个分区中只有一个组。
- 函数签名
def groupBy[K](f: T => K)(implicit kt: ClassTag[K]): RDD[(K, Iterable[T])]
- 7)filter
- 函数说明:将数据根据指定的规则进行筛选过滤,符合规则的数据保留,不符合规则的数据丢弃。当数据进行筛选过滤后,分区不变,但是分区内的数据可能不均衡,生产环境下,可能会出现数据倾斜。
- 函数签名
def filter(f: T => Boolean): RDD[T]
- 8)sample
- 函数说明:根据指定的规则从数据集中抽取数据
- 函数签名
def sample(
withReplacement: Boolean,
fraction: Double,
seed: Long = Utils.random.nextLong): RDD[T]
- 9)distinct
- 函数说明:将数据集中重复的数据去重
- 函数签名
def distinct()(implicit ord: Ordering[T] = null): RDD[T]
def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]
- 10)coalesce
- 函数说明:根据数据量缩减分区,用于大数据集过滤后,提高小数据集的执行效率。当 spark 程序中,存在过多的小任务的时候,可以通过 coalesce 方法,收缩合并分区,减少分区的个数,减小任务调度成本
- 函数签名
def coalesce(
numPartitions: Int,
shuffle: Boolean = false,
partitionCoalescer: Option[PartitionCoalescer] = Option.empty)
(implicit ord: Ordering[T] = null): RDD[T]
- 11)repartition
- 函数说明:该操作内部其实执行的是 coalesce 操作,参数 shuffle 的默认值为 true。无论是将分区数多的RDD 转换为分区数少的 RDD,还是将分区数少的 RDD 转换为分区数多的 RDD,repartition操作都可以完成,因为无论如何都会经 shuffle 过程。
- 函数签名
def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]
- 12)sortBy
- 函数说明:该操作用于排序数据。在排序之前,可以将数据通过 f 函数进行处理,之后按照 f 函数处理的结果进行排序,默认为升序排列。排序后新产生的 RDD 的分区数与原 RDD 的分区数一致。中间存在 shuffle 的过程
- 函数签名
def sortBy[K](
f: (T) => K,
ascending: Boolean = true,
numPartitions: Int = this.partitions.length)
(implicit ord: Ordering[K], ctag: ClassTag[K]): RDD[T]
- 双value类型(需要传递一个rdd)
- 13)intersection
- 函数说明:对源 RDD 和参数 RDD 求交集后返回一个新的 RDD
- 函数签名
def intersection(other: RDD[T]): RDD[T]
- 14)union
- 函数说明:对源 RDD 和参数 RDD 求并集后返回一个新的 RDD
- 函数签名
def union(other: RDD[T]): RDD[T]
- 15)substract
- 函数说明:以一个 RDD 元素为主,去除两个 RDD 中重复元素,将其他元素保留下来。求差集
- 函数签名
def subtract(other: RDD[T]): RDD[T]
- 16)zip
- 函数说明:将两个 RDD 中的元素,以键值对的形式进行合并。其中,键值对中的 Key 为第 1 个 RDD中的元素,Value 为第 2 个 RDD 中的相同位置的元素。
- 函数签名
def zip[U: ClassTag](other: RDD[U]): RDD[(T, U)]
- Key-Value类型
- 17)partitionBy
- 函数说明:将数据按照指定 Partitioner 重新进行分区。Spark 默认的分区器是 HashPartitioner
- 函数签名
def partitionBy(partitioner: Partitioner): RDD[(K, V)]
- 18)reduceByKey
- 函数说明:可以将数据按照相同的 Key 对 Value 进行聚合,需要定义对相邻Key的聚合操作
- 函数签名
def reduceByKey(func: (V, V) => V): RDD[(K, V)]
def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
- 19)groupByKey
- 函数说明:将数据源的数据根据 key 对 value 进行分组
- 函数签名
def groupByKey(): RDD[(K, Iterable[V])]
def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])]
def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])]
1.groupByKey和reduceByKey的区别?
答:
从 shuffle 的角度:在 spark 中 shuffle 操作必须要进行落盘处理(溢写到磁盘),不能在内存中进行等待,否则可能会导致内存溢出。针对于聚合操作,reduceByKey 和 groupByKey 都存在 shuffle 的操作,但是 reduceByKey可以在 shuffle 前对分区内相同 key 的数据进行预聚合(combine)功能,这样会减少落盘的数据量,而 groupByKey 只是进行分组,不存在数据量减少的问题,reduceByKey 性能比较高。
从功能的角度:reduceByKey 其实包含分组和聚合的功能。GroupByKey 只能分组,不能聚合,所以在分组聚合的场合下,推荐使用 reduceByKey,如果仅仅是分组而不需要聚合。那么还是只能使用 groupByKey
2.groupBy和groupByKey的区别?
答:从两者对数据处理完后的结果来看:
如果数据如下(K1,V1), (K1,V2), (K1,V3), (K2,V1), (K2,V2), (K2,V3),.
groupBy(K)的结果为:
(K1, CompactBuffer((K1, V1), (K,1 V2), ……))
(K2, CompactBuffer((K2, V1), (K2, V2), ……))
gruopByKey()的结果为:
(K1, CompactBuffer(V1, V2, ……))
(K2, CompactBuffer(V1, V2, ……))
可以看出groupByKey的结果相比groupBy更加精简一点,没有重复的分组依据出现。
如果想要把groupBy的结果中重复的数据去除可以配合使用mapValues方法,例如:
package indi.lency.Spark.TransfromOperator
import org.apache.spark.{SparkConf, SparkContext}
object test {
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setMaster("local[*]").setAppName("test")
val sc = new SparkContext(sparkConf)
val a = List(Tuple2('a', 1), Tuple2('a', 1), Tuple2('b', 1))
val rdd = sc.makeRDD(a)
val rdd2 = rdd
.groupBy(_._1)
.mapValues(iter =>
iter.map(x => x._2))
.foreach(println)
val rdd3 = rdd
.groupByKey()
.foreach(println)
}
}
运行结果:
不重复数据的去除处理的结果:
使用mapValues方法进行去除重复数据:
- 20)aggregateByKey
- 函数说明:将数据根据不同的规则进行分区内计算和分区间计算
- 函数签名
def aggregateByKey[U: ClassTag](zeroValue: U)(
seqOp: (U, V) => U,
combOp: (U, U) => U): RDD[(K, U)] // 函数柯里化
补充:柯里化(Currying)指的是将原来接受两个参数的函数变成新的接受一个参数的函数的过程。新的函数返回一个以原有第二个参数为参数的函数。
参考
- 21)foldByKey
- 函数说明:当分区内计算规则和分区间计算规则相同时,aggregateByKey 就可以简化为foldByKey
- 函数签名
def foldByKey(zeroValue: V)(func: (V, V) => V): RDD[(K, V)]
- 22)combineByKey
- 函数说明:最通用的对 key-value 型 rdd 进行聚集操作的聚集函数(aggregation function)。类似于aggregate(),combineByKey()允许用户返回值的类型与输入不一致。
- 函数签名
def combineByKey[C](
createCombiner: V => C,
mergeValue: (C, V) => C,
mergeCombiners: (C, C) => C): RDD[(K, C)]
- 23)sortByKey
- 函数说明:最通用的对 key-value 型 rdd 进行聚集操作的聚集函数(aggregation function)。类似于aggregate(),combineByKey()允许用户返回值的类型与输入不一致。
- 函数签名
def combineByKey[C](
createCombiner: V => C,
mergeValue: (C, V) => C,
mergeCombiners: (C, C) => C): RDD[(K, C)]
- 24)join
- 函数说明:在类型为(K,V)和(K,W)的 RDD 上调用,返回一个相同 key 对应的所有元素连接在一起的(K,(V,W))的 RDD
- 函数签名
def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]
join的使用会使的数据量扩大很多,谨慎使用
- 25)leftOuterJoin
- 函数说明:类似于 SQL 语句的左外连接
- 函数签名
def leftOuterJoin[W](other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]
连接?左连接?右连接?笛卡尔积?都是什么运算?
答:参考
- 26)cogroup
- 函数说明:在类型为(K,V)和(K,W)的 RDD 上调用,返回一个(K,(Iterable,Iterable))类型的 RDD
- 函数签名
def cogroup[W](other: RDD[(K, W)]): RDD[(K, (Iterable[V], Iterable[W]))]
转换算子案例:CountADFlow
思路以及伪代码如下:
// 数据处理:缺什么补什么,多什么删什么
// TODO:
// 1.数据分割
// 2.先按照省份进行分组
// 3.在省份的分组内按照广告的类型进行分组
// 4.统计每个广告的出现的次数
// 5.数据的格式是什么样的呢?城市,top3
/**
* agent.log:时间戳,省份,城市,用户,广告,中间字段使用空格分隔 map =>
* ((省份, 广告), 1) reduceByKey =>
* ((省份, 广告), 总点击量) map =>
* (省份, (广告, 总点击量)) groupByKey =>
* (省份, CompactBuffer((广告, 总点击量), (广告, 总点击量), ……)) mapValues 只处理value的map方法 =>
* Iterator(广告, 总点击量).toList.sortBy(_._2).reverse.take(3)
* (省份, List((广告, 第一点击量), (广告, 第二点击量), (广告, 第三点击量)))
*/
2.1.4.2 RDD 行动算子
行动算子就是能够触发此算子之上的整个行动执行的算子,例如:collect算子。
- 1)reduce
- 函数说明:聚集 RDD 中的所有元素,先聚合分区内数据,再聚合分区间数据
- 函数签名
def reduce(f: (T, T) => T): T
- 2)collect
- 函数说明:将所有的分区数据拉回Driver端,然后以数组 Array 的形式返回数据集的所有元素
- 函数签名
def collect(): Array[T]
- 3)count
- 函数说明:返回RDD中元素的个数
- 函数签名
def count(): Long
- 4)first
- 函数说明:返回RDD中的第一个元素
- 函数签名
def first(): T
- 5)take
- 函数说明:返回RDD中前n个元素组成的数组
- 函数签名
def take(num: Int): Array[T]
- 6)takeOrdered
- 函数说明:返回RDD排序后的前n个元素组成的数组
- 函数签名
def takeOrdered(num: Int)(implicit ord: Ordering[T]): Array[T]
- 7)aggregate
- 函数说明:分区的数据通过初始值和分区内的数据进行聚合,然后再和初始值进行分区间的数据聚合
- 函数签名
def aggregate[U: ClassTag](zeroValue: U)(
seqOp: (U, T) => U,
combOp: (U, U) => U): U
- 8)fold
- 函数说明:折叠操作,aggregate 的简化版操作
- 函数签名
def fold(zeroValue: T)(op: (T, T) => T): T
- 9)countByKey
- 函数说明:统计每种 key 的个数
- 函数签名
def countByKey(): Map[K, Long]
- 10)save 相关算子
- 函数说明:将数据保存到不同格式的文件中
- 函数签名
def saveAsTextFile(path: String): Unit
def saveAsObjectFile(path: String): Unit
def saveAsSequenceFile(
path: String,
codec: Option[Class[_ <: CompressionCodec]] = None): Unit
- 11)foreach
- 函数说明:分布式遍历 RDD 中的每一个元素,调用指定函数
- 函数签名
def foreach(f: T => Unit): Unit = withScope {
val cleanF = sc.clean(f)
sc.runJob(this, (iter: Iterator[T]) => iter.foreach(cleanF))
}
2.1.5 RDD 序列化
2.1.5.1 闭包检查
从计算的角度, 算子以外的代码都是在 Driver 端执行, 算子里面的代码都是在 Executor端执行。那么在 scala 的函数式编程中,就会导致算子内经常会用到算子外的数据,这样就形成了闭包的效果,如果使用的算子外的数据无法序列化,就意味着无法传值给 Executor端执行,就会发生错误,所以需要在执行任务计算前,检测闭包内的对象是否可以进行序列化,这个操作我们称之为闭包检测。Scala2.12 版本后闭包编译方式发生了改变。
Java 的序列化能够序列化任何的类。但是比较重(字节多),序列化后,对象的提交也比较大。Spark 出于性能的考虑,Spark2.0 开始支持另外一种 Kryo 序列化机制。Kryo 速度是 Serializable 的 10 倍。当 RDD 在 Shuffle 数据的时候,简单数据类型、数组和字符串类型已经在 Spark 内部使用 Kryo 来序列化。
注意:即使使用 Kryo 序列化,也要继承 Serializable 接口。
2.1.5.2 依赖关系
- RDD 血缘关系
RDD 只支持粗粒度转换,即在大量记录上执行的单个操作。将创建 RDD 的一系列 Lineage(血统)记录下来,以便恢复丢失的分区。RDD 的 Lineage 会记录 RDD 的元数据信息和转换行为,当该 RDD 的部分分区数据丢失时,它可以根据这些信息来重新运算和恢复丢失的数据分区。 - RDD 依赖关系
这里所谓的依赖关系,其实就是两个相邻 RDD 之间的关系,这样的话一旦分布式计算过程出现错误,就可以根据RDD记录的依赖关系(血缘)将数据重新读取进行计算。
- RDD 窄依赖
窄依赖表示每一个父(上游)RDD 的 Partition 最多被子(下游)RDD 的一个 Partition 使用,窄依赖我们形象的比喻为独生子女。 - RDD 宽依赖
宽依赖表示同一个父(上游)RDD 的 Partition 被多个子(下游)RDD 的 Partition 依赖,会引起 Shuffle,总结:宽依赖我们形象的比喻为多生。 - RDD 阶段划分
DAG(Directed Acyclic Graph)有向无环图是由点和线组成的拓扑图形,该图形具有方向,不会闭环。例如,DAG 记录了 RDD 的转换过程和任务的阶段。
6)RDD 阶段划分源码
源码
try {
// New stage creation may throw an exception if, for example, jobs are run on a
// HadoopRDD whose underlying HDFS files have been deleted.
// 首先无论如何都会县创建一个 resultStage 作为 finalStage
finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite)
} catch {
case e: Exception =>
logWarning("Creating new stage failed due to exception - job: " + jobId, e)
listener.jobFailed(e)
return
}
// ……
private def createResultStage( // 创建resultStage
rdd: RDD[_],
func: (TaskContext, Iterator[_]) => _,
partitions: Array[Int],
jobId: Int,
callSite: CallSite): ResultStage = {
val parents = getOrCreateParentStages(rdd, jobId) // 获取当前 rdd 的前依赖 rdd,判断是否需要进行分阶段
val id = nextStageId.getAndIncrement()
val stage =
new ResultStage(id, rdd, func, partitions, parents, jobId, callSite) // 创建新阶段
stageIdToStage(id) = stage
updateJobIdStageIdMaps(jobId, stage)
stage // 返回stage
}
// ……
private def getOrCreateParentStages( // 获取当前 rdd 的前依赖 rdd
rdd: RDD[_], firstJobId: Int): List[Stage] = {
getShuffleDependencies(rdd) // 判断是否进行了分阶段,如果前面是shuffle依赖则分了阶段
.map { shuffleDep =>
getOrCreateShuffleMapStage(shuffleDep, firstJobId) // 对每一个 shuffleDep 创建阶段
}
.toList
}
// ……
private[scheduler] def getShuffleDependencies(
rdd: RDD[_]): HashSet[ShuffleDependency[_, _, _]] = {
val parents = new HashSet[ShuffleDependency[_, _, _]] // 存放 shuffleDep 的 Map
val visited = new HashSet[RDD[_]]
val waitingForVisit = new Stack[RDD[_]]
waitingForVisit.push(rdd) // 将当前的 rdd 放入待访问栈
while (waitingForVisit.nonEmpty) {
val toVisit = waitingForVisit.pop()
if (!visited(toVisit)) {
visited += toVisit
toVisit.dependencies.foreach { // 判断当前 rdd 的每一个依赖
case shuffleDep: ShuffleDependency[_, _, _] =>
parents += shuffleDep // 如果是 shuffleDep 就放到 parents 中
case dependency =>
waitingForVisit.push(dependency.rdd) // 否则递归的处理 rdd 的所有前依赖
}
}
}
parents // 返回shuffleDep
}
一个 Application 中所划分的阶段的数量 = 这个 Application 中所进行的有关 shuffle 的算子的次数 + 1(必然创建的一个resultStage)
7) RDD 任务划分
RDD 任务切分中间分为:Application应用、Job作业、Stage阶段 和 Task任务
⚫ Application:初始化一个 SparkContext 即生成一个 Application;
⚫ Job:一个 Action 算子就会生成一个 Job;
⚫ Stage:Stage数 等于宽依赖(ShuffleDependency)的个数加 1;
⚫ Task:一个 Stage 阶段中,最后一个 RDD 的分区个数就是 Task 的个数,而将一个 Job 中的所有 Stage 的 Task 数目添加到一起,就是整个Job的Task总数。
注意:Application -> Job -> Stage -> Task 每一层都是 1 对 n 的关系。
8)RDD 任务划分源码
val tasks: Seq[Task[_]] = try {
stage match {
case stage: ShuffleMapStage =>
partitionsToCompute.map { id =>
val locs = taskIdToLocations(id)
val part = stage.rdd.partitions(id)
new ShuffleMapTask(stage.id, stage.latestInfo.attemptId, taskBinary, part, locs, stage.latestInfo.taskMetrics, properties, Option(jobId), Option(sc.applicationId), sc.applicationAttemptId)
}
case stage: ResultStage =>
partitionsToCompute.map { id =>
val p: Int = stage.partitions(id)
val part = stage.rdd.partitions(p)
val locs = taskIdToLocations(id)
new ResultTask(stage.id, stage.latestInfo.attemptId, taskBinary, part, locs, id, properties, stage.latestInfo.taskMetrics, Option(jobId), Option(sc.applicationId), sc.applicationAttemptId)
}
}
……
val partitionsToCompute: Seq[Int] = stage.findMissingPartitions()
……
override def findMissingPartitions(): Seq[Int] = {
mapOutputTrackerMaster
.findMissingPartitions(shuffleDep.shuffleId)
.getOrElse(0 until numPartitions)
}
2.1.6 RDD 持久化
- RDD Cache 缓存
RDD 通过 Cache 或者 Persist 方法将前面的计算结果缓存,默认情况下会把数据以缓存在 JVM 的堆内存中。但是并不是这两个方法被调用时立即缓存,而是触发后面的 action 算子时,该 RDD 将会被缓存在计算节点的内存中,并供后面重用。
(并不一定非要面对重用时使用持久化操作,在一些数据处理时间较长,或者前面的数据比较重要的场合也可以采用持久化操作,避免由于后面的数据操作出错而引起的整个数据流程的重新执行。)
缓存有可能丢失,或者存储于内存的数据由于内存不足而被删除,RDD 的缓存容错机制保证了即使缓存丢失也能保证计算的正确执行。通过基于 RDD 的一系列转换,丢失的数据会被重算,由于 RDD 的各个 Partition 是相对独立的,因此只需要计算丢失的部分即可,并不需要重算全部 Partition。
Spark 会自动对一些 Shuffle 操作的中间数据做持久化操作(比如:reduceByKey)。这样做的目的是为了当一个节点 Shuffle 失败了避免重新计算整个输入。但是,在实际使用的时候,如果想重用数据,仍然建议调用 persist 或 cache。 - RDD CheckPoint 检查点
所谓的检查点其实就是通过将 RDD 中间结果写入磁盘。
由于血缘依赖过长会造成容错成本过高,这样就不如在中间阶段做检查点容错,如果检查点之后有节点出现问题,可以从检查点开始重做血缘,减少了开销。
对 RDD 进行 checkpoint 操作并不会马上被执行,必须执行 Action 操作才能触发。 - 缓存和检查点区别
1)Cache 缓存只是将数据保存起来,不切断血缘依赖。Checkpoint 检查点切断血缘依赖。
2)Cache 缓存的数据通常存储在磁盘、内存等地方,可靠性低。Checkpoint 的数据通常存储在 HDFS 等容错、高可用的文件系统,可靠性高。
3)建议对 checkpoint() 的 RDD 使用 Cache 缓存,这样 checkpoint 的 job 只需从 Cache 缓存中读取数据即可,否则需要再从头计算一次 RDD。
2.1.7 RDD 分区器
Spark 目前支持 Hash 分区和 Range 分区,和用户自定义分区。Hash 分区为当前的默认分区。分区器直接决定了 RDD 中分区的个数、RDD 中每条数据经过 Shuffle 后进入哪个分区,进而决定了 Reduce 的个数。
➢ 只有 Key-Value 类型的 RDD 才有分区器,非 Key-Value 类型的 RDD 分区的值是 None
➢ 每个 RDD 的分区 ID 范围:0 ~ (numPartitions - 1),决定这个值是属于那个分区的。
- Hash 分区:对于给定的 key,计算其 hashCode,并除以分区个数取余。
- Range 分区:将一定范围内的数据映射到一个分区中,尽量保证每个分区数据均匀,而且分区间有序
2.2 累加器
分布式共享只写变量。
一些需要shuffle的工作,可以用累加器实现,这样就减少了节点之间数据的传输。
2.2.1 实现原理
累加器用来把 Executor 端变量信息聚合到 Driver 端。在 Driver 程序中定义的变量,在Executor 端的每个 Task 都会得到这个变量的一份新的副本,每个 task 更新这些副本的值后,传回 Driver 端进行 merge。
2.2.2 基础编程
2.2.2.1 系统累加器
class LongAccumulator extends AccumulatorV2[jl.Long, jl.Long]{...}
class DoubleAccumulator extends AccumulatorV2[jl.Double, jl.Double] {...}
class CollectionAccumulator[T] extends AccumulatorV2[T, java.util.List[T]] {...}
以上三个是系统中已经写好了的累加器。
2.2.2.2 自定义累加器
我们可以自定义累加器,来完成我们想要的功能:
/**
* 1.自定义累加器类型
* 2.向Spark进行注册
*/
val wcAcc = new MyAccumulator // 自定义累加器类型
sc.register(wcAcc, "WC")
/**
* 创建累加器对象:
* 1.继承AccumulatorV2,定义泛型
* IN:累加器输入的数据类型 => String
* OUT:累加器输出的数据类型 => mutalble.Map[String, Long]
*
* 2.实现对应的方法
*/
class MyAccumulator extends AccumulatorV2[String, mutable.Map[String, Long]] {
private var wcMap = mutable.Map[String, Long]()
// 判断当前累加器变量是不是处于初始状态
override def isZero: Boolean = {
wcMap.isEmpty
}
// 复制一个新的累加器
override def copy(): AccumulatorV2[String, mutable.Map[String, Long]] = {
new MyAccumulator
}
// 重置当前累加器
override def reset(): Unit = {
wcMap.clear()
}
// 获取累加器需要计算的值
override def add(v: String): Unit = {
wcMap.update(v, wcMap.getOrElse(v, 0L) + 1)
}
// 返回到Driver端时进行合并各个累加器值的操作
override def merge(other: AccumulatorV2[String, mutable.Map[String, Long]]): Unit = {
val map1 = this.wcMap
val map2 = other.value
map2.foreach {
case (word, count) => { // 这里如果不加case的话表示,map中前后两个元素进行操作
map1.update(word, map1.getOrElse(word, 0L) + count)
}
}
}
// 获取累加器的值
override def value: mutable.Map[String, Long] = {
wcMap
}
}
2.3 广播变量
分布式共享只读变量。
闭包数据都是以Task为单位发送的,每个Task中都会包含任务运行所需要的数据,但是当Executor的数量小于Task的数量时,这样就会导致一个Executor中会包含大量的重复数据,并且占用大量的内存。
2.3.1 实现原理
Spark中的广播变量可以将闭包的数据保存到Executor的内存中。
其实每一个Executor就是一个JVM进程,在其启动时就会分配内存,所以我们可以将每个Task中都需要的数据分发到每一个Executor的内存中,已达到在这个Executor中共享的目的。(类似全局变量)
广播变量用来高效分发较大的对象。向所有工作节点发送一个较大的只读值,以供一个或多个 Spark 操作使用。比如,如果你的应用需要向所有节点发送一个较大的只读查询表,广播变量用起来都很顺手。
2.3.2 基础编程
3 Spark案例实操
3.1 电商网站
3.1.1 数据准备
电商网站的实际数据及需求:
上面的数据图是从数据文件中截取的一部分内容,表示为电商网站的用户行为数据,主要包含用户的 4 种行为:搜索,点击,下单,支付,用户的操作在同一个动作中智能包含这四种动作的一种,不会同时包含两种及以上。数据规则如下:
➢ 数据文件中每行数据采用下划线分隔数据
➢ 每一行数据表示用户的一次行为,这个行为只能是 4 种行为的一种
➢ 如果搜索关键字为 null,表示数据不是搜索数据
➢ 如果点击的品类 ID 和产品 ID 为-1,表示数据不是点击数据
➢ 针对于下单行为,一次可以下单多个商品,所以品类 ID 和产品 ID 可以是多个,id 之
间采用逗号分隔,如果本次不是下单行为,则数据采用 null 表示
➢ 支付行为和下单行为类似
详细字段说明:
3.1.2 需求
3.1.2.1 TopK品类
本案例的需求优化为:先按照点击数排名,靠前的就排名高;如果点击数相同,再比较下单数;下单数再相同,就比较支付数。
4 工程化代码
4.1 三层架构
controller控制层:主要用来做任务调度。
service服务层:完成业务逻辑。
dao持久化层:读取文件或者数据库数据。
4.2 工程化代码应有的结构
application:所有应用的入口。
bean:实体类。
common:指的是在大多数类中抽取出来的部分。比如说,多个类中有一些共同的代码或者方法,我们把他们抽取出来,放到这里。(类似于抽象类,这不过在Scala中叫做特质)
controller:控制层,用来做调度
dao:持久化层,用来做数据交互
service:完成业务执行的逻辑。
util:工具类,所有地方都可以使用,都可能使用的工具类。比如说判断非空等。
三、SparkCore 源码解析
3.1 YARN集群模式下Spark任务提交和Driver、Executor创建流程
- 执行脚本提交任务,实际是启动一个 SparkSubmit 的 JVM 进程;
- SparkSubmit 类中的 main 方法反射调用 YarnClusterApplication 的 main 方法;
- YarnClusterApplication 创建 Yarn 客户端,然后向 Yarn 服务器发送执行指令:bin/java ApplicationMaster;
- Yarn 框架收到指令后会在指定的 NM 中启动 ApplicationMaster;
- ApplicationMaster 启动 Driver 线程,执行用户的作业;
- AM 向 RM 注册,申请资源;
- 获取资源后 AM 向 NM 发送指令:bin/java YarnCoarseGrainedExecutorBackend;
- CoarseGrainedExecutorBackend 进程会接收消息,跟 Driver 通信,注册已经启动的 Executor;然后启动计算对象 Executor 等待接收任务
- Driver 线程继续执行完成作业的调度和任务的执行。
- Driver 分配任务并监控任务的执行。
注意:SparkSubmit、ApplicationMaster 和 CoarseGrainedExecutorBackend是独立的进程;Driver是独立的线程;Executor 和 YarnClusterApplication 是对象。
这个流程是按照如下的核心步骤进行工作的:
- 任务提交后,都会先启动 Driver 程序;
Driver其实是一个线程,运行在某个NodeManage中的一个ApplicationMaster进程中的,而理论上一个NodeManage是可以同时开启很多ApplicationMaster进程的,所以NodeManage和Driver是1对多的关系。 - Driver向集群管理器注册应用程序,此时黄色线这边从“执行main方法”以下处于阻塞状态,等待Executor资源分配完成。
- 之后集群管理器根据此任务的配置信息分配 Executor 并启动;
而 Executor 有两个指代,一个是集群管理器新建的进程用于通信(Executor之间或者Executor和Driver之间进行注册),另一个是这个进程在后续环节中会创建的 Executor 对象,而这个对象才真正负责实际的计算任务的执行,它属于是 Executor 进程中的一个计算对象。我们平时说的Execturo其实是这个进程,而不是这个进程中用于计算的对象。
4)当 Executor 新建完成后会向 ApplicationMaster 进程中的 Driver 进行注册,告知 Driver 当前可用的 Executor 资源有哪些。 - Driver 开始执行 main 函数,Spark 查询为懒执行,当执行到 Action 算子时开始反向推算,根据宽依赖进行 Stage 的划分,随后每一个 Stage 对应一个 Taskset,Taskset 中有多个 Task,查找可用资源 Executor 进行调度;
- 根据本地化原则,Task 会被分发到指定的 Executor 去执行,在任务执行的过程中,Executor 也会不断与 Driver 进行通信,报告任务运行情况。
3.2 Spark 通信架构
➢ RpcEnv:RPC 上下文环境,每个 RPC 终端运行时依赖的上下文环境称为 RpcEnv;当前 Spark 版本中使用的 NettyRpcEnv
➢ RpcEndpoint:RPC 通信终端。Spark 针对每个节点(Client/Master/Worker)都称之为一个 RPC 终端,且都实现 RpcEndpoint 接口,内部根据不同端点的需求,设计不同的消息和不同的业务处理,如果需要发送(询问)则调用 Dispatcher。在 Spark 中,所有的终端都存在生命周期:
⚫ Constructor
⚫ onStart
⚫ receive*
⚫ onStop
➢ Dispatcher:消息调度(分发)器,针对于 RPC 终端需要发送远程消息或者从远程 RPC接收到的消息,分发至对应的指令发件箱(收件箱)。如果指令接收方是自己则存入收件箱,如果指令接收方不是自己,则放入发件箱;
➢ Inbox:指令消息收件箱。一个本地 RpcEndpoint 对应一个收件箱,Dispatcher 在每次向Inbox 存入消息时,都将对应 EndpointData 加入内部 ReceiverQueue 中,另外 Dispatcher 创建时会启动一个单独线程进行轮询 ReceiverQueue,进行收件箱消息消费;
➢ RpcEndpointRef 是对远程 RpcEndpoint 的一个引用。当我们需要向一个具体的 RpcEndpoint 发送消息时,一般我们需要获取到该 RpcEndpoint 的引用,然后通过该引用发送消息。
➢ OutBox:指令消息发件箱。对于当前 RpcEndpoint 来说,一个目标 RpcEndpoint 对应一个发件箱,如果向多个目标 RpcEndpoint 发送信息,则有多个OutBox。当消息放入Outbox 后,紧接着通过 TransportClient 将消息发送出去。消息放入发件箱以及发送过程是在同一个线程中进行;
➢ RpcAddress:表示远程的 RpcEndpointRef 的地址,Host + Port。
➢ TransportClient:Netty通信客户端,一个 OutBox 对应一个TransportClient,TransportClient 不断轮询 OutBox,根据 OutBox 消息的 receiver 信息,请求对应的远程 TransportServer;
➢ TransportServer:Netty 通信服务端,一个 RpcEndpoint 对应一个 TransportServer,接受远程消息后调用 Dispatcher 分发消息至对应收发件箱;
3.3 Spark 任务调度
Job 是以 Action 方法为界,遇到一个 Action 方法则触发一个 Job;
Stage 是 Job 的子集,以 RDD 宽依赖(即 Shuffle)为界,遇到 Shuffle 做一次划分;
Task 是 Stage 的子集,以并行度(分区数)来衡量,分区数是多少,则有多少个 task。
Spark 的任务调度总体来说分两路进行,一路是 Stage 级的调度,一路是 Task 级的调度,总体调度流程如下图所示:
Spark RDD 通过其 Transactions 操作,形成了 RDD 血缘(依赖)关系图,即 DAG,最后通过 Action 的调用,触发 Job 并调度执行,执行过程中会创建两个调度器:DAGScheduler和 TaskScheduler。
➢ DAGScheduler 负责 Stage 级的调度,主要是将 job 切分成若干 Stages,并将每个 Stage 打包成 TaskSet 交给 TaskScheduler 调度。
➢ TaskScheduler 负责 Task 级的调度,将 DAGScheduler 给过来的 TaskSet 按照指定的调度
策略分发到 Executor 上执行,调度过程中 SchedulerBackend 负责提供可用资源,其中 SchedulerBackend 有多种实现,分别对接不同的资源管理系统。
3.4 Spark 内存管理
3.4.1 堆内和堆外内存规划
作为一个 JVM 进程,Executor 的内存管理建立在 JVM 的内存管理之上,Spark 对 JVM的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存。同时,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用。堆内内存受到 JVM 统一管理,堆外内存是直接向操作系统进行内存的申请和释放。
- 堆内内存
堆 内 内 存 的 大 小 , 由 Spark 应 用 程 序 启 动 时 的 – executor-memory 或spark.executor.memory 参数配置。Executor 内运行的并发任务共享 JVM 堆内内存,这些任务在缓存 RDD 数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存,而这些任务在执行 Shuffle 时占用的内存被规划为执行(Execution)内存,剩余的部分不做特殊规划,那些 Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例,均占用剩余的空间。不同的管理模式下,这三部分占用的空间大小各不相同。Spark 对堆内内存的管理是一种逻辑上的”规划式”的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请后和释放前记录这些内存,我们来看其具体流程:
申请内存流程如下:
- Spark 在代码中 new 一个对象实例;
- JVM 从堆内内存分配空间,创建对象并返回对象引用;
- Spark 保存该对象的引用,记录该对象占用的内存。
释放内存流程如下: - Spark 记录该对象释放的内存,删除该对象的引用;
- 等待 JVM 的垃圾回收机制释放该对象占用的堆内内存。
- 堆外内存
为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。
堆外内存意味着把内存对象分配在 Java 虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影响。
凭借统一内存管理机制,Spark 在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护 Spark 内存的难度,但并不意味着开发者可以高枕无忧。如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的 RDD 数据通常都是长期驻留内存的。所以要想充分发挥 Spark 的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。
四、SparkSQL
4.1 SparkSQL是什么?
4.2 DataFrame 是什么?
在 Spark 中,DataFrame 是一种以 RDD 为基础的分布式数据集,类似于传统数据库中的二维表格。DataFrame 与 RDD 的主要区别在于,前者带有 schema 元信息,即 DataFrame所表示的二维表数据集的每一列都带有名称和类型。这使得 Spark SQL 得以洞察更多的结构
信息,从而对藏于 DataFrame 背后的数据源以及作用于 DataFrame 之上的变换进行了针对性的优化,最终达到大幅提升运行时效率的目标。反观 RDD,由于无从得知所存数据元素的具体内部结构,Spark Core 只能在 stage 层面进行简单、通用的流水线优化。同时,与 Hive 类似,DataFrame 也支持嵌套数据类型(struct、array 和 map)。从 API易用性的角度上看,DataFrame API 提供的是一套高层的关系操作,比函数式的 RDD API 要更加友好,门槛更低。
左侧的 RDD[Person]虽然以 Person对象为类型参数,但 Spark 框架本身不了解 Person 类的内部结构。所以我们在对Person中某个属性进行获取或操作时,总是要对 Person 进行获取和操作。
而右侧的 DataFrame 却提供了详细的结构信息,使得 Spark SQL 可以清楚地知道该数据集中包含哪些列,每列的名称和类型各是什么。DataFrame 是为数据提供了 Schema 的视图。可以把它当做数据库中的一张表来对待DataFrame 也是懒执行的,但性能上比 RDD 要高,主要原因:优化的执行计划,即查询计划通过 Spark catalyst optimiser 进行优化。比如下面一个例子:
为了说明查询优化,我们来看上图展示的人口数据分析的示例。图中构造了两个DataFrame,将它们 join 之后又做了一次 filter 操作。如果原封不动地执行这个执行计划,最终的执行效率是不高的。因为 join 是一个代价较大的操作,也可能会产生一个较大的数据集。如果我们能将 filter 下推到 join 下方,先对 DataFrame 进行过滤,再 join 过滤后的较小的结果集,便可以有效缩短执行时间。而 Spark SQL 的查询优化器正是这样做的。简而言之,逻辑查询计划优化就是一个利用基于关系代数的等价变换,将高成本的操作替换为低成本操作的过程。
4.3 DataSet 是什么?
DataSet 是分布式数据集合。DataSet 是 Spark 1.6 中添加的一个新抽象,是 DataFrame的一个扩展。它提供了 RDD 的优势(强类型,使用强大的 lambda 函数的能力)以及 SparkSQL 优化执行引擎的优点。DataSet 也可以使用功能性的转换(操作 map,flatMap,filter
等等)。
➢ DataSet 是 DataFrame API 的一个扩展,是 SparkSQL 最新的数据抽象
➢ 用户友好的 API 风格,既具有类型安全检查也具有 DataFrame 的查询优化特性;
➢ 用样例类来对 DataSet 中定义数据的结构信息,样例类中每个属性的名称直接映射到 DataSet 中的字段名称;
➢ DataSet 是强类型的。比如可以有 DataSet[Car],DataSet[Person]。
➢ DataFrame 是 DataSet 的特列,DataFrame=DataSet[Row] ,所以可以通过 as 方法将DataFrame 转换为 DataSet。Row 是一个类型,跟 Car、Person 这些的类型一样,所有的表结构信息都用 Row 来表示,获取数据时需要按照 Row 指定的顺序进行数据获取。
4.4 RDD、DataFrame和DataSet三者的关系
在 SparkSQL 中 Spark 为我们提供了两个新的抽象,分别是 DataFrame 和 DataSet。他们和 RDD 有什么区别呢?首先从版本的产生上来看:
➢ Spark1.0 => RDD
➢ Spark1.3 => DataFrame
➢ Spark1.6 => Dataset
如果同样的数据都给到这三个数据结构,他们分别计算之后,都会给出相同的结果。不同是的他们的执行效率和执行方式。在后期的 Spark 版本中,DataSet 有可能会逐步取代 RDD和 DataFrame 成为唯一的 API 接口。
4.4.1 三者的共性
➢ RDD、DataFrame、DataSet 全都是 spark 平台下的分布式弹性数据集,为处理超大型数据提供便利;
➢ 三者都有惰性机制,在进行创建、转换,如 map 方法时,不会立即执行,只有在遇到 ActionOperator 如 foreach 时,三者才会开始遍历运算;
➢ 三者有许多共同的函数,如 filter,排序等;
➢ 在对 DataFrame 和 Dataset 进行操作许多操作都需要这个包: import spark.implicits._(在创建好 SparkSession 对象后尽量直接导入)
➢ 三者都会根据 Spark 的内存情况自动缓存运算,这样即使数据量很大,也不用担心会内存溢出
➢ 三者都有 partition 的概念
➢ DataFrame 和 DataSet 均可使用模式匹配获取各个字段的值和类型
4.4.2 三者的区别
- RDD
➢ RDD 一般和 spark mllib 同时使用
➢ RDD 不支持 sparksql 操作 - DataFrame
➢ 与 RDD 和 Dataset 不同,DataFrame 每一行的类型固定为 Row,每一列的值没法直接访问,只有通过解析才能获取各个字段的值
➢ DataFrame 与 DataSet 一般不与 spark mllib 同时使用
➢ DataFrame 与 DataSet 均支持 SparkSQL 的操作,比如 select,groupby 之类,还能注册临时表/视窗,进行 sql 语句操作
➢ DataFrame 与 DataSet 支持一些特别方便的保存方式,比如保存成 csv,可以带上表头,这样每一列的字段名一目了然(后面专门讲解) - DataSet
➢ Dataset 和 DataFrame 拥有完全相同的成员函数,区别只是每一行的数据类型不同。DataFrame 其实就是 DataSet 的一个特例 type DataFrame = Dataset[Row]
➢ DataFrame 也可以叫 Dataset[Row],每一行的类型是 Row,不解析,每一行究竟有哪些字段,各个字段又是什么类型都无从得知,只能用上面提到的 getAS 方法或者共性中的第七条提到的模式匹配拿出特定字段。而 Dataset 中,每一行是什么类型是不一定的,在自定义了 case class 之后可以很自由的获得每一行的信息.
五、SparkStreaming
SparkStreaming是一个准实时(秒、分)、微批次(每隔一段时间,当做一批)的数据处理框架。
问题:
1.Application、Job、Stage、task之间的关系?
答:Application->Job->Stage->Task 每一层都是 1 对 n 的关系。
RDD 任务切分中间分为:Application、Job、Stage 和 Task
⚫ Application:初始化一个 SparkContext 即生成一个 Application;
⚫ Job:一个 Action 算子就会生成一个 Job;
⚫ Stage:Stage 等于宽依赖(ShuffleDependency)的个数加 1;
⚫ Task:一个 Stage 阶段中,最后一个 RDD 的分区个数就是 Task 的个数。
2.分区数与Task之间的关系?Spark的并发度?
首先数据文件存储在HDFS上,每个File都包含了很多块,称为Block。当Spark读取这些文件作为输入时,会根据具体数据格式对应的InputFormat进行解析,一般都是将若干个Block合并成一个输入分片(当然有的输入格式是Block中的每个file划分成一个InputSplit,但是可以选择性的进行combiner),称为InputSplit,注意InputSplit不能跨越文件。
随后将为这些输入分片生成具体的Task。InputSplit与Task是一一对应的关系。随后这些具体的Task每个都会被分配到集群上的某个节点的某个Executor去执行。
- 1)每个节点可以起一个或多个Executor。
- 2)每个Executor由若干core组成,每个Executor的每个core一次只能执行一个Task。
- 3)每个Task执行的结果就是生成了目标RDD的一个partiton。
注意: 这里的core是虚拟的core而不是机器的物理CPU核,可以理解为就是Executor的一个工作线程。而 Task被执行的并发度 = Executor数目 * 每个Executor核数。
至于partition的数目:
- 1)对于数据读入阶段,每一个InputSplit划分成一个分区(因为默认的分区方法就是和InputSplit的数目有关,详情见Hadoop的学习笔记),例如sc.textFile,输入文件被划分为多少InputSplit就会需要多少初始Task。
- 2)在Map阶段partition数目保持不变。
- 3)在Reduce阶段,RDD的聚合会触发shuffle操作,聚合后的RDD的partition数目跟具体操作有关,例如repartition操作会聚合成指定分区数,还有一些算子是可配置的。
RDD在计算的时候,每个分区都会起一个task,所以rdd的分区数目决定了总的的task数目。申请的计算节点(Executor)数目和每个计算节点虚拟核数,决定了你同一时刻可以并行执行的task。
比如的RDD有100个分区,那么计算的时候就会生成100个task,你的资源配置为10个计算节点,每个两2个核,同一时刻可以并行的task数目为20,计算这个RDD就需要5个轮次。如果计算资源不变,你有101个task的话,就需要6个轮次,在最后一轮中,只有一个task在执行,其余核都在空转。如果资源不变,你的RDD只有2个分区,那么同一时刻只有2个task运行,其余18个核空转,造成资源浪费。这就是在spark调优中,增大RDD分区数目,增大任务并行度的做法。
(参考:https://blog.csdn.net/weixin_41590998/article/details/115710162)
3.一种误解:Spark是一种基于内存的技术,所以他相比MapReduce速度快。
是这样的,但是也不全是。
不管是什么技术,都是基于内存进行计算的。因为我们都需要将数据从磁盘中读取出来,然后加载到内存进行操作。
但是Spark的关键在于:在涉及到多个任务的交接时是不需要进行数据落盘的,这样就避免了与磁盘的 IO ;而 MapReduce 每个任务最终都是要先落盘才能进行下一个任务。所以 Spark 相比 MapReduce 比较快。
但是,Spark 中有的算子一样会涉及到磁盘 IO —— Shuffle,所以我们在进行数据处理的过程中尽量要避免使用涉及到Shuffle的算子。
一般来说,Spark比MapReduce运行速度快的原因主要有以下几点:
- task启动时间比较快,Spark是fork出线程;而MR是启动一个新的进程(使用system命令);(一般来说,使用system函数不是启动其它进程的理想手段,因为它必须用一个shell来启动需要的程序。由于启动之前需要先启动一个shell,而且对shell的安装情况及使用的环境的依赖也很大,所以使用system函数来创建一个新的运行的程序的方法效率不高。)
- 更快的shuffles,Spark只有在shuffle的时候才会将数据放在磁盘,而MR却不是。
- 更快的工作流:典型的MR工作流是由很多MR作业组成的,他们之间的数据交互需要把数据持久化到磁盘才可以;而Spark支持DAG以及pipelining,在没有遇到shuffle完全可以不把数据缓存到磁盘。
- 缓存:虽然目前HDFS也支持缓存,但是一般来说,Spark的缓存功能更加高效,特别是在SparkSQL中,我们可以将数据以列式的形式储存在内存中。
所有的这些原因才使得Spark相比Hadoop拥有更好的性能表现;在比较短的作业确实能快上100倍,但是在真实的生产环境下,一般只会快 2.5x ~ 3x!
4.数据倾斜可能发生的原因?怎么处理?
发生的原因:根本原因是数据不均衡,引起数据不均衡的原因有:
1.可能原本的数据就是分布不均衡的
2.还有就是可能进行了不当的合并分区操作,比如说使用了coalesce(),虽然第二个参数是shuffle可以避免数据倾斜,但是会将所有的数据进行收集然后重新分区,发生大量的数据传输。
解决办法:
1.首先通过任务运行的监测后端,查看是哪一个节点发生的数据倾斜。然后得到的它的数据分区(怎么得到呢?),然后通过smaple算子判断其中那些数据比较多,然后再对对应的数据进行更细致的划分,比如说对其进行转换成其他字符、对数量较多的数据在在其他标准进行数据分区。
5.groupByKey和reduceByKey的区别?
答:
从 shuffle 的角度:在 spark 中 shuffle 操作必须要进行落盘处理(溢写到磁盘),不能在内存中进行等待,否则可能会导致内存溢出。针对于聚合操作,reduceByKey 和 groupByKey 都存在 shuffle 的操作,但是 reduceByKey可以在 shuffle 前对分区内相同 key 的数据进行预聚合(combine)功能,这样会减少落盘的数据量,而 groupByKey 只是进行分组,不存在数据量减少的问题,reduceByKey 性能比较高。
从功能的角度:reduceByKey 其实包含分组和聚合的功能。GroupByKey 只能分组,不能聚合,所以在分组聚合的场合下,推荐使用 reduceByKey,如果仅仅是分组而不需要聚合。那么还是只能使用 groupByKey
6.groupBy和groupByKey的区别?
答:从两者对数据处理完后的结果来看:
如果数据如下(K1,V1), (K1,V2), (K1,V3), (K2,V1), (K2,V2), (K2,V3),.
groupBy(K)的结果为:
(K1, CompactBuffer((K1, V1), (K,1 V2), ……))
(K2, CompactBuffer((K2, V1), (K2, V2), ……))
gruopByKey()的结果为:
(K1, CompactBuffer(V1, V2, ……))
(K2, CompactBuffer(V1, V2, ……))
可以看出groupByKey的结果相比groupBy更加精简一点,没有重复的分组依据出现。
如果想要把groupBy的结果中重复的数据去除可以配合使用mapValues方法,例如:
package indi.lency.Spark.TransfromOperator
import org.apache.spark.{SparkConf, SparkContext}
object test {
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setMaster("local[*]").setAppName("test")
val sc = new SparkContext(sparkConf)
val a = List(Tuple2('a', 1), Tuple2('a', 1), Tuple2('b', 1))
val rdd = sc.makeRDD(a)
val rdd2 = rdd
.groupBy(_._1)
.mapValues(iter =>
iter.map(x => x._2))
.foreach(println)
val rdd3 = rdd
.groupByKey()
.foreach(println)
}
}
运行结果:
不重复数据的去除处理的结果:
使用mapValues方法进行去除重复数据:
7.缓存cache()、持久化persist() 和 检查点checkpoint() 的区别
1)Cache 缓存只是将数据保存起来,不切断血缘依赖。Checkpoint 检查点切断血缘依赖,相当于改变了原始数据的来源。
2)Cache 缓存、persist 的数据通常存储在磁盘、内存等地方,可靠性低,而且是临时存储,当本次的Job运行结束时这些数据都会被删除,如果下次再用时还需要重新计算。Checkpoint 的数据通常存储在 HDFS 等容错、高可用的文件系统,可靠性高,Job运行结束后也不会消失,可以多次使用,相当于改变了Job的原始数据来源。
3)建议对使用了 checkpoint() 的 RDD 使用 Cache 缓存,这样 checkpoint 的 job 只需从 Cache 缓存中读取数据即可,否则需要再从头计算一次 RDD。