这是我参加「第四届青训营」笔记创作活动的第3天。这篇笔记的主要内容有大数据处理引擎Spark(SparkCore中的RDD/调度/Shuffle/内存管理等概念机制),以及SQL在Spark引擎中执行的详细流程。
一、大数据处理引擎Spark
1. 大数据处理技术栈
2. 常见大数据处理链路
3. 开源大数据处理引擎
4. 什么是Spark?
Apache Spark是一种多语言引擎,用于在单节点机器或集群上执行数据工程、数据科学和机器学习。其主要特征有:
- 批处理/流式数据:Spark支持使用Python、SQL、Scala、Java或R,统一批量处理和实时流式处理您的数据。
- SQL分析:为仪表板和临时报告执行快速、分布式的ANSI SQL查询。比大多数数据仓库运行得快。
- 大规模数据科学:对PB级数据执行探索性数据分析(EDA),而无需进行下采样。
- 机器学习:在笔记本电脑上训练机器学习算法,并使用相同的代码扩展到包含数千台机器的容错集群。
Spark发展历程:
Spark生态和特点:
Spark生态系统主要包含了Spark Core、Spark SQL、Spark Structured Streaming、MLlib和GraphX等组件,各个组件的具体功能如下:
- Spark Core:Spark核心组件,它实现了Spark的基本功能,包含任务调度、内存管理、错误恢复、与存储系统交互等模块。
- Spark SQL:用来操作结构化数据的核心组件,通过Spark SQL可以直接查询Hive、HBase等多种外部数据源中的数据。
- Spark Structured Streaming:Spark提供的流式计算框架,支持高吞吐量、可容错处理的实时流式数据处理。
- MLib(机器学习):Spark提供的关于机器学习功能的算法程序库,包括分类、回归、聚类、协同过滤算法等,还提供了模型评估、数据导入等额外的功能。
- GraphX(图计算):Spark提供的分布式图处理框架,拥有对图计算和图挖掘算法的API接口以及丰富的功能和运算符。
- 独立调度器、Yarn、Mesos、Kubernetes:Spark框架可以高效地在一个到数千个节点之间伸缩计算,集群管理器则主要负责各个节点的资源管理工作,为了实现这样的要求,同时获得最大灵活性,Spark支持在各种集群管理器(Cluster Manager)上运行。
- 统一引擎,支持多种分布式场景
- 多语言支持
- 可读写丰富数据源
- 丰富灵活的API/算子
- 支持K8S/YARN/Mesos 资源调度
5. Spark 特点
5.1. 多语言支持
(1)SQL:
(2)Java:
(3)Scala:
(4)R
(5)Python
5.2. 丰富数据源
内置DataSource:
- Text
- Parquet/ORC
- JSON/CSV
- JDBC
自定义DataSource:
- 实现DataSourceV1/V2 API
- HBase/Mongo/ElasticSearch/...
- 针对ApacheSpark的第三方软件包的社区索引(spark-packages.org)
5.3. 丰富的API/算子
SparkCore→RDD:
- map/filter/flatMap/mapPartitions
- repartition/groupBy/reduceBy/join/aggregate
- foreach/foreachPartition
- count/max/min
SparkSQL→DataFrame:
- select/filter/groupBy/agg/join/union/orderBy...
- Hive UDF/自定义UDF
6. Spark运行架构和部署方式
- Driver Program(驱动器):运行main()方法并创建SparkContext的进程。
- Cluster Manager(集群管理器):用于在集群上申请资源的外部服务(如:独立部署的集群管理器、Mesos或者Yarn)。
- Worker Node(工作节点):集群上运行应用程序代码的任意一个节点。
- Executor(执行器):在集群工作节点上为某个应用启动的工作进程,该进程负责运行计算任务,并为应用程序存储数据。
- Task(任务):执行器的工作单元。
Spark应用在集群上运行时,包括了多个独立的进程,这些进程之间通过驱动程序(Driver Program)中的SparkContext对象进行协调,SparkContext对象能够与多种集群资源管理器(Cluster Manager)通信,一旦与集群资源管理器连接,Spark会为该应用在各个集群节点上申请执行器(Executor),用于执行计算任务和存储数据。Spark将应用程序代码发送给所申请到的执行器,SparkContext对象将分割出的任务(Task)发送给各个执行器去运行。
注意:
- 每个Spark application都有其对应的多个executor进程。Executor进程在整个应用程序生命周期内,都保持运行状态,并以多线程方式执行任务。这样做的好处是,Executor进程可以隔离每个Spark应用。从调度角度来看,每个driver可以独立调度本应用程序的内部任务。从executor角度来看,不同Spark应用对应的任务将会在不同的JVM中运行。然而这样的架构也有缺点,多个Spark应用程序之间无法共享数据,除非把数据写到外部存储结构中。
- Spark对底层的集群管理器一无所知,只有Spark能够申请到executor进程,能与之通信即可。这种实现方式可以使Spark比较容易的在多种集群管理器上运行,例如Mesos、YARN、Kubernetes。
- Driver Program在整个生命周期内必须监听并接受其对应的各个executor的连接请求,因此driver program必须能够被所有worker节点访问到。
- 因为集群上的任务是由driver来调度的,driver应该和worker节点距离近一些,最好在同一个本地局域网中,如果需要远程对集群发起请求,最好还是在driver节点上启动RPC服务响应这些远程请求,同时把driver本身放在离集群Worker节点比较近的机器上。
以下为Spark三种部署方式:
(1)Spark Local Mode:
- 本地测试/单进程多线程模式
- spark-sql --master local[*]...
(2)Spark Standalone Mode:
- 需要启动Spark的Standalone集群的Master/Worker
(3)on YARN/K8S:
- 依赖外部资源调度器(YARN/K8S)
- spark-sql --master yarn...
二、SparkCore原理解析
1. SparkCore
- RDD(Resilient Distributed Dataset):弹性分布式数据集,是一个容错的、并行的数据结构。
- RDD算子:对任何函数进行某一项操作都可以认为是一个算子,RDD算子是RDD的成员函数。
- DAG(Directed Acyclic Graph):有向无环图,Spark中的RDD通过一系列的转换算子操作和行动算子操作形成了一个DAG。
- DAGScheduler:将作业的DAG划分成不同的Stage,每个Stage都是TaskSet任务集合,并以TaskSet为单位提交给TaskScheduler。
- TaskScheduler:通过TaskSetManager管理Task,并通过集群中的资源管理器(Standalone模式下是Master,Yarn模式下是ResourceManager)把Task发给集群中Worker的Executor。
- Shuffle:Spark中数据重分发的一种机制。
2. 什么是RDD?
RDD(Resilient Distributed Dataset):表示可并行操作的不可变的分区元素集合。
描述RDD的五要素:
- Partitions:分区列表
- Compute:一个用于计算每个分区的函数
- Dependencies:RDD之间的依赖关系
- Partitioner:键值RDD的分片函数(例如RDD是散列化的)
- PreferredLocations:计算每个分区的首选位置列表(例如HDFS文件的块位置)
2.1. 如何创建RDD?
RDD的创建可以通过对内存中的数据并行化计算结果,或直接读取分布式数据库(S3,HDFS,Cassandra等等)创建,也可以直接读取本地文件创建。
内置RDD:
ShuffleRDD/HadoopRDD/JDBCRDD/KafkaRDD/UnionRDD/MapPartitionsRDD/...
自定义RDD:
- class CustomRDD(...) extends RDD {}
- 实现五要素对应的函数
2.2. RDD算子
以下为两类RDD算子:
- Transform算子:生成一个新的RDD
(map/filter/flatMap/groupByKey/reduceByKey/...)
- Action算子:触发Job提交
(collect/count/take/saveAsTextFile/...)
2.3. RDD依赖
- RDD依赖:描述父子RDD之间的依赖关系(lineage)。
- 窄依赖:父RDD的每个partition至多对应一个子RDD分区。 (NarrowDependency、OneToOneDependency、RangeDependency、PruneDependency)
- 宽依赖:父RDD的每个partition都可能对应多个子RDD分区。 (shuffleDependency)
2.4. RDD执行流程
- Job:RDD action算子触发
- Stage:依据宽依赖划分
- Task:Stage内执行单个partition任务
- 划分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提供。
3. 内存管理
- Executor 内存主要有两类:Storage、Execution。
- UnifiedMemoryManager:统一管理多个并发Task的内存分配
- Storage/Execution:内存
- user memory:存储用户自定义的数据结构或者spark内部元数据。
- Reserverd memory:预留内存,防止OOM。
- 堆内(On-Heap)内存/堆外(Off-Heap)内存:Execution 内运行的并发任务共享JVM堆内内存。为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 可以直接操作系统堆外内存,存储经过序列化的二进制数据。减少不必要的内存开销,以及频繁的 GC 扫描和回收,提升处理性能。
Spark 作为一个基于内存的分布式计算引擎,Spark采用统一内存管理机制。重点在于动态占用机制。
- 设定基本的存储内存(Storage)和执行内存(Execution)区域,该设定了确定了双方各自拥有的空间的范围,UnifiedMemoryManager统一管理Storage/Execution内存。
- 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间。
- Storage 和 Execution 内存使用是动态调整,可以相互借用。
- 当Storage 空闲,Execution可以借用 Storage 的内存使用,可以减少 spill 等操作,Execution 使用的内存不能被 Storage 驱逐。Execution内存的空间被Storage内存占用后,可让对方将占用的部分转存到硬盘,然后“归还”借用的空间。
- 当 Execution 空闲,Storage 可以借用 Execution 的内存使用,当 Execution 需要内存时,可以驱逐被 Storage 借用的内存,直到spark.memory.storageFraction 边界。
每个Task获取的内存区间为1/(2*N)~1/N,N为当前Executor中正在并发运行的task数量。
4. Shuffle
spark.shuffle.manager→sort
- 每个MapTask生成一个Shuffle数据文件和一个index文件。
- dataFile中的数据按照partitionId进行排序,同一个partitionId的数据聚集在一起。
- indexFile保存了所有partitionId在dataFile中的位置信息,方便后续 Reduce Task 能 Fetch 到对应 partitionId 的数据。
shuffle write 的文件被 NodeManager 中的 Shuffle Service 托管,供后续 ReduceTask 进行 shuffle fetch,如果Executor空闲,DRA 可以进行回收。
三、SparkSQL 原理解析
1. SparkSQL
- DataFrame:是一种以RDD为基础的分布式数据集,被称为SchemaRDD。
- Catalyst:SparkSQL核心模块,主要是对执行过程中的执行计划进行处理和优化。
- DataSource:SparkSQL支持通过DataFrame接口对各种数据源进行操作。
- Adaptive Query Execution:自适应查询执行。
- Runtime Filter:运行时过滤。
- Codegen:生成程序代码的技术或系统,可以在运行时环境中独立于生成器系统使用。
- Unresolved Logical Plan:未解析的逻辑计划,仅仅说数据结构,不包含任何数据信息。
- Logical Plan:解析后的逻辑计划,节点中绑定了各种优化信息。
- Optimized Logical Plan:优化后的逻辑计划。
- Physical Plans:物理计划列表。
- Selected Physical Plan:从列表中按照一定的策略选取最优的物理计划。
SparkSQL执行过程:
- 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字节码。
2. Catalyst 优化器
Catalyst 优化器-Rule Based Optimizer(RBO):基于规则优化,对语法树进行一次遍历,模式匹配能够满足特定规则的节点,再进行相应的等价转换。
Batch执行策略:
- Once→只执行一次
- FixedPoint→重复执行,直到plan不再改变,或者执行达到固定次数(默认100次)
Cost Based Optimizer(CBO):基于代价优化,根据优化规则对关系表达式进行转换,生成多个执行计划,然后CBO会通过根据统计信息(Statistics)和代价模型(Cost Model)计算各种可能执行计划的代价,从中选用COST最低的执行方案,作为实际运行方案。CBO依赖数据库对象的统计信息,统计信息的准确与否会影响CBO做出最优的选择。
3. Adaptive Query Execution(AQE)
AQE对于整体的Spark SQL的执行过程做了相应的调整和优化,它最大的亮点是可以根据已经完成的计划结点真实且精确的执行统计结果来不停的反馈并重新优化剩下的执行计划。
- 每个Task结束会发送MapStatus信息给Driver。
- Task的MapStatus中包含当前Task Shuffle产生的每个Partition的size统计信息。
- Driver获取到执行完的Stages的MapStatus信息之后,按照MapStatus中partition大小信息识别匹配一些优化场景,然后对后续未执行的Plan进行优化。
目前支持的优化场景:
- Partition合并,优化shuffle读取,减少reduce task个数
- SMJ→BHJ
- Skew Join优化
3.1 AQE-Partition 合并(coalescing shuffle partitions)
问题:spark.sql.shuffle.partition作业粒度参数,一个作业中所有Stage都一样,但是每个Stage实际处理的数据不一样,可能某些Stage的性能比较差。
比如:
- partition参数对某个Stage过大,则可能单个partition的大小化较小,而且Task个数会比较多,shuffle fetch阶段产生大量的小块的随机读,影响性能。
- partition参数对某个Stage过小,则可能单个partition的大小比较大,会产生更多的spill或者OOM。
解决办法:作业运行过程中,根据前面运行完的Stage的MapStatus中实际的partition大小信息,可以将多个相邻的较小的partition进行动态合并,由一个Task读取进行处理。
- spark.sql.adaptive.coalescePartitions.enabled
- spark.sql.adaptive.coalescePartitions.initialPartitionNum
- spark.sql.adaptive.advisoryPartitionSizeInBytes
3.2 AQE-Switching Join Strategies
SortMergeJoin(SMJ)→ BroadcastHashJoin(BHJ)
问题:Catalyst Optimizer优化阶段,算子的statistics估算不准确,生成的执行计划并不是最优。
解决方法:AQE运行过程中动态获取准确Join的leftChild/rightChild的实际大小,将SMJ转换为BHJ。
3.3 AQE-Optimizing Skew Joins
AQE根据MapStatus信息自动检测是否有倾斜,将大的partition拆分成多个Task进行Join。
- spark.sql.adaptive.skewJoin.enabled
- spark.sql.adaptive.skewwJoin.skewedPartitionFactor
- spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes
4. Runtime Filter
- 实现在Catalyst中。动态获取Filter内容做相关优化,当将一张大表和一张小表等值连接时,可以从小表侧收集一些统计信息,并在执行join前将其用于大表的扫描,进行分区修剪或数据过滤。可以大大提高性能。
- Runtime Filter减少了大表的扫描,shuffle的数据量以及参加Join的数据量,所有对整个集群IO/网络/CPU有比较大的节省(10TB TPC-DS 获得了大约35%的改进)。
5. Codegen
- 从提高cpu的利用率的角度来进行runtime优化。
5.1 Codegen-表达式(Expression)
表达式常规递归求值语法树。需要做很多类型匹配、虚函数调用、对象创建等额外逻辑,这些overhead远超对表达式求值本身,为了消除这些overhead,Spark Codegen直接拼成求值表达式的java代码并进行即时编译。
将表达式中的大量虚函数调用压平到一个函数内部,类似手写代码。
动态生成代码,Janino即时编译执行。
5.2 codegen—WholeStageCodegen
- 传统的火山模型:SQL经过解析会生成一颗查询树,查询树的每个节点为Operator,火山模型把operator看成迭代器,每个迭代器提供一个next()接口。通过自顶向下的调用 next 接口,数据则自底向上的被拉取处理,火山模型的这种处理方式也称为拉取执行模型,每个Operator只有关心自己的处理逻辑即可,耦合性低。
- 火山模型问题:数据以行为单位进行处理,不利于CPU cache发挥作用;每处理一行需要调用多次next()函数,而next()为虚函数调用。会有大量类型转换和虚函数调用。虚函数调用会导致CPU分支预测失败,从而导致严重的性能回退。
- Spark WholestageCodegen:为了消除这些overhead,会为物理计划生成类型确定的java代码。并进行即时编译和执行。
- Codegen打破了Stage内部算子间的界限,拼出来跟原来的逻辑保持一致的裸的代码(通常是一个大循环)然后把拼成的代码编译成可执行文件。
- 算子之间大量的虚函数调用,开销大。
- 将同一个Stage中的多个算子压平到一个函数内部进行执行。
动态生成代码,Janino即时编译执行。
一个SQL包含多个Stage的WholeStageCodegen。