前言
继Tensorflow笔记系列之后,我准备写一篇Spark笔记系列。本文是系列的第一篇《原理篇》,看完本文你能收获:1.啥是Spark?2.SparkCore是怎么运作的?3.SparkSQL为什么这么快?废话少说,进入正文。(本文持续写作中,大家想看什么内容可评论区留言)
一、基础
引用官网的介绍:
Apache Spark™is a unified analytics engine for large-scale data processing.
Spark就是个专门做大数据计算的东西。
当数据量很大的时候,一台机器放不下,就需要放到一个集群上,每台机器都存一部分,这个技术叫“分布式存储”。HDFS、HBase、Cassandra等产品解决了这个问题。而对于分布式存储的数据如何计算,也不是一台机器就能处理的,需要一个分布式的计算框架。MapReduce和Spark等产品就是为了解决这个问题。
Spark只是分布式计算框架,是建立在分布式存储框架(比如HDFS)之上的。其核心是SparkCore部分,也就是对RDD(Resilient Distributed Dataset 即弹性分布式数据集)的操作。因为其易用性不强,需要手动调优代码,写出优秀的Spark代码是有门槛的。所以在SparkCore的基础上出现了SparkSQL,他不仅方便易用,而且在很多地方进行了优化,就算我们代码写的烂,SparkSQL也会帮我们优化。所以SparkSQL很快普及开,并在他的基础上又出现了SQL、Streaming、MLlib等封装。
Spark作为分布式计算框架,是典型的主从式结构。一台Driver机负责任务调度、监控任务运行状态等工作;其他机器均为Executor机,只负责执行Driver分发给他的Task任务,以及上报任务执行状态。
二、SparkCore是如何运作的
2.1 RDD
作为SparkCore的核心,我们先来介绍一下什么是RDD。RDD简单来说就是把一个大的数据文件(比如10000行),分成很多个小的数据文件(比如10个文件,每个1000行),保存在集群中的多台服务器上。我们先来看一个HDFS上的RDD的样例:
|--datapath
| |--_SUCCESS
| |--part-00000
| |--part-00001
| |--part-00002
| |--part-00003
在HDFS路径datapath下有一个名为"_SUCCESS"的文件,这个文件不保存任何数据,它的存在仅代表这个HDFS路径下的数据在写入HDFS过程是成功了的。其他的名为“part-xxxxx”的文件就是名副其实的数据文件了,他们的内容其实和普通的本地文件一样,都是一行一行的文本文件,可以通过类似`hdfs dfs -car xxxxx | head -n 5`的方式来显示特定文件的5行内容。我们来看一个RDD内容的例子:
1,0,3,"Braund, Mr. Owen Harris",male,22,1,0,A/5 21171,7.25,,S
2,1,1,"Cumings, Mrs. John Bradley (Florence Briggs Thayer)",female,38,1,0,PC 17599,71.2833,C85,C
3,1,3,"Heikkinen, Miss. Laina",female,26,0,0,STON/O2. 3101282,7.925,,S
4,1,1,"Futrelle, Mrs. Jacques Heath (Lily May Peel)",female,35,1,0,113803,53.1,C123,S
5,0,3,"Allen, Mr. William Henry",male,35,0,0,373450,8.05,,S
这是一个Titanic数据的CSV文件,每一行表示一个乘客,列之间用逗号隔开。可以看到就和我们打开本地文件一样,RDD文件并没有经过特殊的编码。
需要强调一点,每一个part-xxxxx都是一个partition。虽然在上面的HDFS路径下,这些partition的文件都在datapath路径下,但是他们实际上是存放在集群中不同的机器上的。这个叫datapath的HDFS路径其实是逻辑上的虚拟路径,不是真实的物理路径。
另外与本地文件不同,当保存数据到本地文件时,需要保存到指定路径下的指定文件内;但对于RDD来说,只需要指定路径即可,Spark会在制定路径下自动生成part-xxxxx文件,并在写入成功后创建“_SUCCESS”文件。
2.2 惰性计算
我们了解了RDD长什么样,下面我们来看看RDD数据如何进行计算,RDD的计算不得不提到惰性计算和两种算子——Transformation和Action。
首先一个前提是Spark中RDD是不可变的,所以进行计算时只能根据一个RDD进行计算操作得到一个新的RDD,比如下面这个操作将每个元素+1
scala> val rdd1 = sc.parallelize(Array(1,2,3)) // 根据 Array(1,2,3) 生成一个RDD
scala> val rdd2 = rdd1.map(x => x+1) // 每个元素 +1
在这个例子中,不可变性体现在我们不能直接通过一句`rdd1 + 1`来实现+1的目的,只能通过一个map操作,将rdd1处理后赋值给一个新的rdd2。
而所谓的惰性计算就是只你在代码中告诉我`rdd2 = rdd1.map(x => x+1)`后,我不会直接开始计算,而是记住了rdd2和rdd1之间的依赖关系,比如我们来看一下rdd2现在长什么样子:
scala> rdd2
res1: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[1] at map at <console>:25
结果并没有输出我们想要的Array(2,3,4),而只给出了rdd2的类型`org.apache.spark.rdd.RDD[Int]`,因为此时此刻计算机还没有真正去计算rdd2的内容。那什么时候计算呢?就是在你高速计算机:“我要看结果啦!”的时候,计算机知道你现在要去看哪一个RDD的内容,他才回过头去从头开始计算这一个RDD的数值。比如下面这样:
scala> rdd2.collect()
res2: Array[Int] = Array(2, 3, 4)
终于看到了我们想要的rdd2的结果。这种“你不管我要结果,我就懒得给你算”的计算方式,就是惰性计算。其中类似前面map()这种,不需要立刻计算结果的操作就是“Transformation操作”,而类似collect()这种需要输出结果的操作就是“Action操作”。
说了这么多,为什么Spark要采用惰性计算的机制呢?第一,采用惰性计算可以忽略那些“你写了,但是没用到”的计算逻辑,有加速的作用。第二,不需要每一个中间结果都保存在内存或磁盘中,节省了空间。
常用的Transformation算子包括:
- filter(func: T => U)
- map(func: T => Bool)
- flatMap(func: T => Seq[U])
- sample(fraction: Float)
- groupByKey()
- reduceByKey(func: (V, V) => V)
- union()
- join()
- cogroup()
- crossProduct()
- mapValues(func: V => W)
- sort(c: Comparator[K])
- sortByKey(desc: Bool)
- sortBy(k: K, desc: Bool)
- partitionBy(p: Partitioner[K])
常用的Action算子包括:
- count()
- collect()
- reduce(func: (T, T) => T)
- lookup(k: K)
- save(path: String)
只需要了解上述的18个算子已经能够涵盖工作中绝大多数需求了。按理说这里我应该详细介绍一下每个算子,但是这部分内容网络上已经很多很详细,本着不重复造轮子的原则,这部分内容不作为本文的重点,本文只贴出传送门。
2.3 Shuffle
前面提到的Transformation操作中根据输入输出RDD的依赖关系,也可以继续细分为“宽依赖”和“窄依赖”。
窄依赖(左)及宽依赖(右)
- 窄依赖:就是指输入RDD的每一个partition,指对应输出RDD的一个partition。
- 宽依赖:就是指输入RDD的每一个partition,可能对应输出RDD的多个partition。
为什么要这样分呢?因为在窄依赖中,机器处理每一个partition的Task只需要处理这一个partition的数据,不需要考虑其他partition的数据。不存在Tasks之间的数据交换,可以更充分的并行。反之宽依赖中,每一个Task之间都存在数据交换,很大程度上限制了程序的性能。(实践经验表明,绝大多数的性能其实都是消耗在shuffle操作上)。
常用窄依赖:
- filter(func: T => U)
- map(func: T => Bool)
- flatMap(func: T => Seq[U])
- sample(fraction: Float)
- union()
- mapValues(func: V => W)
常用宽依赖:
- groupByKey()
- reduceByKey(func: (V, V) => V)
- join()
- cogroup()
- crossProduct()
- sort(c: Comparator[K])
- sortByKey(desc: Bool)
- sortBy(k: K, desc: Bool)
- partitionBy(p: Partitioner[K])
如果仅仅是列举那些算子需要Shuffle的话,这一环节我就没必要写了。下面上点干货,我们来看看Shuffle是如何实现的。先来看看Spark的早期版本(1.1.0以前)如何做的:
一个Shuffle操作分两个阶段:首先Shuffle write阶段把每个partition中的数据进行分桶(比如groupByKey操作就按Key分桶),然后把每个桶的文件写入磁盘(即上图中灰色partition分桶成Bucket)。然后把相同Key的Bucket的数据汇总成一个partition,接下来就可以在partition内部进行计算了。这样做有一个缺陷,就是过程中会产生大量的中间文件(num-partition*num-keys)。接下来Spark进行了一点改进:
其实就是把一个Executor上不同core所执行的Task所产生的Bucket,都写到同一个文件里。这样确实减少了文件数量,但是依然治标不治本,文件数量还是很多。而在1.1.0版本之后,Spark做了比较大的改动:
新的方式采用排序的方法,把一个partition中不同key的数据排序,并写道一个文件内(替代分别写到不同的文件中)。这样在Shuffle read的过程中,只需要根据不同索引,找到不同key在Bucket文件中的位置即可。这样就每个partition只需要输出一个中间文件。但是这样做需要引入排序操作,相比前面的HashShuffle来说更加耗时。
2.4 任务调度
下面就是本章节的重点,一段代码是如何转化为一系列的任务,并分发到每台机器上的每个CPU的呢?这里需要先介绍三个概念:Job、Stage、Task(这三句话要仔细读):
- Job:每一个Action操作,以及其向前依赖的所有Transformation操作所构成的计算任务
- Stage:一个Job内,每一个Shuffle操作及其向前的窄依赖操作所构成的计算任务
- Task:一个Stage内,每一个partition上需要进行的计算任务
上图中展示了一个Job的工作内容,其中最右边的Save操作是一个Action操作,将RDD数据写入到磁盘中。那么Save操作,以及其向前依赖的全部操作(这张图里的全部操作)就构成了一个Job。
三个红色虚线圈起来的部分就是三个Stage,把整条依赖链中的Shuffle操作砍断,剩下的几个部分就是几个Stage,这个将一个Job划分为多个Stage的过程由DAGSchduler来负责。
一个RDD内部的圆圈表示partition,对于每个partition(就是前面提到的一个part-xxxxx文件)进行的计算就是一个Task。Spark中有一个叫TaskSchduler的调度器来不断的根据当前的可用资源去取Task执行,队列可以按照先提交先执行(FIFO)或按权重执行(Priority)。
对于资源的配置:RDD的分区数决定Task的任务数,同一时间单个Executor的一个core(即一个cpu核)运行一个Task。虽然单个Executor的多个core分别运行不同的Task,但是他们共享内存,所以在设置num-executor和executor-cores的时候,不要过于极端。注意避免如下现象:
- executor-cores很小,但num-executor很大。每个executor都是独立的JVM,这样会造成网络IO成本太高。
- executor-cores很大,但num-executor很小。单个executor的cores共享内存,这样会造成executor的内存竞争。
- 分区过多。任务过细,增加Driver的维护成本。(建议单个core不超过10个partition)
2.5 broadcast、cache、checkpoint
- broadcast
如果在任务中有某个对象需要被Executor频繁使用,那么一般情况下,每次Executor使用该变量的时候都需要从Driver读取,这造成大量的网络IO操作,损失性能。所以可以先把这个对象拷贝到每个Executor上并保存,这样每次Executor就可以从自己的内存中读取该对象,这个过程叫做broadcast。
这里不得不提到broadcast用来加速Join的一个技巧:如果我们要用一个很小的表A去join一个大表B,直接join的话会产生数据IO。我们可以先把小表A进行广播,让每一个Executor上都保存一份小表A,这样就可以在每个Executor上通过map算子,对每个partition内部进行join,从而避免了Shuffle操作。
- cache
如上图中,程序有两个Job,分别是以rdd4为输出的Job和以rdd5为输出的Job。但是这两个Job都有计算rdd1和rdd2的过程,也就是说在计算rdd4的时候,需要计算rdd1->rdd2->rdd3->rdd4;在计算rdd5的时候需要计算rdd1->rdd2->rdd5,其中rdd1和rdd2被重复计算了。
如果能够在第一次计算rdd4的时候,就把rdd2暂时保存起来,当再计算rdd5时就没必要从rdd1重新算起,这个暂时保存的操作叫做cache。通过cache可以减少重复计算,加快运算速度。原则上,长链式依赖不需要进行cache,树形依赖时需要考虑进行cache。进行cache时可以通过缓存级别参数来设置把RDD缓存到哪里:
- MEMMORY_ONLY:只保存到内存中
- MEMMORY_ONLY_SER:序列化模式保存到内存中
- MEMMORY_ONLY_SER+COMPRESS:序列化模式保存到内存中,同时设置参数spark.rdd.compress=true
- MOMMORY_AND_DISK:先把数据写入内存,如果内存不够就再将内存数据导入到磁盘
- DISK_ONLY:只把数据保存到磁盘中
如果RDD不是特别大,建议采用MEMMORY_ONLY_SER或MEMMORY_ONLY_SER+COMPRESS,如果RDD特别大可以使用DISK_ONLY。不建议使用MOMMORY_AND_DISK,因为MOMMORY_AND_DISK模式会不断的将数据写入内存,然后再导入磁盘,所以实际上往往耗时更长。另外MOMMORY_AND_DISK还存在OOM的隐患,因为将内存中数据导入到磁盘的过程和申请内存的过程是异步的,如果内存满了之后,数据还没来得及导入到磁盘,就申请新的内存,就会造成OOM。
- checkpoint
相比cache是将RDD计算出来并保存到内存或磁盘,依赖链并没有丢掉。但是checkpoint是将RDD计算出来后,保存到HDFS并且具有高容错性,并且会丢掉前面的依赖链。
三、SparkSQL是如何执行和优化的
3.1 执行流程
SparkSQL是基于SparkCore的高级封装。他的优点是能够通过一系列的分析、优化,将SQL文本或DataFrame操作转化为优化过的RDD编程逻辑,并执行。这样一来我们只需要写SQL或者操作DataFrame即可,不需要手动去考虑如何优化RDD代码。
SparkSQL执行过程可以概括为五步:语法解析、语法分析、逻辑优化、生成物理模型、选择物理模型。下面分别介绍:
3.1.1 语法解析
语法解析过程主要负责将代码转化为语法树的过程,比如下面这段SQL代码:
SELECT AVG(v) as v
FROM (
SELECT 1250 + 0 + salary.base_salary as v
FROM employee
JOIN salary
ON employee.id = salary.employee_id
AND employee.work_age < 3
)
就会解析成下图的语法树,该语法树中仅仅表示每个语法之间的依赖关系,并没有解析输入输出是否合法等问题。
3.1.2 语法分析
语法分析过程主要负责将前面的语法树与实际数据对应,并检查数据类型、输入输出等。
3.1.3 逻辑计划优化
逻辑计划优化一般包含过滤下推,常量折叠,列剪枝等。对于一些行过滤条件更早进行意味着后面进行运算的数据量会减少,有助于加速计算。
以及对于一些常量计算,可以事先算好,避免后面重复计算
对于后面用不到的列,可以及时剪掉,这样在后面的计算中可以节省内存
3.1.4 物理计划生成与决策
前面提到的逻辑计划优化中,只是改变了计算的顺序或筛掉一些不用的列。在物理计划生成阶段,会针对类似采用Join还是BroadcastJoin?HashAggregate还是SortAggregate?的问题,分别生成方案,并且通过一个损失函数来预估哪种方案最优,并采用最优的方案。
3.2 Join
在SparkSQL的join操作中,会把两张表分配为“流式遍历表”和“查找表”。遍历流式遍历表中的每条数据,并在查找表中去找key相等的数据,如果找到了就拼在一起作为新表的数据。
如果是Inner join,Spark会自动把比较大的表作为流式遍历表,小表作为查找表:
如果是Left join,会对查找失败的记录拼接一个nullRow并作为新表中的数据(Right join同理):
如果是Outer join比较复杂,会将两个表分别按照key排序,然后两边都按照顺序取出记录,并进行拼接:
如果是Left semi join,以左表为准,在右表中查找匹配的记录,如果查找成功,则仅返回左边的记录,否则返回null:
如果是Left anti join,是以左表为准,在右表中查找匹配的记录,如果查找成功,则返回null,否则仅返回左边的记录:
如果其中一个表很小(小于10M),SparkSQL会自动进行broadcast join,这种情况下不会有Shuffle操作。这个Table B大小的阈值的可以通过参数spark.sql.autoBroadcastJoinThreadhoad=10M
来设置。
如果Table B的大小超过了阈值,Spark会默认按照sort join方式进行join。首先会将具有相同key的记录在同一个分区(需要做一次shuffle),map阶段根据join条件确定每条记录的key,基于该key做shuffle write,将可能join到一起的记录分到同一个分区中,这样在shuffle read阶段就可以将两个表中具有相同key的记录拉到同一个分区处理。