Spark架构
驱动程序 (Driver program)
驱动程序运行应用程序的主要进程,负责创建 SparkContext、将用户程序转换为集群中的作业、跟踪执行程序的运行状态以及调度任务。
集群资源管理器 (Cluster manager)
集群资源管理器是外部服务,用于获取集群中的资源,例如独立管理器、Mesos、YARN等。
工作节点 (Worker node)
工作节点是集群中可以运行应用程序代码的任何节点。
执行程序 (Executor)
执行程序是在工作节点上为应用程序启动的进程,负责运行任务并在内存或磁盘存储中保存数据。每个应用程序都有自己的执行程序,它相当于一个Java虚拟机(JVM)。
任务 (Task)
被送到某个Executor上的工作单元,但hadoopMR中的MapTask和ReduceTask概念一样,是运行Application的基本单位,多个Task组成一个Stage,而Task的调度和管理等是由TaskScheduler负责。
作业 (Job)
作业是由多个任务组成的并行计算,响应于 Spark 的动作(例如保存、收集),你会在驱动程序的日志中看到这个术语。
阶段 (Stage)
阶段是将每个作业分成较小的任务集的过程,这些任务集相互依赖,类似于MapReduce中的映射和归约阶段,你会在驱动程序的日志中看到这个术语。
Spark DAG
Spark DAG是Spark作业的执行计划,用有向无环图表示。顶点是操作,边是操作之间的数据流依赖。Spark使用DAG优化作业执行,将其分解成一系列阶段并并行执行任务。通过DAG,Spark实现了惰性计算、任务调度优化和容错性,提高了作业执行效率和可靠性
job、stage和task的关系
Job、stage和task是spark任务执行流程中的三个基本单位。其中job是最大的单位,也是Spark Application任务执行的基本单元,由action
算子划分触发生成。
stage隶属于单个job,根据shuffle算子(宽依赖)拆分。单个stage内部可根据数据分区数划分成多个task,由TaskScheduler分发到各个Executor上的task线程中执行。
DAGScheduler的Stage划分算法
官网的RDD执行流程图:
针对一段应用代码(如上),Driver会以Action算子为边界生成DAG调度图。DAGScheduler从DAG末端开始遍历划分Stage
,封装成一系列的tasksets移交TaskScheduler,后者根据调度算法, 将taskset
分发到相应worker上的Executor中执行。
1. DAGSchduler的工作原理
- DAGScheduler是一个
面向stage
调度机制的高级调度器,为每个job计算stage的DAG
(有向无环图),划分stage并提交taskset给TaskScheduler。 - 追踪每个RDD和stage的物化情况,处理因shuffle过程丢失的RDD,重新计算和提交。
- 查找rdd partition 是否cache/checkpoint。提供
优先位置
给TaskScheduler,等待后续TaskScheduler的最佳位置划分
2. Stage划分算法
- 从触发action操作的算子开始,从后往前遍历DAG。
- 为最后一个rdd创建
finalStage
。 - 遍历过程中如果发现该rdd是宽依赖,则为其生成一个新的stage,与旧stage分隔而开,此时该rdd是新stage的最后一个rdd。
- 如果该rdd是窄依赖,将该rdd划分为旧stage内,继续遍历,以此类推,继续遍历直至DAG完成。
TaskScheduler的Task分配算法
TaskScheduler负责Spark中的task任务调度工作。TaskScheduler内部使用TasksetPool
调度池机制存放task任务。TasksetPool分为FIFO
(先进先出调度)和FAIR
(公平调度)。
- FIFO调度: 基于队列思想,使用先进先出原则顺序调度taskset
- FAIR调度: 根据权重值调度,一般选取资源占用率作为标准,可人为设定
1. TaskScheduler的工作原理
- 负责Application在Cluster Manager上的注册
- 根据不同策略创建TasksetPool资源调度池,初始化pool大小
- 根据task分配算法发送Task到Executor上执行
- Task分配算法
- 首先获取所有的executors,包含executors的ip和port等信息
- 将所有的executors根据shuffle算法进行打散
- 遍历executors。在程序中依次尝试
本地化级别
,最终选择每个task的最优位置
(结合DAGScheduler优化位置策略) - 序列化task分配结果,并发送RPC消息等待Executor响应
面试题:Spark如何划分stage,每个stage又根据什么决定task个数?
Stage:根据RDD之间的依赖关系的不同将Job划分成不同的Stage,遇到一个宽依赖则划分一个Stage。
Task:Stage是一个TaskSet,将Stage根据分区数划分成一个个的Task。
Spark工作流程
1.启动SparkContext:
- 应用程序启动时,首先创建 SparkContext,它是与 Spark 集群通信的入口点。SparkContext向资源管理器(可以是Standalone、Mesos或YARN)注册,并请求运行 Executor 资源。
2.资源分配和Executor启动:
- 资源管理器根据 SparkContext 的请求,分配 Executor 资源,并启动 StandaloneExecutorBackend。Executor 运行情况通过心跳发送到资源管理器上。
3.构建DAG图和分解成Stage:
- SparkContext根据应用程序代码构建 DAG 图,并将其分解成阶段(Stage)。
4.任务调度:
- SparkContext将 DAG 图中的任务(Task)分发给任务调度器(Task Scheduler)。Executor向 SparkContext 申请任务。
5.代码分发和任务执行:
- SparkContext将应用程序代码分发给 Executor。任务调度器将任务发放给 Executor 运行。
6.任务执行和资源释放:
- Executor 在本地运行任务,并在完成后释放所有相关资源
Spark中的两种核心Shuffle
Spark中触发Shuffle过程的算子包括:
- groupByKey:将具有相同键的元素分组到一起。
- reduceByKey:对具有相同键的元素执行聚合操作。
- sortByKey:根据键对RDD中的元素进行排序。
- join:连接两个RDD,根据它们的键将具有相同键的元素组合在一起。
…
HashShuffle
(在 Spark 2.0 版本中, Hash Shuffle 方式己经不再使用。)
Shuffle Write 阶段:
- 当前 Stage 结束计算后,每个 Task 处理的数据会按照 key 进行“划分”。
- 数据按照 key 经过 hash 算法分配到相应的内存缓冲中。
- 当内存缓冲填满后,数据会溢写到磁盘文件中。每个 Task 会为下游 Stage 的每个 Task 创建一个磁盘文件,用于存储该 Task 处理的数据。
- 当前 Stage 有多个 Task,每个 Task 都会为下一个 Stage 的每个 Task 创建相应数量的磁盘文件。
Shuffle Read 阶段:
- 下一个 Stage 开始时,每个 Task 需要从上一个 Stage 的计算结果中拉取所有相同 key 的数据到本地节点。
- 每个 Shuffle Read Task 在拉取数据时一边进行聚合操作,一边将数据写入自己的内存缓冲中。
- 拉取的数据大小受到每个 Task 的缓冲大小限制,每次拉取一部分数据,然后进行聚合操作。
- 聚合完一批数据后,继续拉取下一批数据,直到所有数据都拉取完毕,并得到最终的结果。
优化后的Hash Shuffle
开启 spark.shuffle.consolidateFiles
参数后,在 HashShuffleManager 中会采用一种优化机制,称为 consolidate 机制。这个机制会将 shuffle write 过程中复用buffer和进行磁盘文件的合并,大幅减少了小文件数量,从而提升了性能。(复用buffer)
举例来说,假设第一个 stage 有 50 个 Task,第二个 stage 有 100 个 Task,总共有 10 个 Executor(每个 Executor 有 1 个 CPU Core),每个 Executor 执行 5 个 Task。未经优化时,每个 Executor 会创建 500 个磁盘文件,总共 5000 个磁盘文件。经过优化后,每个 Executor 只会创建 100 个磁盘文件,总共只有 1000 个磁盘文件。这种优化大幅减少了磁盘文件数量,提升了性能
SortShuffle
SortShuffleManager 的运行机制主要分成三种:
- 普通运行机制;
- bypass 运行机制,当 shuffle read task 的数量小于等于
spark.shuffle.sort.bypassMergeThreshold
参数的值时(默认为 200),就会启用 bypass 机制; - Tungsten Sort 运行机制,开启此运行机制需设置配置项
spark.shuffle.manager=tungsten-sort
。开启此项配置也不能保证就一定采用此运行机制(后面会解释)。[了解]
普通运行机制
在这种模式下,数据会先被写入一个内存数据结构中。根据不同的Shuffle算子,可能选择不同的数据结构。
例如,对于reduceByKey等聚合类的Shuffle算子,会选择Map数据结构,在内存中进行聚合并写入;而对于join等普通的Shuffle算子,则会选择Array数据结构直接写入内存。
每写入一条数据到内存数据结构后,都会检查是否达到了某个临界阈值。一旦达到临界阈值,会尝试将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构。
在溢写到磁盘文件之前,会先根据 key 对内存数据结构中已有的数据进行排序。
写入磁盘文件是通过 Java 的 BufferedOutputStream 实现的。BufferedOutputStream 是 Java 的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘 IO 次数,提升性能。
在这个过程中,一个task会多次进行磁盘溢写操作,生成多个临时文件。最后,会执行合并操作(merge),将所有之前生成的临时文件合并成一个最终的磁盘文件(大大减少了文件数量)。由于一个task对应一个磁盘文件,为了标识下游阶段的task所需的数据,会额外写入一个索引文件,记录了下游各个task的数据在文件中的起始偏移量和结束偏移量。
bypass 运行机制
Reducer 端任务数比较少的情况下,基于 Hash Shuffle 实现机制明显比基于 Sort Shuffle 实现机制要快,因此基于 Sort Shuffle 实现机制提供了一个带 Hash 风格的回退方案,就是 bypass 运行机制。对于 Reducer 端任务数少于配置属性spark.shuffle.sort.bypassMergeThreshold
设置的个数时,使用带 Hash 风格的回退计划。
bypass 运行机制的触发条件如下:
- shuffle map task 数量小于
spark.shuffle.sort.bypassMergeThreshold=200
参数的值。 - 不是聚合类的 shuffle 算子。
1.每个 task 会为每个下游 task 都创建一个临时磁盘文件,并将数据按 key 进行 hash 然后根据 key 的 hash 值,将 key 写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。
该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好。
而该机制与普通 SortShuffleManager 运行机制的不同在于:第一,磁盘写机制不同;第二,不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。
Tungsten Sort 运行机制
tungsten Sort是对普通Sort的一种优化,它通过对数据进行序列化后的字节数组的指针进行排序,而不是直接对内容进行排序,从而避免了序列化和反序列化的开销,大幅降低了内存消耗和GC开销。
Spark提供了配置属性,用于选择Shuffle的具体实现机制。默认情况下,Spark使用SortShuffle实现机制,但实际上,SortShuffleManager既支持SortShuffle也支持Tungsten Sort Shuffle。
对应非基于 Tungsten Sort 时,通过 SortShuffleWriter.shouldBypassMergeSort
方法判断是否需要回退到 Hash 风格的 Shuffle 实现机制,当该方法返回的条件不满足时,则通过 SortShuffleManager.canUseSerializedShuffle
方法判断是否需要采用基于 Tungsten Sort Shuffle 实现机制,而当这两个方法返回都为 false,即都不满足对应的条件时,会自动采用普通运行机制。
要使用Tungsten Sort Shuffle,需要满足以下条件:
- Shuffle操作没有聚合操作或排序要求。
- 序列化器支持序列化值的重定位,目前仅支持KryoSerializer。
- 输出分区数量少于16777216个。
此外,还有其他限制,如单条记录的长度不能超过128MB,这受到内存模型的影响。
Yarn Cluster&Yarn Client模式的区别
Spark中最普遍的一道面试题
Yarn Cluster模式的driver进程托管给Yarn
(AppMaster)管理,通过yarn UI
或者Yarn logs
命令查看日志。
Yarn Client模式的driver进程运行在本地客户端
,因资源调度、任务分发会和Yarn集群产生大量网络通信,出现网络激增现象,适合本地调试
,不建议生产上使用。
- Yarn Cluster模式
- Yarn Client模式
Spark的广播变量和累加器
广播变量(Broadcast Variables):
- 基本原理:广播变量是一种将较大的只读数据集(List、Array)在集群中所有节点间进行高效共享的机制。Spark会将广播变量的内容序列化后分发到每个Executor的内存中,并在任务执行时直接使用它们,而不是通过网络传输大量数据。
- 用途:适用于需要在所有任务中共享的大型只读数据集,例如需要在每个任务中使用的模型参数、配置数据等。
- 注意不能广播RDD,因为RDD不存储数据
- 在Driver端使用broadcast()将一些
大变量
(List、Array)持久化,Executor根据broadcastid拉取本地缓存中的Broadcast对象,如果不存在,则尝试远程拉取Driver端持久化的那份Broadcast变量。这样所有的Executor均存储了一份变量的备份,这个executor启动的task会共享这个变量,节省了通信的成本和服务器的资源。
累加器(Accumulators):
-
基本原理:累加器是一种仅能进行“加”的分布式变量,只有驱动程序能够读取它们的值,而任务只能将值加到累加器中。累加器的更新是在各个任务执行过程中进行的,因此可以用来实现分布式的计数、求和等操作。
-
用途:适用于需要在所有任务中进行累加计算的场景,例如统计所有任务中某个条件满足的记录数、计算全局的计数器等
-
Spark累加器支持在Driver端进行全局汇总的计算需求,实现分布式计数的功能
参考:
https://bbs.huaweicloud.com/blogs/326863
面试题:
如何使用Spark实现TopN的获取(描述思路或使用伪代码)
-
将数据按照需要排序的字段进行降序排序:使用Spark的sortBy算子对数据集进行排序,按照需要进行降序排序。
-
取前N个元素:使用take算子获取排序后的前N个元素,即为TopN结果。
SELECT * FROM data ORDER BY value DESC LIMIT N
// 假设有一个包含(key, value)对的RDD,需要根据value字段获取TopN val dataRDD: RDD[(String, Int)] = ... // 定义需要获取的TopN数量 val topN: Int = ... // 对数据进行降序排序,并取前N个元素 val topNElements: Array[(String, Int)] = dataRDD.sortBy(_._2, ascending = false).take(topN) // 打印TopN结果 topNElements.foreach(println)
京东:调优之前与调优之后性能的详细对比(例如调整map个数,map个数之前多少、之后多少,有什么提升)
调优之前,假设有几百个文件,每个文件一个map任务,总共几百个map任务。在进行join操作时,每个map任务都需要读取一个文件的数据,然后进行join操作,这会导致大量的数据移动和shuffle操作,性能较低。
调优之后,通过对map任务进行coalesce操作,将几百个map任务合并成较少的几十个map任务,即窄依赖。这样,在进行join操作时,每个map任务需要处理的数据量会增加,但由于减少了map任务的数量,整体上减少了shuffle操作产生的文件数。因此,join操作的性能会有所提升。
在 Spark 中,每个分区会被分配给一个 map 任务来处理。因此,调优的关键是合理设置分区数量,以便最大限度地利用集群资源,并最小化不必要的数据移动和 shuffle 操作。
对于你提到的情况,假设有几百个小文件,每个文件一个分区,而每个分区都会被分配给一个 map 任务来处理。在进行 join 操作时,如果分区数量过多,会导致大量的 map 任务,增加了 shuffle 操作的开销,降低了性能。
通过使用
coalesce
将分区数量减少到一个更合理的数量,可以减少 map 任务的数量,从而降低了 shuffle 操作的开销,提高了性能。将几百个分区合并成几十个分区,可以显著减少 map 任务的数量,加速 join 操作的执行。
键是合理设置分区数量**,以便最大限度地利用集群资源,并最小化不必要的数据移动和 shuffle 操作。
对于你提到的情况,假设有几百个小文件,每个文件一个分区,而每个分区都会被分配给一个 map 任务来处理。在进行 join 操作时,如果分区数量过多,会导致大量的 map 任务,增加了 shuffle 操作的开销,降低了性能。
通过使用
coalesce
将分区数量减少到一个更合理的数量,可以减少 map 任务的数量,从而降低了 shuffle 操作的开销,提高了性能。将几百个分区合并成几十个分区,可以显著减少 map 任务的数量,加速 join 操作的执行。因此,在这种情况下,调优的重点是调整分区数量,而不是直接合并 map 任务。通过合理设置分区数量,可以达到减少 shuffle 操作、提高性能的目的