引入
在深入MapReduce中有提到,MapReduce虽然通过“分而治之”的思想,解决了海量数据的计算处理问题,但性能还是不太理想,这体现在两个方面:
- 每个任务都有比较大的overhead,都需要预先把程序复制到各个 worker 节点,然后启动进程;
- 所有的中间数据都要读写多次硬盘。map 的输出结果要写到硬盘上,reduce抓取数据排序合并之后,也要先写到本地硬盘上再进行读取,所以快不起来。
除此之外,MapReduce还有以下的问题:
- 算子不够丰富,仅有Map和Reduce,复杂算子的实现极为烦琐;
- MapReduce在处理迭代计算、实时查询和交互式数据挖掘任务时效率较低,因为每次迭代都需要将数据写入磁盘,导致大量的I/O开销;
- 无法支持血缘或上下游依赖的概念,失败重试只能从头开始,变相地无法实现迭代计算。
针对以上的缺陷,不同的计算引擎采取了不同的优化策略。例如Tez简化了MapReduce过程,支持DAG(Directed Acyclic Graph,有向无环图),细化MapReduce环节并灵活组合。Impala则专注于单节点纯内存计算。而Spark依托DAG Lineage、纯内存计算、RDD(分布式弹性数据集)等特性,以及与Hadoop生态极佳的兼容性,支持例如图计算、机器学习、流(Micro-Batch)计算等多样化的功能或场景,在一系列大数据引擎中脱颖而出,成为当今最主流的计算引擎之一。
Spark最初由加州大学伯克利分校的AMPLab开发,后来成为Apache软件基金会的顶级项目。
Spark的核心概念
下面通过Spark的一些核心概念,去进一步了解它。
RDD(弹性分布式数据集)
定义
RDD(Resilient Distributed Dataset)是Spark的核心抽象,是一个不可变的、分布式的数据集合,可以并行操作。RDD可以通过在存储系统中的文件或已有的Scala、Java、Python集合以及通过其他RDD的转换操作来创建。
特性
-
不可变性:RDD一旦创建,其数据就不能被修改。
-
分区:RDD的数据被划分为多个分区,每个分区可以独立计算。
-
依赖关系:RDD之间通过转换操作形成依赖关系,这些依赖关系构成了DAG(有向无环图)。
DAG(有向无环图)
定义
DAG是Spark中用于表示RDD之间依赖关系的有向无环图。DAG调度器负责将DAG分解成多个阶段(stages),每个阶段是一系列并行的任务。
工作原理
-
操作解析:当代码被提交到Spark的解释器时,系统会生成一个操作图,称为Operator Graph,记录了所有的Transformation操作及其依赖关系。
-
DAG调度器:当一个Action操作(如collect或save)被触发时,DAG调度器会将Operator Graph分解为多个Stage(阶段)。窄依赖(Narrow Dependency)的操作如map和filter不需要数据重新分区,属于同一阶段;宽依赖(Wide Dependency)的操作如reduceByKey需要数据Shuffle,不同阶段之间以宽依赖为界。
-
任务调度器:每个Stage会被拆分为多个基于数据分区的Task。Task调度器将这些Task分发到集群的Worker节点上执行。
-
执行与结果:每个Worker节点执行分配的Task,并将结果返回给Driver程序。DAG确保各个阶段按依赖顺序执行,并通过内存优化中间结果存储,最大限度减少I/O和通信开销。
作用
-
任务依赖分析:DAG调度器通过分析RDD之间的依赖关系,决定任务的执行顺序。
-
内存计算优化:通过减少Shuffle和磁盘读写,DAG提高了计算效率。
-
全局优化:DAG确保每个Stage都包含最少的任务,避免重复计算。
Shuffle机制
定义
Shuffle是Spark中的一种数据重新分区操作,通常在宽依赖(Wide Dependency)的操作中发生,如reduceByKey、groupByKey等。
工作原理
-
分区:Shuffle操作会将数据重新分区,通常会根据键(key)进行分区。
-
数据传输:数据从一个节点传输到另一个节点,以确保相同键的数据位于同一个节点上。
-
排序和分组:在目标节点上,数据会被排序和分组,以便进行后续的聚合操作。
优化策略
-
减少数据传输:通过数据本地性优化,尽量减少数据在节点之间的传输。
-
压缩:在Shuffle过程中,可以启用数据压缩,减少网络传输的开销。
-
缓存:在Shuffle之前,可以将数据缓存到内存中,减少重复计算。
数据缓存机制
定义
数据缓存机制是Spark中用于提高数据处理效率的一种机制,通过将数据缓存到内存中,减少重复计算的开销。
实现方式
-
cache():
cache()
方法是persist()
的简化版,其底层实现直接调用persist(StorageLevel.MEMORY_AND_DISK)
,默认将数据存储在内存中,如果内存不足,则溢写到磁盘。 -
persist():
persist()
方法允许用户选择存储级别(StorageLevel
),如MEMORY_ONLY
、MEMORY_AND_DISK
、DISK_ONLY
等。
作用
-
加速重复计算:通过缓存数据,避免重复计算DAG中的父节点。
-
灵活的存储策略:
persist()
方法提供了更灵活的存储策略,适应内存、磁盘等不同环境。
适用场景
-
数据需要被多次使用:适用于数据需要被多次使用,但不需要跨作业的容错能力的场景。
-
计算代价大:适用于计算代价大,但内存能够容纳数据的场景。
错误容忍机制
RDD的DAG Lineage(血缘)
指创建RDD所依赖的转换操作序列。当某个RDD的分区数据丢失时,Spark可以通过Lineage信息重新计算该分区的数据。
RDD的 DAG Lineage 主要用于描述数据从源到目标的转换过程,包括数据的流动、处理、转换等各个步骤。DAG Lineage能够清晰地展示数据的来源、去向以及数据在不同阶段的变化,帮助用户了解数据的全生命周期。
Checkpoint(检查点)
通过将RDD的状态保存到可靠的存储系统(如HDFS、S3等),以支持容错和优化长计算链。当Spark应用程序出现故障时,可以从检查点恢复状态。
故障恢复
- 节点故障:当Worker节点故障时,Spark会利用RDD的血统信息重新计算丢失的数据分区。如果设置了检查点,Spark会从检查点位置开始重新执行,减少计算开销。
- 驱动节点故障:如果驱动节点故障,Spark会通过Apache Mesos等集群管理器重新启动驱动节点,并恢复执行状态。
内存管理机制
内存模型
执行内存(Execution Memory):主要用于存储任务执行过程中的临时数据,如Shuffle的中间结果等。这部分内存主要用于任务的执行期间,任务完成后会被释放。
存储内存(Storage Memory):用于缓存中间结果(RDD)和DataFrame/DataSet的持久化数据。这部分内存是为了加速重复计算而存在的,数据可以被多次复用。
内存分配
内存分配比例:内存分配比例可以通过配置项spark.executor.memory来设置总的内存大小,并通过spark.storage.memoryFraction来指定存储内存所占的比例,默认为0.6。这意味着默认情况下,Executor的60%的内存用于存储,剩余的40%用于执行。
内存回收
LRU缓存淘汰策略:Spark采用LRU(Least Recently Used)缓存淘汰策略来管理存储内存中的数据。当存储内存不足时,Spark会根据LRU算法淘汰最近最少使用的数据。
Spill to Disk:当执行内存不足时,Spark会将一部分数据溢写到磁盘,以释放内存空间。例如,在Shuffle操作期间,如果内存不足以存放所有中间结果,Spark会将部分数据写入磁盘。
动态内存管理
动态调整内存分配:在Spark 2.x版本之后,引入了更先进的内存管理机制,支持动态调整执行内存和存储内存之间的比例。这意味着在运行时,Spark可以根据实际内存使用情况动态调整内存分配,从而更好地利用资源。
内存配置
- spark.executor.memory:设置Executor的总内存大小。
- spark.storage.memoryFraction:设置存储内存所占的比例。
- spark.shuffle.spill.compress:是否启用Shuffle数据的压缩。
- spark.serializer:设置序列化库,默认为org.apache.spark.serializer.KryoSerializer。
- spark.kryoserializer.buffer.max:设置Kryo序列化器的最大缓冲区大小。
Spark的执行原理
由于本专栏的重点是SQL,所以我们主要看Spark SQL的执行过程。相比于Hive的源码,Spark就贴心很多了,提供了org.apache.spark.sql.execution.QueryExecution类,这个类是Spark SQL查询执行的核心,它封装了从SQL解析到最终执行的整个过程,为开发者提供了丰富的接口来理解和调试查询执行的整个过程。
QueryExecution源码注释如下:</