这是我参与「第四届青训营 -大数据场」笔记创作活动的第7篇笔记
本文已参与「新人创作礼」活动, 一起开启掘金创作之路。
SparkCore原理解析
RDD
RDD:可容错的并行执行分布式数据集
RDD的特性:
- 分区列表(Partitions):每个RDD都会有多个分区,分区运行在集群的不同节点上,每一个分区会被task处理,分区决定并行计算的数量,创建RDD时可以指定分区
- 计算函数compute():Spark 中RDD 的计算是以分片为单位的,每个RDD都会实现compute 函数以达到这个目的。ompute 函数会对迭代器进行复合,不需要保存每次计算的结果。
def compute(split: Partition, context: TaskContext): Iterator[T]
- 依赖:每一个RDD都会依赖其他的RDD
- 分区器(Partitioner):作用在key-value格式的RDD上
- 优先位置(PreferredLocations):对于一个HDFS 文件来说,这个列表保存的就是每个Partition 所在的块的位置。按照“移动数据不如移动计算”的理念,Spark 在进行任务调度的时候,会尽可能地将计算任务分配到其所要处理数据块的存储位置。
创建RDD
- 从稳定的文件存储系统(比如HDFS、Hive、HBase)中创建RDD,如下:
//从hdfs文件中创建
JavaRDD<String> textFileRDD = sc.textFile("hdfs://master:9999/users/hadoop-twq/word.txt");
从本地文件系统的文件中,注意file:后面肯定是至少三个///,四个也行,不能是两个。如果指定第二个参数的话,表示创建的RDD的最小的分区数,如果文件分块的数量大于指定的分区数的话则以文件的分块数量为准
JavaRDD textFileRDD = sc.textFile(“hdfs://master:9999/users/hadoop-twq/word.txt” 2 );
- 从父RDD转换得到新的RDD。可以经过transformation api从一个已经存在的RDD上创建一个新的RDD,以下是map这个转换api
JavaRDD<String> mapRDD = textFileRDD.map(new Function<String, String>() {
@Override
public String call(String s) throws Exception {
return s + "test";
}
});
System.out.println("mapRDD = " + mapRDD.collect());
- 用SparkContext的parallelize方法将Driver上的数据集并行化,形成分布式的RDD
从内存中的列表数据创建一个RDD,可以指定RDD的分区数,如果不指定的话,则取所有Executor的所有cores数量
//创建一个单类型的JavaRDD
JavaRDD<Integer> integerJavaRDD = sc.parallelize(Arrays.asList(1, 2, 3, 3, 4), 2);
System.out.println("integerJavaRDD = " + integerJavaRDD.glom().collect());
//创建一个单类型且类型为Double的JavaRDD
JavaDoubleRDD doubleJavaDoubleRDD = sc.parallelizeDoubles(Arrays.asList(2.0, 3.3, 5.6));
System.out.println("doubleJavaDoubleRDD = " + doubleJavaDoubleRDD.collect());
//创建一个key-value类型的RDD
import scala.Tuple2;
JavaPairRDD<String, Integer> javaPairRDD = sc.parallelizePairs(Arrays.asList(new Tuple2("test", 3), new Tuple2("kkk", 3)));
System.out.println("javaPairRDD = " + javaPairRDD.collect());
RDD算子
- Transformation(变换):生成一个新的RDD
- Action(行动):触发Job提交
RDD依赖
- 窄依赖:父RDD的每个Partition至多对应一子RDD分区,map操作就是一个父RDD的一个分区,union操作就是两个父RDD的一个分区。父RDD的partition至多被一个子RDD partition依赖(OneToOneDependency,RangeDependency,)
- 宽依赖:父RDD的每个Partition都可能对应多个子RDD分区(ShuffleDependency)
特性:
- 窄依赖可以在某个计算节点上直接通过计算父RDD的某块数据得到子RDD对应的某块数据;宽依赖则要等到父RDD所有数据都计算完成,且父RDD的计算结果进行hash并传到对方节点上之后才能计算子RDD。
- 数据丢失时,对于窄依赖,只要重新计算丢失的那一块数据就可以完成恢复;对于宽依赖,则要通过对祖先RDD中的所有数据块全部重新计算来恢复。
RDD执行流程
RDD在Spark中运行,主要分为以下三步:
- 创建RDD对象
- DAG scheduler模块介入运算,计算RDD之间的依赖关系,形成DAG
- 每一个Job被划分为多个stage,划分stage的一个主要依据是当前计算因子的输入是否确定,如果确定将其分在同一个stage,避免多个stage之间的消息传递开销
内存管理
堆内内存的大小,由 Spark 应用程序启动时的 –executor-memory 或 spark.executor.memory 参数配置。Executor 内运行的并发任务共享 JVM 堆内内存,这些任务在缓存 RDD 数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存,而这些任务在执行 Shuffle 时占用的内存被规划为执行(Execution)内存,剩余的部分不做特殊规划,那些 Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例,均占用剩余的空间。
堆内内存
UnifiedMemoryManager统一管理Storage/Execution内存,Storage和Execution内存使用时动态调整,可以相互借用。当Storage空闲,Execution可以借用Storage的内存使用,可以减少spill等操作,Execution使用的内存不能被Storage驱逐,当Execution空闲时,Storage可以借用Execution的内存使用。当Execution需要内存时,可以驱逐被Storage借用的内存,直到spark.memory.storageFraction边界
Spark 对堆内内存的管理是一种逻辑上的"规划式"的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请后和释放前记录这些内存,我们来看其具体流程:
- 申请内存:
- Spark 在代码中 new 一个对象实例
- JVM 从堆内内存分配空间,创建对象并返回对象引用
- Spark 保存该对象的引用,记录该对象占用的内存
- 释放内存:
- Spark 记录该对象释放的内存,删除该对象的引用
- 等待 JVM 的垃圾回收机制释放该对象占用的堆内内存
堆外内存
为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。利用 JDK Unsafe API(从 Spark 2.0 开始,在管理堆外的存储内存时不再基于 Tachyon,而是与堆外的执行内存一样,基于 JDK Unsafe API 实现),Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。
在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用,并由 spark.memory.offHeap.size 参数设定堆外空间的大小。除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存。
Shuffle
SortShuffleManager
运行原理:
- 普通运行机制
- bypass运行机制
当shuffle read task的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认为200),就会启用bypass机制。
External Shuffle Service
为了解耦数据计算和数据读取服务,Spark支持单独的服务来处理读取服务。这个单独的服务叫做External Shuffle Service,运行在每台主机上。管理该主机的所有Execuotor节点生成的shuffle数据。
下图展示了一个 shuffle 操作,有两台主机分别运行了三个 Map 节点。Map 节点生成完 shuffle 数据后,会将数据的文件路径告诉给 ExternalShuffleService。之后的 Reduce 节点读取数据,就只和 ExternalShuffleService 交互。
SparkSQL
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 要更加友好,门槛更低。
Catalyst
RBO,CBO策略
AQE-Adaptive Query Execution
自适应查询执行(AQE)的作用是对正在执行的查询任务进行优化。AQE使Spark计划器在运行过程中可以检测到在满足某种条件的情况下可以进行的动态自适应规划,自适应规划会基于运行时的统计数据对正在运行的任务进行优化,从而提升性能。
AQE主要用于解决如下问题:
(1)统计信息过期或缺失导致估计错误。
(2)收集统计信息的代价较大。
(3)因某些谓词使用自定义UDF导致无法预估。
(4)开发人员在SQL上手动指定hints跟不上数据的变化。
Runtime Filter
- Runtime Filter涉及两种模式,一种是In Filter(In Filter会转换为Semi Join),一种是Bloom Filter
- Bloom Filter的参数控制 n_items(多少行数据),n_bits(多个位来标识)
Codegen
- Expression:
- WholeStage:
(待补充)
SparkSQL和DataFrame来的数据转化为RDDs的过程
- 将SparkSQL的字符串解析成AST树,变成未解析的逻辑计划(Unresolved Logical Plan)
- Analysis模块中,会遍历整个AST树,根据Catalog的元数据来对数据表中的数据字段进行解析
- 解析后的逻辑计划(Logical Plan),进入Logical Optimization 成为优化后的逻辑计划(Optimized Logical Plan)
- Logical Plan不能被Spark直接执行,通过Physical Planning模块变成Physical Plans
- Physical 通过Cost Model模块选出一个最佳的,即Selected Physical Plan
- 最后代码生成,实现
上述流程是预先根据SQL语句和数据分布对SQL进行解析、优化和执行的,但由于执行计划是预估的,准确性很难保证,因此执行计划并不是最理想的。有了AQE后,Spark就可以在任务运行过程中实时统计任务的执行情况,并通过自适应计划将统计结果反馈给优化器,从而对任务再次进行优化,这种边执行、边优化的方式极大提高了SQL的执行效率。