Spark 原理与实践| 青训营笔记

99 阅读18分钟

Spark 原理与实践| 青训营笔记

这是我参与「第四届青训营 」笔记创作活动的的第5天

大数据处理引擎 Spark 介绍

大数据处理技术栈

  • 应用:BI报表/实时大盘/广告/推荐
  • 计算:Spark/Flink/Presto/Impala/ClickHouse/YARN/K8s/...
  • 存储:MetaStore/Paequet/ORC/DeltaLake/Hudi/Iceberg/HDFS/Kafka/HBase/Kudu/TOS/S3/...
  • 数据:Volume/Variety/Velocity On-Premise/On-cloud;平台,管理,安全/...

常见大数据处理链路

image-20220801015032709.png

开源大数据处理引擎

  • Batch:Hive/hadoop/Spark
  • Streaming:Flink
  • OLAP:Presto/ClickHouse/Impala/DORIS

什么是 Spark?

  • Spark是一种基于内存的快速、通用、可扩展的大数据分析计算引擎。

  • 用于大规模的数据统一分析引擎,它可以在单机节点/集群上执行数据工程、数据科学和机器学习

  • 支持多语言的 批/流计算处理您的数据

  • 提供执行快速、分布式的 ANSI SQL 查询分析

  • 对 PB 级数据执行探索性数据分析 (EDA),而无需进行下采样

  • 训练机器学习算法,并使用相同的代码扩展到包含数千台机器的容错集群。

Spark 版本演进

image-20220801020048695.png

Spark 生态组件

image-20220801020416813.png

  • Spark Core:Spark核心组件,它实现了Spark的基本功能,包含任务调度、内存管理、错误恢复、与存储系统交互等模块。
    • SparkCore --> RDD
      • map/filter/flatMap/mapPartitions/repartition/groupBy/reduceBy/join/aggregate/foreach/foreachPartition/count/max/min等API
  • Spark SQL:用来操作结构化数据的核心组件,通过Spark SQL可以直接查询Hive、HBase等多种外部数据源中的数据。
    • SparkSQL --> DataFrame
      • select/filter/groupBy/agg/join/union/orderBy/Hive UDF/自定义UDF等算子
  • Spark Structured Streaming:Spark提供的流式计算框架,支持高吞吐量、可容错处理的实时流式数据处理。
  • MLlib:Spark提供的关于机器学习功能的算法程序库,包括分类、回归、聚类、协同过滤算法等,还提供了模型评估、数据导入等额外的功能。
  • GraphX:Spark提供的分布式图处理框架,拥有对图计算和图挖掘算法的API接口以及丰富的功能和运算符。
  • 独立调度器、Yarn、Mesos、Kubernetes:Spark框架可以高效地在一个到数千个节点之间伸缩计算,集群管理器则主要负责各个节点的资源管理工作,为了实现这样的要求,同时获得最大灵活性,Spark支持在各种集群管理器(Cluster Manager)上运行。
  • 提供丰富的数据源
    • 内置 DataSource 支持Text、Parquet/ORC、JSON/CSV、JDBC等
    • 自定义 DataSource,自己实现或者社区提供其他数据源的自定义实现

Spark 运行架构

图形中的Driver表示master,负责管理整个集群中的作业任务调度。图形中的Executor 则是 slave,负责实际执行任务。

image-20220801071319880.png

  • Application(应用):Spark上运行的应用。Application中包含了一个驱动器(Driver)进程和集群上的多个执行器(Executor)进程。

  • Driver Program(驱动器):运行main()方法并创建SparkContext的进程。

    • Driver在Spark作业执行时主要负责:
      • 将用户程序转化为作业(job)
      • 在Executor之间调度任务(task)
      • 跟踪Executor的执行情况
      • 通过UI展示查询运行情况
  • Cluster Manager(集群管理器):用于在集群上申请资源的外部服务(如:独立部署的集群管理器、Mesos或者Yarn)。

  • Worker Node(工作节点):集群上运行应用程序代码的任意一个节点。

  • Executor(执行器):在集群工作节点上为某个应用启动的工作进程,该进程负责运行计算任务,并为应用程序存储数据。

    • 负责运行组成Spark应用的任务,并将结果返回给驱动器进程
    • 它们通过自身的块管理器(Block Manager)为用户程序中要求缓存的 RDD 提供内存式存储。RDD 是直接缓存在Executor进程内的,因此任务可以在运行时充分利用缓存数据加速运算。
  • Task(任务):执行器的工作单元。

  • Job(作业):一个并行计算作业,由一组任务(Task)组成,并由Spark的行动(Action)算子(如:save、collect)触发启动。

  • Stage(阶段):每个Job可以划分为更小的Task集合,每组任务被称为Stage。

Spark目前支持几个集群管理器:

  • Standalone :Spark 附带的简单集群管理器,可以轻松设置集群。

  • Apache Mesos:通用集群管理器,也可以运行 Hadoop MapReduce 和服务应用程序。(已弃用)

  • Hadoop YARN: Hadoop 2 和 3 中的资源管理器。

  • Kubernetes:用于自动部署、扩展和管理容器化应用程序的开源系统。

SparkCore 原理解析

SparkCore

image-20220801022309611.png

RDD

RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据处理模型。代码中是一个抽象类,它代表一个弹性的、不可变、可分区、里面的元素可并行计算的集合。

  • 弹性
  • 存储的弹性:内存与磁盘的自动切换;
  • 容错的弹性:数据丢失可以自动恢复;
  • 计算的弹性:计算出错重试机制;
  • 分片的弹性:可根据需要重新分片。
  • 分布式:数据存储在大数据集群不同节点上
  • 数据集:RDD封装了计算逻辑,并不保存数据
  • 数据抽象:RDD是一个抽象类,需要子类具体实现
  • 不可变:RDD封装了计算逻辑,是不可以改变的,想要改变,只能产生新的RDD,在新的RDD里面封装计算逻辑
  • 可分区、并行计算

RDD核心属性

  • 分区列表
    • RDD数据结构中存在分区列表,用于执行任务时并行计算,是实现分布式计算的重要属性。
  • 分区计算函数
    • Spark在计算时,是使用分区函数对每一个分区进行计算
  • RDD之间的依赖关系
    • RDD是计算模型的封装,当需求中需要将多个计算模型进行组合时,就需要将多个RDD建立依赖关系
  • 分区器(可选)
    • 当数据为KV类型数据时,可以通过设定分区器自定义数据的分区
  • 首选位置(可选)
    • 计算数据时,可以根据计算节点的状态选择不同的节点位置进行计算

RDD创建

从集合(内存)中创建RDD

val sparkConf = new SparkConf().setMaster("local[*]").setAppName("spark")
val sparkContext = new SparkContext(sparkConf)

val rdd1 = sparkContext.parallelize(List(1,2,3,4))
val rdd2 = sparkContext.makeRDD(List(1,2,3,4))

rdd1.collect().foreach(println)
rdd2.collect().foreach(println)
sparkContext.stop()

// 从底层代码实现来讲,makeRDD方法其实就是parallelize方法
def makeRDD[T: ClassTag](
    seq: Seq[T],
    numSlices: Int = defaultParallelism): RDD[T] = withScope {
  parallelize(seq, numSlices)
}

从外部存储(文件)创建RDD

由外部存储系统的数据集创建RDD包括:本地的文件系统,所有Hadoop支持的数据集,比如HDFS、HBase等。

val sparkConf = new SparkConf().setMaster("local[*]").setAppName("spark")
val sparkContext = new SparkContext(sparkConf)

val fileRDD: RDD[String] = sparkContext.textFile("input")
fileRDD.collect().foreach(println)

sparkContext.stop()

RDD算子

  • Transform算子:生成一个新的RDD
    • 如map/filter/flatMap/groupByKey/reduceByKey/...
  • Action算子:触发Job 提交
    • 如collect/count/take/saveAsTextFile/...

RDD依赖

RDD只支持粗粒度转换,即在大量记录上执行的单个操作。将创建RDD的一系列Lineage(血统)记录下来,以便恢复丢失的分区。RDD的Lineage会记录RDD的元数据信息和转换行为,当该RDD的部分分区数据丢失时,它可以根据这些信息来重新运算和恢复丢失的数据分区。

RDD 依赖:描述父子RDD之间的依赖关系(lineage)

这里所谓的依赖关系,其实就是两个相邻RDD之间的关系

image-20220801032113040.png

  • 窄依赖:表示每一个父(上游)RDD的Partition最多被子(下游)RDD的一个Partition使用,窄依赖我们形象的比喻为独生子女。
    • NarrowDependency
    • OneToOneDependency
    • RangeDependency
    • PruneDependency
  • 宽依赖(会产生Shuffle):表示同一个父(上游)RDD的Partition被多个子(下游)RDD的Partition依赖,会引起Shuffle,总结:宽依赖我们形象的比喻为多生。
    • ShuffleDependency

RDD执行流程

42d510c4-e92e-49a8-9b2d-cea67e40e4e4.jpg

划分Stage的整体思路:从后往前推,遇到宽依赖就断开,划分为一个Stage。遇到窄依赖,就将这个RDD加入该Stage中,DAG最后一个阶段会为每个结果的Partition生成一个ResultTask。每个Stage里面的Task数量由最后一个RDD的Partition数量决定,其余的阶段会生成ShuffleMapTask。

当RDD对象创建后,SparkContext会根据RDD对象构建DAG有向无环图,然后将Task提交给DAGScheduler。DAGScheduler根据ShuffleDependency将DAG划分为不同的Stage,为每个Stage生成TaskSet任务集合,并以TaskSet为单位提交给TaskScheduler。TaskScheduler根据调度算法(FIFO/FAIR)对多个TaskSet进行调度,并通过集群中的资源管理器(Standalone模式下是Master,Yarn模式下是ResourceManager)把Task调度(locality)到集群中Worker的Executor,Executor由SchedulerBackend提供。

RDD任务切分中间分为:Application、Job、Stage和Task

  • Application:初始化一个SparkContext即生成一个Application;
  • Job:一个Action算子就会生成一个Job;
  • Stage:Stage等于宽依赖(ShuffleDependency)的个数加1;
  • Task:一个Stage阶段中,最后一个RDD的分区个数就是Task的个数。

Scheduler(调度器)

当Driver起来后,Driver则会根据用户程序逻辑准备任务,并根据Executor资源情况逐步分发任务。在详细阐述任务调度前,首先说明下Spark里的几个概念。一个Spark应用程序包括Job、Stage以及Task三个概念:

  • Job是以Action方法为界,遇到一个Action方法则触发一个Job;
  • Stage是Job的子集,以RDD宽依赖(即Shuffle)为界,遇到Shuffle做一次划分;
  • Task是Stage的子集,以并行度(分区数)来衡量,分区数是多少,则有多少个task。

Spark的任务调度总体来说分两路进行,一路是Stage级的调度,一路是Task级的调度,总体调度流程如下图所示:

image-20220801034148414.png

Spark RDD通过其Transactions操作,形成了RDD血缘(依赖)关系图,即DAG,最后通过Action的调用,触发Job并调度执行,执行过程中会创建两个调度器:DAGScheduler和TaskScheduler。

  • DAGScheduler负责Stage级的调度,主要是将job切分成若干Stages,并将每个Stage打包成TaskSet交给TaskScheduler调度。
  • TaskScheduler负责Task级的调度,将DAGScheduler给过来的TaskSet按照指定的调度策略分发到Executor上执行,调度过程中SchedulerBackend负责提供可用资源,其中SchedulerBackend有多种实现,分别对接不同的资源管理系统。
    • TaskScheduler支持两种调度策略,一种是FIFO,也是默认的调度策略,另一种是FAIR。

Memory Management(内存管理)

image-20220801080905624.png

Spark 作为一个基于内存的分布式计算引擎,Spark采用统一内存管理机制。重点在于动态占用机制。

  • 设定基本的存储内存(Storage)和执行内存(Execution)区域,该设定确定了双方各自拥有的空间的范围,UnifiedMemoryManager统一管理Storage/Execution内存

    • UnifiedMemoryManager统一管理多个并发Task的内存分配 image-20220801081124495.png

      每个Task获取的内存区间为1(2N)\frac{1}{(2*N)}1N\frac{1}{N}, N为当前Executor中正在并发运行的task数量

  • 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间

  • 当Storage空闲,Execution可以借用Storage的内存使用,可以减少spill等操作, Execution内存不能被Storage驱逐。Execution内存的空间被Storage内存占用后,可让对方将占用的部分转存到硬盘,然后"归还"借用的空间

  • 当Execution空闲,Storage可以借用Execution内存使用,当Execution需要内存时,可以驱逐被Storage借用的内存,可让对方将占用的部分转存到硬盘,然后"归还"借用的空间

user memory存储用户自定义的数据结构或者spark内部元数据

Reserverd memory:预留内存,防止OOM,

堆内(On-Heap)内存/堆外(Off-Heap)内存:Executor 内运行的并发任务共享 JVM 堆内内存。为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 可以直接操作系统堆外内存,存储经过序列化的二进制数据。减少不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。

Shuffle

Spark是以Shuffle为边界,将一个Job划分为不同的Stage,这些Stage构成了一个大粒度的DAG。Spark的Shuffle主要分为Shuffle Write和Shuffle Read两个阶段。

执行Shuffle的主体是Stage中的并发任务,这些任务分为ShuffleMapTask和ResultTask两大类。ShuffleMapTask要进行Shuffle,ResultTask负责返回计算结果,一个Job中只有最后一个Stage采用ResultTask,其它均为ShuffleMapTask。ShuffleMapStage的结束伴随着shuffle文件的写磁盘。

image-20220801083139478.png

  • Shuffle Write阶段:发生于ShuffleMapTask对该Stage的最后一个RDD完成了map端的计算之后,首先会判断是否需要对计算结果进行聚合,然后将最终结果按照不同的reduce端进行区分,写入前节点的本地磁盘。
  • Shuffle Read阶段:开始于reduce端的任务读取ShuffledRDD之后,首先通过远程或者本地数据拉取获得Write阶段各个节点中属于当前任务的数据,根据数据的Key进行聚合,然后判断是否需要排序,最后生成新的RDD。

image-20220801082652272.png

SortShuffl

image-20220801084037393.png

每个MapTask生成一个Shuffle数据文件和一个index文件,该文件中的记录首先是按照 Partition Id 排序,每个 Partition 内部再按照 Key 进行排序,Map Task 运行期间会顺序写每个 Partition 的数据,同时生成一个索引文件记录每个 Partition 的大小和偏移量。

image-20220801084207410.png

shuffle write的文件被NodeManage r中的Shuffle Service托管,供后续Reduce Task进行shuffle fetch,如果Executor空闲,DRA可以进行回收

SparkSQL 原理解析

Spark SQL是Spark的其中一个模块,用于结构化数据处理。与基本的Spark RDD API不同,Spark SQL提供的接口为Spark提供了有关数据结构和正在执行的计算的更多信息,Spark SQL会使用这些额外的信息来执行额外的优化。

下图为Spark SQL的流程

image-20220801084249130.png

  • SQL Parse: 将SparkSQL字符串或DataFrame解析为一个抽象语法树/AST,即Unresolved Logical Plan

  • Analysis:遍历整个AST,并对AST上的每个节点进行数据类型的绑定以及函数绑定,然后根据元数据信息Catalog对数据表中的字段进行解析。 利用Catalog信息将Unresolved Logical Plan解析成Analyzed Logical plan

  • Logical Optimization:该模块是Catalyst的核心,主要分为RBO和CBO两种优化策略,其中RBO是基于规则优化,CBO是基于代价优化。 利用一些规则将Analyzed Logical plan解析成Optimized Logic plan

  • Physical Planning: Logical plan是不能被spark执行的,这个过程是把Logic plan转换为多个Physical plans

  • CostModel: 主要根据过去的性能统计数据,选择最佳的物理执行计划(Selected Physical Plan)。

  • Code Generation: sql逻辑生成Java字节码

Catalyst 优化器

RBO

Rule Based Optimizer(RBO): 基于规则优化,对语法树进行一次遍历,模式匹配能够满足特定规则的节点,再进行相应的等价转换。

下图为 逻辑树 --> RBO规则执行 --> 物理树 流程

image-20220801084843546.png

Batch执行策略:

  • Once:只执行一次
  • FixedPoint:重复执行,直到plan不再改变, 或者执行达到固定次数(默认100次)

两种遍历规则:

  • transformDown 先序遍历树进行规则匹配
  • transformUp 后序遍历树进行规则匹配

CBO

Cost Based Optimizer(CBO): 基于代价优化,根据优化规则对关系表达式进行转换,生成多个执行计划,然后CBO会通过根据统计信息(Statistics)和代价模型(Cost Model)计算各种可能执行计划的代价,从中选用COST最低的执行方案,作为实际运行方案。CBO依赖数据库对象的统计信息,统计信息的准确与否会影响CBO做出最优的选择。

JoinSelection

  • Broadcast Join:大表和小表
  • Shuffle Hash Join
  • SortMergeJoin:大表

Adaptive Query Execution(AQE)

AQE对于整体的Spark SQL的执行过程做了相应的调整和优化,它最大的亮点是可以根据已经完成的计划结点真实且精确的执行统计结果来不停的反馈并重新优化剩下的执行计划。每个Task结束会发送MapStatus信息给Driver,Task的MapStatus中包含当前Task Shuffle产生的每个Partition的size统计信息,Driver获取到执行完的StagesMapStatus信息之后,按照MapStatus中partition大小信息识别匹配一些优化场景,然后对后续未执行的Plan进行优化。

AQE框架三种优化场景:

  • 动态合并shuffle分区(Dynamically coalescing shuffle partitions)

  • 动态调整Join策略(Dynamically switching join strategies)

  • 动态优化数据倾斜Join(Dynamically optimizing skew joins)

Coalescing Shuffle Partitions

未经动态合并Shuffle分区时

image-20220801093056231.png

动态合并Shuffle后

image-20220801093225875.png

作业运行过程中,根据前面运行完的Stage的MapStatus中实际的partiton大小信息,可以将多个相邻的较小的partiton进行动态合并,由一个Task读取进行处理。

spark.sql.adaptive.coalescePartitions.enabled
spark.sql.adaptive.coalescePartitions.initialPartitionNum
spark.sql.adaptive.advisoryPartitionSizelnBytes

Switching Join Strategies

AQE运行过程中动态获取准确Join的leftChild/rightChild的实际大小,将SortMergeJoin (SMJ) 转化为BroadcastHashJoin (BHJ)

image-20220801093416231.png

Optimizing Skew Joins

AQE根据MapStatus信息自动检测是否有倾斜,将大的partition拆分成多个Task进行Join。

未优化前

image-20220801093527262.png

优化后

image-20220801093542609.png

spark.sql.adaptive.skewJoin.enabled
spark.sql.adaptive.skewJoin.skewedPartitionFactor
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBvtes

Runtime Filter

实现在Catalyst中。动态获取Filter内容做相关优化,当我们将一张大表和一张小表等值连接时,我们可以从小表侧收集一些统计信息,并在执行join前将其用于大表的扫描,进行分区修剪或数据过滤。可以大大提高性能

image-20220801093659974.png

Runtime优化分两类:

  1. 全局优化:从提升全局资源利用率、消除数据倾斜、降低IO等角度做优化。包括AQE。
  2. 局部优化:提高某个task的执行效率,主要从提高CPU与内存利用率的角度进行优化。依赖Codegen技术。

Codegen

从提高cpu的利用率的角度来进行runtime优化。

  1. Expression级别

表达式常规递归求值语法树。需要做很多类型匹配、虚函数调用、对象创建等额外逻辑,这些overhead远超对表达式求值本身,为了消除这些overhead,Spark Codegen直接拼成求值表达式的java代码并进行即时编译

  1. WholeStage级别

传统的火山模型:SQL经过解析会生成一颗查询树,查询树的每个节点为Operator,火山模型把operator看成迭代器,每个迭代器提供一个next()接口。通过自顶向下的调用 next 接口,数据则自底向上的被拉取处理,火山模型的这种处理方式也称为拉取执行模型,每个Operator 只要关心自己的处理逻辑即可,耦合性低。

火山模型问题:数据以行为单位进行处理,不利于CPU cache 发挥作用;每处理一行需要调用多次next() 函数,而next()为虚函数调用。会有大量类型转换和虚函数调用。虚函数调用会导致CPU分支预测失败,从而导致严重的性能回退

Spark WholestageCodegen:为了消除这些overhead,会为物理计划生成类型确定的java代码。并进行即时编译和执行。

Codegen打破了Stage内部算子间的界限,拼出来跟原来的逻辑保持一致的裸的代码(通常是一个大循环)然后把拼成的代码编译成可执行文件。

业界挑战与实践

Shuffle稳定性问题

在大规模作业下 开源ESS的实现机制容易带来大量随机读导致磁盘的IOPS瓶颈、fetch请求积压等问题,进而导致运算过程中经常出现stage重算及作业失败继而引起资源使循环

  • 业内目前解决方案:各公司开源的RemoteShuffleService进行优化

SQL执行性能问题

压榨CPU资源 CPU瓶颈

  • 超标量流水线/乱序执行/分支预测 并行程序越多越好/CPU缓存友好(后续cache预存)/SIMD(单指令多数据流)
  • Vectorized 向量化(拉取模式函数返回一批 CPU开销一组数据分摊 适用于列存储 缺点中间数据很大)/ Codegen (打破算子之间界限,复合算子)
  • Codegen限制Java代码,相对native C++等性能有缺陷,无法进行SIMD优化

参数推荐/作业诊断

  • 问题:
    • Spark参数很多,资源类/shuffle/join/agg等,调参难度大
    • 参数不合理的作业对资源利用率/shuffle稳定性/性能有非常大影响
    • 线上作业失败/运行慢,用户排查难度大
  • 解决方案:
    • 自动参数推荐/作业诊断