0 源码全流程
0.1 Spark提交流程(YarnCluster)
-
脚本启动执行:
- 命令行参数启动脚本 spark-submit
- SparkSubmit解析参数 --master --class jar名称 jar路径 输入输出路径
- 创建客户端YarnClusterApplication Client YarnClient
- 封装提交参数和命令,将脚本命令封装成任务信息
- 将任务信息submitApplication提交到Resource Manager
-
启动ApplicationMaster
-
AM根据任务信息,启动Driver线程并初始化SparkContext
- 执行代码
- 初始化SC
-
YarnRMClient向Resource Manager注册AM,申请资源
-
RM返回资源可用列表
-
111
- launcherPool
- ExecutorRunalbe(NMClient)
- 启动Executor bin/java CoarseGrainedExecutorBackend
- 通信模块发出on Start信息
-
NM的通信模块向Driver注册Executor
-
注册成功
-
创建Executor计算对象
-
向Driver告知NM上的Executor启动成功
0.2 Spark通讯架构
0.3 Spark任务划分
- Application = SparkContext个数
- Job个数 = Action算子个数
- Job的Stage个数 = 宽依赖个数 + 1
- Job的Task个数 = 一个Stage阶段中,最后一个RDD的分区个数就是Task的个数
0.4 Task任务调度
0.5 Shuffle原理
1 环境准备和提交流程
1.1 程序起点
-
spark-3.0.0-bin-hadoop3.2\bin\spark-submit.cmd => cmd/V/E/C"“%-dp0spark-submit2.cmd”%*"
-
spark-submit2.cmd => set CLASS=org.apache.spark.deploy.SparkSubmit “%-dp0spark-class2.cmd”%CLASS%%*
-
spark-class2.cmd => %SPARK_CMD%
-
在spark-class2.cmd文件中增加打印%SPARK_CMD%语句
echo %SPARK_CMD%
%SPARK_CMD%
-
在spark-3.0.0-bin-hadoop3.2\bin目录上执行cmd命令
-
spark-submit --class org.apache.examples.SparkPi --master local[2] …/examples/jars/spark-examples_2.12-3.0.0.jar 10
-
发现底层执行的命令为
java -cp org.apache.spark.deploy.SparkSubmit
java -cp 和-classpath一样,是指定类运行依赖其他类的路径
-
执行java -cp就会开启JVM虚拟机,在虚拟机上开启SparkSubmit进程,然后开始执行main方法
java -cp => 开启JVM虚拟机 => 开启Process(SparkSubmit) => 程序入口 SparkSubmit.main
-
在IDEA中全局查找(ctrl + n)org.apache.spark.deploy.SparkSubmit,找到SparkSubmit的伴生对象,并找到main方法
override def main(args: Array[String]): Unit = {
val submit = new SparkSubmit() {
... ...
}
}
1.2 Spark组件通信
1.2.1 Spark中通信框架的发展
虽然Netty没有Akka协程级的性能优势,但是Netty内部高效的Reactor线程模型,无锁化的串行设计,高效的序列化,零拷贝,内存池等特性也保证了Netty不会存在性能问题。
1.2.2 三种通信方式
BIO 阻塞式IO
NIO 非阻塞式IO
AIO 异步非阻塞式IO
Spark底层采用Netty,Netty支持NIO和Epoll模式,默认采用NIO
linux对AIO支持的不够好,Windows支持AIO很好,Linux采用Epoll模仿AIO操作。
1.2.3 Spark底层通信原理
RpcEndpoint:RPC通信终端:Spark针对每个节点(Client/Master/Worker)都称之为一个RPC终端,且都实现RpcEndpoint接口,内部根据不同端点的需求,设计不同的消息和不同的业务处理,如果需要发送(询问)则调用Dispatcher。在Spark中,所有的终端都存在生命周期:
Constructor => onStart => receive => onStop
RpcEnv:RPC上下文环境:每个RPC终端运行时依赖的上下文环境称为RpcEnv;在当前Spark版本中使用的NettyRpcEnv
**Dispatcher:消息调度(分发)器:**针对于RPC终端需要发送远程消息或者从远程RPC接收到的消息,分发至对应的指令收件箱(发件箱)。如果指令接收方是自己则存入收件箱,如果指令接收方不是自己,则放入发件箱;
Inbox:指令消息收件箱:一个本地RpcEndpoint对应一个收件箱,Dispatcher在每次向Inbox存入消息时,都将对应EndpointData加入内部ReceiverQueue中,另外Dispatcher创建时会启动一个单独线程进行轮询ReceiverQueue,进行收件箱消息消费;
RpcEndpointRef:RpcEndpointRef是对远程RpcEndpoint的一个引用:当我们需要向一个具体的RpcEndpoint发送消息时,一般我们需要获取到该RpcEndpoint的引用,然后通过该应用发送消息。
OutBox:指令消息发件箱:对于当前RpcEndpoint来说,一个目标RpcEndpoint对应一个发件箱,如果向多个目标RpcEndpoint发送信息,则有多个OutBox。当消息放入Outbox后,紧接着通过TransportClient将消息发送出去。消息放入发件箱以及发送过程是在同一个线程中进行;
RpcAddress:表示远程的RpcEndpointRef的地址:Host + Port。
TransportClient:Netty通信客户端:一个OutBox对应一个TransportClient,TransportClient不断轮询OutBox,根据OutBox消息的receiver信息,请求对应的远程TransportServer;
TransportServer:Netty通信服务端:一个RpcEndpoint对应一个TransportServer,接受远程消息后调用Dispatcher分发消息至对应收发件箱;
2 Spark任务的执行
2.1 概述
2.1.1 任务切分和任务调度原理
2.1.2 本地化调度
任务分配原则:根据每个Task的优先位置,确定Task的Locality(本地化)级别,本地化一共有五种
移动数据不如移动计算
名称 | 解析 |
---|---|
PROCESS_LOCAL | 进程本地化,task和数据在同一个Executor中,性能最好。 |
NODE_LOCAL | 节点本地化,task和数据在同一个节点中,但是task和数据不在同一个Executor中,数据需要在进程间进行传输。 |
RACK_LOCAL | 机架本地化,task和数据在同一个机架的两个节点上,数据需要通过网络在节点之间进行传输。 |
NO_PREF | 对于task来说,从哪里获取都一样,没有好坏之分。 |
ANY | task和数据可以在集群的任何地方,而且不在一个机架中,性能最差。 |
2.1.3 失败重试与黑名单机制
除了选择合适的Task调度运行外,还需要监控Task的执行状态,前面也提到,与外部打交道的是SchedulerBackend,Task被提交到Executor启动执行后,Executor会将执行状态上报给SchedulerBackend,SchedulerBackend则告诉TaskScheduler,TaskScheduler找到该Task对应的TaskSetManager,并通知到该TaskSetManager,这样TaskSetManager就知道Task的失败与成功状态,对于失败的Task,会记录它失败的次数,如果失败次数还没有超过最大重试次数,那么就把它放回待调度的Task池子中,否则整个Application失败。
在记录Task失败次数过程中,会记录它上一次失败所在的Executor Id和Host,这样下次再调度这个Task时,会使用黑名单机制,避免它被调度到上一次失败的节点上,起到一定的容错作用。黑名单记录Task上一次失败所在的Executor Id和Host,以及其对应的“拉黑”时间,“拉黑”时间是指这段时间内不要再往这个节点上调度这个Task了。
3 Shuffle
Spark0.1 HashShuffle
Spark0.8.1 优化后的HashShuffle
Spark1.1 加入SortShuffle,默认是HashShuffle
Spark1.2 默认是SortShuffle,但是可配置HashShuffle
Spark2.0 删除HashShuffle只有SortShuffle
3.1 Shuffle的原理和执行过程
Shuffle一定会有落盘
如果shuffle过程中落盘数据量小,可以提高性能
算子如果存在预聚合功能,可以提高性能
3.2 HashShuffle解析
3.2.1 未优化的HashShuffle
3.2.2 优化后的HashShuffle
优化后的HashShuffle过程就是启动合并机制,合并机制就是复用Buffer,开启合并机制的配置是
spark.shuffle.consolidateFiles(默认为false)
将其设置为true即可开启优化机制。通常来讲,如果我们使用HashShuffleManager,那么都建议开启这个选项。
http://spark.apache.org/docs/0.8.1/configuration.html
3.3 SortShuffle解析
3.3.1 SortShuffle
在该模式下,数据会先写入一个数据结构,reduceByKey写入Map,一边通过Map局部聚合,一边写入内存。join算子写入ArrayList直接写入内存中,然后需要判断是否达到阈值,如果达到就会将内存数据结构的数据写入到磁盘,清空内存数据结构。
在溢写磁盘前,先根据key进行排序,排序后的数据,会被分批写入到磁盘中。默认批次为10000条,数据会以每批一万条写入到磁盘文件。写入磁盘文件通过缓冲区溢写的方式,每次溢写都会产生一个磁盘文件,也就是说一个Task过程会产生多个临时文件。
最后在每个Task中,将所有的临时文件合并,这就是merge过程,此过程将所有临时文件读取出来,一次写入到最终文件。意味着一个Task的所有数据都在这个文件中。同时单独写一份索引文件,标识下游各个Task的数据在文件中的索引,start offset 和 end offset。
3.3.2 bypassShuffle
bypassShuffle和sortShuffle的区别是不会对数据排序
bypass运行机制的除法条件如下:
- shuffle reduce task <= spark.shuffle.sort.bypassMergeThreshold 参数的值,默认为200
- 不是聚合类的shuffle算子(如reduceByKey)
4 Spark内存管理
4.1 堆内存和堆外内存
4.1.1 概念
Spark既支持堆内内存也支持堆外内存
堆内内存:程序在运行时动态地申请某个大小的内存空间
堆外内存:直接向操作系统进行申请的内存,不受JVM控制
4.1.2 堆内内存和堆外内存优缺点
堆外内存的优点:
- 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作
- 加快了赋值的速度,因为堆内存在Flush到远程时,会先序列化,然后再发送;而堆外内存本身时序列化的,发送时不需要序列化工序。
堆外内存的缺点:
- 堆外内存难以控制,如果内存泄漏,那么很难排查
- 堆外内存相对不适合存储很复杂的对象,只适合存储一般简单的对象或扁平化的对象
**堆外内存是序列化的,其占用的内存大小可以直接计算。堆内内存是非序列化的对象,其占用的内存是通过周期性的采样近视估算得到的,**即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能错误较大,导致某一时刻的实际内存有可能远远超出预期。此外,在被Spark标记为释放的对象实例,很有可能在实际上并没有被JVM回收,导致实际可用的内存小于Spark记录的可用内存。所以Spark并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出OOM的异常。
4.1.3 如何配置
# 堆内内存大小设置
--executor-memory
spark.executor.memory
# 默认情况下堆外内存并不启用
spark.memory.offHeap.enabled
# 设置堆外空间的大小
spark.memory.offHeap.size=
http://spark.apache.org/docs/3.0.0/configuration.html
4.2 堆内内存空间分配
堆内内存包括:存储(Storage)内存、执行(Execution)内存、其他内存
4.2.1 静态内存管理
在Spark最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在Spark应用程序运行期间均为固定的,但用户可以在启动应用程序前进行配置,堆内内存的分配如下:
可以看到,可用的堆内内存的大小需要按照下列方式计算
可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safety Fraction
可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safety Fraction
其中systemMaxMemoryq取决于当前JVM堆内存的大小,最后可用的执行内存或者存储内存要在此基础上于各自的memoryFraction 参数和safetyFraction 参数相乘得出。上述计算公式中的两个 safetyFraction 参数,其意义在于在逻辑上预留出 1-safetyFraction 这么一块保险区域,降低因实际内存超出当前预设范围而导致 OOM 的风险(上文提到,对于非序列化对象的内存采样估算会产生误差)。值得注意的是,这个预留的保险区域仅仅是一种逻辑上的规划,在具体使用时 Spark 并没有区别对待,和”其它内存”一样交给了 JVM 去管理。
Storage内存和Execution内存都有预留空间,目的是防止OOM,因为Spark堆内内存大小的记录是不准确的,需要留出保险区域。
堆外的空间分配较为简单,只有存储内存和执行内存,如下图所示。可用的执行内存和存储内存占用的空间大小直接由参数spark.memory.storageFraction 决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域。
静态内存管理机制实现起来较为简单,但如果用户不熟悉Spark的存储机制,或没有根据具体的数据规模和计算任务或做相应的配置,很容易造成”一半海水,一半火焰”的局面,即存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容。由于新的内存管理机制的出现,这种方式目前已经很少有开发者使用,出于兼容旧版本的应用程序的目的,Spark 仍然保留了它的实现。
4.2.2 统一内存管理
Spark 1.6 以后,引入统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享一块空间,可以动态占用对方的空闲区域,统一内存管理的堆内内存结构如图所示:
统一内存管理的堆外内存结构如下所示:
动态占用机制:
- 设定基本的存储内存和执行内存区域(spark.storageFraction参数),该设定确定了双方各自拥有的空间范围
- 双方的空间都不足时,则存储到硬盘,若己方空间不足而对方空余时,可借用对方的空间(存储空间不足是指不足以放下一个完整的Block)
- 执行内存的空间被对方占用后,可让对方将占用的部分转移到磁盘,然后“归还”借用的空间
- 存储内存的空间被对方占用后,无法让对方“归还”,因为需要考虑Shuffle过程用的很多因素,实现起来较为复杂
4.3 存储内存管理
4.3.1 RDD的持久化机制
RDD作为Spark 最根本的数据抽象,是只读的分区记录(Partition)的集合,只能基于在稳定物理存储中的数据集上创建,或者在其他已有的RDD上执行转换(Transformation)操作产生一个新的RDD。转换后的RDD与原始的RDD之间产生的依赖关系,构成了血统(Lineage)。凭借血统,Spark 保证了每一个RDD都可以被重新恢复。但RDD的所有转换都是惰性的,即只有当一个返回结果给Driver的行动(Action)发生时,Spark才会创建任务读取RDD,然后真正触发转换的执行。
Task在启动之初读取一个分区时,会先判断这个分区是否已经被持久化,如果没有则需要检查Checkpoint 或按照血统重新计算。所以如果一个 RDD 上要执行多次行动,可以在第一次行动中使用 persist或cache 方法,在内存或磁盘中持久化或缓存这个RDD,从而在后面的行动时提升计算速度。
事实上,cache 方法是使用默认的 MEMORY_ONLY 的存储级别将 RDD 持久化到内存,故缓存是一种特殊的持久化。 堆内和堆外存储内存的设计,便可以对缓存RDD时使用的内存做统一的规划和管理。
RDD的持久化由 Spark的Storage模块负责,实现了RDD与物理存储的解耦合。Storage模块负责管理Spark在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时Driver端和 Executor 端的Storage模块构成了主从式的架构,即Driver端的BlockManager为Master,Executor端的BlockManager 为 Slave。
Storage模块在逻辑上以Block为基本存储单位,RDD的每个Partition经过处理后唯一对应一个 Block(BlockId 的格式为rdd_RDD-ID_PARTITION-ID )。Driver端的Master负责整个Spark应用程序的Block的元数据信息的管理和维护,而Executor端的Slave需要将Block的更新等状态上报到Master,同时接收Master 的命令,例如新增或删除一个RDD。
在对RDD持久化时,Spark规定了MEMORY_ONLY、MEMORY_AND_DISK 等7种不同的存储级别,而存储级别是以下5个变量的组合:
class StorageLevel private(
private var _useDisk: Boolean, //磁盘
private var _useMemory: Boolean, //这里其实是指堆内内存
private var _useOffHeap: Boolean, //堆外内存
private var _deserialized: Boolean, //是否为非序列化
private var _replication: Int = 1 //副本个数
)
Spark中7种存储级别如下:
持久化级别 | 含义 |
---|---|
MEMORY_ONLY | 以非序列化的Java对象的方式持久化在JVM内存中。如果内存无法完全存储RDD所有的partition,那么那些没有持久化的partition就会在下一次需要使用它们的时候,重新被计算 |
MEMORY_AND_DISK | 同上,但是当某些partition无法存储在内存中时,会持久化到磁盘中。下次需要使用这些partition时,需要从磁盘上读取 |
MEMORY_ONLY_SER | 同MEMORY_ONLY,但是会使用Java序列化方式,将Java对象序列化后进行持久化。可以减少内存开销,但是需要进行反序列化,因此会加大CPU开销 |
MEMORY_AND_DISK_SER | 同MEMORY_AND_DISK,但是使用序列化方式持久化Java对象 |
DISK_ONLY | 使用非序列化Java对象的方式持久化,完全存储到磁盘上 |
MEMORY_ONLY_2 MEMORY_AND_DISK_2 等等 | 如果是尾部加了2的持久化级别,表示将持久化数据复用一份,保存到其他节点,从而在数据丢失时,不需要再次计算,只需要使用备份数据即可 |
通过对数据结构的分析,可以看出存储级别从三个维度定义了RDD的 Partition(同时也就是Block)的存储方式:
- 存储位置:磁盘/堆内内存/堆外内存。如MEMORY_AND_DISK是同时在磁盘和堆内内存上存储,实现了冗余备份。OFF_HEAP 则是只在堆外内存存储,目前选择堆外内存时不能同时存储到其他位置。
- 存储形式:Block 缓存到存储内存后,是否为非序列化的形式。如 MEMORY_ONLY是非序列化方式存储,OFF_HEAP 是序列化方式存储。
- 副本数量:大于1时需要远程冗余备份到其他节点。如DISK_ONLY_2需要远程备份1个副本。
RDD缓存过程:
RDD 在缓存到存储内存之前,Partition中的数据一般以迭代器(Iterator)的数据结构来访问,这是Scala语言中一种遍历数据集合的方法。通过Iterator可以获取分区中每一条序列化或者非序列化的数据项(Record),这些Record的对象实例在逻辑上占用了JVM堆内内存的other部分的空间,同一Partition的不同 Record 的存储空间并不连续。
RDD 在缓存到存储内存之后,Partition 被转换成Block,Record在堆内或堆外存储内存中占用一块连续的空间。将Partition由不连续的存储空间转换为连续存储空间的过程,Spark称之为"展开"(Unroll)。
Block 有序列化和非序列化两种存储格式,具体以哪种方式取决于该 RDD 的存储级别。非序列化的Block以一种 DeserializedMemoryEntry 的数据结构定义,用一个数组存储所有的对象实例,序列化的Block则以SerializedMemoryEntry的数据结构定义,用字节缓冲区(ByteBuffer)来存储二进制数据。每个 Executor 的 Storage模块用一个链式Map结构(LinkedHashMap)来管理堆内和堆外存储内存中所有的Block对象的实例,对这个LinkedHashMap新增和删除间接记录了内存的申请和释放。
因为不能保证存储空间可以一次容纳 Iterator 中的所有数据,当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的Unroll空间来临时占位,空间不足则Unroll失败,空间足够时可以继续进行。
对于序列化的Partition,其所需的Unroll空间可以直接累加计算,一次申请。
对于非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,采样估算其所需的Unroll空间并进行申请,空间不足时可以中断,释放已占用的Unroll空间。
如果最终Unroll成功,当前Partition所占用的Unroll空间被转换为正常的缓存 RDD的存储空间,如下图所示。
在静态内存管理时,Spark 在存储内存中专门划分了一块 Unroll 空间,其大小是固定的,统一内存管理时则没有对 Unroll 空间进行特别区分,当存储空间不足时会根据动态占用机制进行处理。
4.3.2 淘汰与落盘
由于同一个Executor的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对LinkedHashMap中的旧Block进行淘汰(Eviction),而被淘汰的Block如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该Block。
存储内存的淘汰规则为:
- 被淘汰的旧Block要与新Block的MemoryMode相同,即同属于堆外或堆内内存
- 新旧Block不能属于同一个RDD,避免循环淘汰
- 旧Block所属RDD不能处于被读状态,避免引发一致性问题
- 遍历LinkedHashMap中Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新Block所需的空间。其中LRU是LinkedHashMap的特性
落盘的流程则比较简单,如果其存储级别符合_useDisk为true的条件,再根据其_deserialized判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在Storage模块中更新其信息。
4.4 执行内存管理
执行内存主要用来存储任务在执行Shuffle时占用的内存,Shuffle是按照一定规则对RDD数据重新分区的过程,我们来看Shuffle的Write和Read两阶段对执行内存的使用:
(1)Shuffle Write
若在map端选择普通的排序方式,会采用ExternalSorter进行外排,在内存中存储数据时主要占用堆内执行空间。
若在map端选择 Tungsten 的排序方式,则采用ShuffleExternalSorter直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够。
(2)Shuffle Read
在对reduce端的数据进行聚合时,要将数据交给Aggregator处理,在内存中存储数据时占用堆内执行空间。
如果需要进行最终结果排序,则要将再次将数据交给ExternalSorter 处理,占用堆内执行空间。
在ExternalSorter和Aggregator中,Spark会使用一种叫AppendOnlyMap的哈希表在堆内执行内存中存储数据,但在 Shuffle 过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用的内存会进行周期性地采样估算,当其大到一定程度,无法再从MemoryManager 申请到新的执行内存时,Spark就会将其全部内容存储到磁盘文件中,这个过程被称为溢存(Spill),溢存到磁盘的文件最后会被归并(Merge)。
Shuffle Write 阶段中用到的Tungsten是Databricks公司提出的对Spark优化内存和CPU使用的计划(钨丝计划),解决了一些JVM在性能上的限制和弊端。Spark会根据Shuffle的情况来自动选择是否采用Tungsten排序。
Tungsten 采用的页式内存管理机制建立在MemoryManager之上,即 Tungsten 对执行内存的使用进行了一步的抽象,这样在 Shuffle 过程中无需关心数据具体存储在堆内还是堆外。
每个内存页用一个MemoryBlock来定义,并用 Object obj 和 long offset 这两个变量统一标识一个内存页在系统内存中的地址。
堆内的MemoryBlock是以long型数组的形式分配的内存,其obj的值为是这个数组的对象引用,offset是long型数组的在JVM中的初始偏移地址,两者配合使用可以定位这个数组在堆内的绝对地址;堆外的 MemoryBlock是直接申请到的内存块,其obj为null,offset是这个内存块在系统内存中的64位绝对地址。Spark用MemoryBlock巧妙地将堆内和堆外内存页统一抽象封装,并用页表(pageTable)管理每个Task申请到的内存页。
Tungsten 页式管理下的所有内存用64位的逻辑地址表示,由页号和页内偏移量组成:
页号:占13位,唯一标识一个内存页,Spark在申请内存页之前要先申请空闲页号。
页内偏移量:占51位,是在使用内存页存储数据时,数据在页内的偏移地址。
有了统一的寻址方式,Spark 可以用64位逻辑地址的指针定位到堆内或堆外的内存,整个Shuffle Write排序的过程只需要对指针进行排序,并且无需反序列化,整个过程非常高效,对于内存访问效率和CPU使用效率带来了明显的提升。
Spark的存储内存和执行内存有着截然不同的管理方式:对于存储内存来说,Spark用一个LinkedHashMap来集中管理所有的Block,Block由需要缓存的 RDD的Partition转化而成;而对于执行内存,Spark用AppendOnlyMap来存储 Shuffle过程中的数据,在Tungsten排序中甚至抽象成为页式内存管理,开辟了全新的JVM内存管理机制。
5 SparkSQL优化介绍
Spark 3.0大版本发布,Spark SQL的优化占比将近50%。Spark SQL 取代Spark Core,成为新一代的引擎内核,所有其他子框架如Mllib、Streaming和 Graph,都可以共享Spark SQL 的性能优化,都能从Spark社区对于Spark SQL的投入中受益。
5.1 执行计划
SparkSQL具有和Hive语法类型的执行计划查询。
从3.0开始,explain方法有一个新的参数mode,该参数可以指定执行计划展示格式:
.expliain(mode="xxx")
explain(mode="simple"):只展示物理执行计划
explain(mode="extended"):展示物理执行计划和逻辑执行计划
explain(mode="codegen") :展示要Codegen生成的可执行Java代码
explain(mode="cost"):展示优化后的逻辑执行计划以及相关的统计
explain(mode="formatted"):以分隔的方式输出,它会输出更易读的物理执行计划,并展示每个节点的详细信息
5.2 执行计划处理流程
核心的执行过程一共有5个步骤:
这些操作和计划都是Spark SQL自动处理的,会生成以下计划:
-
Unresolved 逻辑执行计划:== Parsed Logical Plan ==
Parser组件检查SQL语法上是否有问题,然后生成Unresolved(未决断)的逻辑计划,不检查表名、不检查列名。
-
Resolved 逻辑执行计划:== Analyzed Logical Plan ==
通过访问Spark中的Catalog存储库来解析验证语义、列名、类型、表名等。
-
优化后的 逻辑执行计划:== Optimized Logical Plan ==
Catalyst优化器根据各种规则进行优化。
-
物理执行计划:== Physical Plan ==
- HashAggregate运算符表示数据聚合,一般HashAggregate是成对出现,第一个HashAggregate是将执行节点本地的数据进行局部聚合,另一个HashAggregate是将各个分区的数据进一步进行聚合计算。
- Exchange运算符其实就是shuffle,表示需要在集群上移动数据。很多时候HashAggregate会以Exchange分隔开来。
- Project运算符是SQL中的投影操作,就是选择列(例如:select name, age…)。
- BroadcastHashJoin运算符表示通过基于广播方式进行HashJoin。
- LocalTableScan运算符就是全表扫描本地的表。
5.3 语法优化
SparkSQL在整个执行计划处理的过程中,使用了Catalyst优化器。
在Spark 3.0 版本中,Catalyst 总共有 81 条优化规则(Rules),分成 27 组(Batches),其中有些规则会被归类到多个分组里。因此,如果不考虑规则的重复性,27 组算下来总共会有129个优化规则。
如果从优化效果的角度出发,这些规则可以归纳到以下3个范畴:
5.3.1 谓词下推(Preducate Pushdown)
将过滤条件的谓词逻辑都尽可能提前执行,减少下游处理的数据量。对应PushDownPredicte优化规则,对于Parquet、ORC这类存储格式,结合文件注脚(Footer)中的统计信息,下推的谓词能够大幅减少数据扫描量,降低磁盘I/O开销。
左外关联下推规则:左表left join 右表。
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 4 --executor-memory 6g --class com.atguigu.sparktuning.rbo.PredicateTuning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
左表 | 右表 | |
---|---|---|
Join中条件(on) | 只下推右表 | 只下推右表 |
Join后条件(where) | 两表都下推 | 两表都下推 |
外关联时,过滤条件写在on和where结果是不一样的
5.3.2 列裁剪(Column Pruning)
列裁剪就是扫描数据源的时候,只读取那些与查询相关的字段
5.3.3 常量替换(Constant Folding)
假设我们在年龄上加的过滤条件是 “age < 12 + 18”,Catalyst 会使用 ConstantFolding 规则,自动帮我们把条件变成 “age < 30”。再比如,我们在 select 语句中,掺杂了一些常量表达式,Catalyst 也会自动地用表达式的结果进行替换。
5.4 基于CBO的优化
CBO优化主要在物理计划层面,原理是计算所有可能的物理计划的代价,并挑选出代价最小的物理执行计划。充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划。
每个执行节点的代价,分为两个部分:
- 该执行节点对数据集的影响,即该节点输出数据集的大小与分布
- 该执行节点操作算子的代价
每个操作算子的代价相对固定,可用规则来描述。而执行节点输出数据集的大小与分布,分为两个部分:
- 初始数据集,也即原始表,其数据集的大小与分布可直接通过统计得到
- 中间节点输出数据集的大小与分布可由其输入数据集的信息与操作本身的特点推算
5.5 Join策略
Spark提供了5种JOIN机制来执行具体的JOIN操作。选择的方式如下图:
5.5.1 影响Join的因素
5.5.1.1 数据集的大小
参与join的数据集的大小会直接影响Join操作的执行效率。同样,也会影响JOIN机制的选择和join的执行效率。
5.5.1.2 Join的条件
JOIN的条件会涉及字段之间的逻辑比较。根据JOIN的条件,JOIN可分为两大类:等值连接和非等值连接。等值连接会涉及一个或多个需要同时满足的相等条件。在两个输入数据集的属性之间应用每个等值条件。当使用其他运算符(运算连接符不为**=**)时,称之为非等值连接。
5.5.1.3 Join的类型
在输入数据集的记录之间应用连接条件之后,JOIN类型会影响JOIN操作的结果。主要有以下几种JOIN类型:
内连接(Inner Join):仅从输入数据集中输出匹配连接条件的记录。
外连接(Outer Join):又分为左外连接、右外链接和全外连接。
半连接(Semi Join):右表只用于过滤左表的数据而不出现在结果集中。
交叉连接(Cross Join):交叉联接返回左表中的所有行,左表中的每一行与右表中的所有行组合。交叉联接也称作笛卡尔积。
5.5.2 Shuffle Hash Join
当执行数据量较大的等值连接时,使用Shuffle Hash Join,操作流程为将两个表需要join的Key,进行hash重分区走shuffle,写入磁盘之后,将相同分区编号的数据读取合并。
条件与合并:
- 仅支持等值合并,join key不需要排序
- 支持除了全外连接之外的所有join类型
- 需要对小表构建Hash map,属于内存密集型操作,如果构建Hash表的一侧数据比较大,可能会造成OOM
- 将参数设置为 spark.sql.join.prefersortmergeJoin=false(default true)
5.5.3 Broadcast Hash Join
广播Join,也称为Map端Join,该操作可以避免走shuffle,是一种非常高效的Join操作。当一张表格比较小的时候,通常使用Broadcast Hash Join。具体的操作为先将小表发送到Driver端,之后使用广播变量发送到每个Executor端。
# 1 通过参数指定自动广播
spakr.sql.autoBroadcastJoinThreshold 默认值为10M
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 4g --class com.atguigu.sparktuning.join.AutoBroadcastJoinTuning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
# 2 强行广播
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 4g --class com.atguigu.sparktuning.join.ForceBroadcastJoinTuning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
条件与特点:
- 仅支持等值连接,join key不需要排序
- 支持除了全外连接之外的所有join类型
- Broadcast Hash Join相比其他的Join机制而言,效率更高。但是Broadcast Hash Join属于网络密集型的操作(数据冗余传输),除此之外,需要在Driver端缓存数据,所以当小表的数据量较大时,会出现OOM的情况
- 被广播的小表的数据量要小于spark.sql.autoBroadcastJionThreshold值,默认是10MB(10485760)
- 基表不能被Broadcast,比如左连接时,只能将右表进行广播(fact_table.join(broadcast(dimension_table),可以不使用Broadcast提示,当满足条件时会自动转为该Join方式。
5.5.4 Sort Merge Join
该JOIN机制是Spark默认的,可以通过参数spark.sql.join.preferSortMergeJoin进行配置,默认是true,即优先使用Sort Merge Join。一般在两张大表进行JOIN时,使用该方式。
条件与特点:
- 仅支持等值连接
- 支持所有join类型
- Join Keys是排序的
- 参数spark.sql.join.perfersortmergeJoin=true(deault true)
桶join优化:需要进行分桶,首先会进行排序,然后根据key值合并,把相同key的数据放到同一个bucket中(按照key进行hash)。分桶的目的其实就是把大表化成小表。相同key的数据都在同一个桶中之后,再进行join操作,那么在联合的时候就会大幅度的减小无关项的扫描。
使用条件:
- 两表进行分桶,桶的个数
- 两边进行join时,join列 = 排序列 = 分桶列
# 不使用SMB join BigJoinDemo
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 6g --class com.atguigu.sparktuning.join.BigJoinDemo spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
# 使用SMB Join SMBJoinTuning
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 6g --class com.atguigu.sparktuning.join.SMBJoinTuning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
5.5.5 Cartesian Join
非等值连接时会使用Cartesian Join。顾名思义,这种Join的结果就是得到两张表的笛卡尔积。
条件与特点:
- 仅支持内连接
- 支持等值和不等值连接
- 开启参数spark.sql.crossJoin.enabled=true
5.5.6 Broadcast Nested Loop Join
无法使用别的Join策略时的最后选择,一般在非等值连接,同时还不是内连接的时候使用。应该避免使用这种Join策略。
条件与特点:
- 支持等值和非等值连接
- 支持所有的Join类型,主要优化点如下:
- 当右外连接时要广播左表
- 当左外连接时要广播右表
- 当内连接时,要广播左右两张表
5.6 AQE自适应优化
Spark 在 3.0 版本推出了 AQE(Adaptive Query Execution),即自适应查询执行。AQE 是 Spark SQL 的一种动态优化机制,在运行时,每当 Shuffle Map 阶段执行完毕,AQE 都会结合这个阶段的统计信息,基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计划,来完成对原始查询语句的运行时优化。
5.6.1 动态分区合并
在Spark中运行查询处理非常大的数据时,shuffle通常会对查询性能产生非常重要的影响。shuffle是非常昂贵的操作,因为它需要进行网络传输移动数据,以便下游进行计算。
最好的分区取决于数据,但是每个查询的阶段之间的数据大小可能相差很大,这使得该数字难以调整:
- 如果分区太少,则每个分区的数据量可能会很大,处理这些数据量非常大的分区,可能需要将数据溢写到磁盘(例如,排序和聚合),降低了查询。
- 如果分区太多,则每个分区的数据量大小可能很小,读取大量小的网络数据块,这也会导致I/O效率低而降低了查询速度。拥有大量的task(一个分区一个task)也会给Spark任务计划程序带来更多负担。
为了解决这个问题,我们可以在任务开始时先设置较多的shuffle分区个数,然后在运行时通过查看shuffle文件统计信息将相邻的小分区合并成更大的分区。
例如,假设正在运行select max(i) from tbl group by j。输入tbl很小,在分组前只有2个分区。那么任务刚初始化时,我们将分区数设置为5,如果没有AQE,Spark将启动五个任务来进行最终聚合,但是其中会有三个非常小的分区,为每个分区启动单独的任务这样就很浪费。
取而代之的是,AQE将这三个小分区合并为一个,因此最终聚只需三个task而不是五个
# AQE自适应优化
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 2g --class com.atguigu.sparktuning.aqe.AQEPartitionTunning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
# 结合动态申请资源
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 2 --executor-memory 2g --class com.atguigu.sparktuning.aqe.DynamicAllocationTunning spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
5.6.2 动态切换Join策略
Spark支持多种join策略,其中如果join的一张表可以很好的插入内存,那么broadcast hash join通常性能最高。因此,spark join中,如果小表小于广播大小阀值(默认10MB),Spark将计划进行broadcast hash join。但是,很多事情都会使这种大小估计出错(例如,存在选择性很高的过滤器),或者join关系是一系列的运算符而不是简单的扫描表操作。
为了解决此问题,AQE现在根据最准确的join大小运行时重新计划join策略。从下图实例中可以看出,发现连接的右侧表比左侧表小的多,并且足够小可以进行广播,那么AQE会重新优化,将sort merge join转换成为broadcast hash join。
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 4 --executor-memory 2g --class com.atguigu.sparktuning.aqe.AqeDynamicSwitchJoin spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
5.6.3 动态优化Join倾斜
当数据在群集中的分区之间分布不均匀时,就会发生数据倾斜。严重的倾斜会大大降低查询性能,尤其对于join。AQE skew join优化会从随机shuffle文件统计信息自动检测到这种倾斜。然后它将倾斜分区拆分成较小的子分区。
例如,下图 A join B,A表中分区A0明细大于其他分区。
因此,skew join 会将A0分区拆分成两个子分区,并且对应连接B0分区。
没有这种优化,会导致其中一个分区特别耗时拖慢整个stage,有了这个优化之后每个task耗时都会大致相同,从而总体上获得更好的性能。
- spark.sql.adaptive.skewJoin.enabled 是否开启倾斜join检测,如果开启了,那么会将倾斜的分区数据拆成多个分区,默认是开启的,但是得打开aqe
- spark.sql.adaptive.skewJoin.skewedPartitionFactor 默认值5,此参数用来判断分区数据量是否数据倾斜,当任务中最大数据量分区对应的数据量大于的分区中位数乘以此参数,并且也大于spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes参数,那么此任务是数据倾斜
- spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes 默认值256MB,用于判断是否数据倾斜
- spark.sql.adaptive.advisoryPartitionSizeBytes 此参数用来告诉spark进行拆分后推荐分区大小是多少
# AqeOptimizingSkewJoin
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 4 --executor-memory 2g --class com.atguigu.sparktuning.aqe.AqeOptimizingSkewJoin spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
# 如果同时开启了spark.sql.adaptive.coalescePartitions.enabled动态合并分区功能,那么会先合并分区,再去判断倾斜,将动态合并分区打开后,重新执行:
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 4 --executor-memory 2g --class com.atguigu.sparktuning.aqe.AqeOptimizingSkewJoin spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar
# 修改中位数的倍数为2,重新执行:
spark-submit --master yarn --deploy-mode client --driver-memory 1g --num-executors 3 --executor-cores 4 --executor-memory 2g --class com.atguigu.sparktuning.aqe.AqeOptimizingSkewJoin spark-tuning-1.0-SNAPSHOT-jar-with-dependencies.jar