spark常用算法 spark计算案例_HDFS


前言

继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常用算法 spark计算案例_spark常用算法_02


Spark只是分布式计算框架,是建立在分布式存储框架(比如HDFS)之上的。其核心是SparkCore部分,也就是对RDD(Resilient Distributed Dataset 即弹性分布式数据集)的操作。因为其易用性不强,需要手动调优代码,写出优秀的Spark代码是有门槛的。所以在SparkCore的基础上出现了SparkSQL,他不仅方便易用,而且在很多地方进行了优化,就算我们代码写的烂,SparkSQL也会帮我们优化。所以SparkSQL很快普及开,并在他的基础上又出现了SQL、Streaming、MLlib等封装。


spark常用算法 spark计算案例_spark 算子例子_03


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算子包括:

  1. filter(func: T => U)
  2. map(func: T => Bool)
  3. flatMap(func: T => Seq[U])
  4. sample(fraction: Float)
  5. groupByKey()
  6. reduceByKey(func: (V, V) => V)
  7. union()
  8. join()
  9. cogroup()
  10. crossProduct()
  11. mapValues(func: V => W)
  12. sort(c: Comparator[K])
  13. sortByKey(desc: Bool)
  14. sortBy(k: K, desc: Bool)
  15. partitionBy(p: Partitioner[K])

常用的Action算子包括:

  1. count()
  2. collect()
  3. reduce(func: (T, T) => T)
  4. lookup(k: K)
  5. save(path: String)

只需要了解上述的18个算子已经能够涵盖工作中绝大多数需求了。按理说这里我应该详细介绍一下每个算子,但是这部分内容网络上已经很多很详细,本着不重复造轮子的原则,这部分内容不作为本文的重点,本文只贴出传送门。

2.3 Shuffle

前面提到的Transformation操作中根据输入输出RDD的依赖关系,也可以继续细分为“宽依赖”和“窄依赖”。


spark常用算法 spark计算案例_HDFS_04

窄依赖(左)及宽依赖(右)

  • 窄依赖:就是指输入RDD的每一个partition,指对应输出RDD的一个partition。
  • 宽依赖:就是指输入RDD的每一个partition,可能对应输出RDD的多个partition。

为什么要这样分呢?因为在窄依赖中,机器处理每一个partition的Task只需要处理这一个partition的数据,不需要考虑其他partition的数据。不存在Tasks之间的数据交换,可以更充分的并行。反之宽依赖中,每一个Task之间都存在数据交换,很大程度上限制了程序的性能。(实践经验表明,绝大多数的性能其实都是消耗在shuffle操作上)。

常用窄依赖:

  1. filter(func: T => U)
  2. map(func: T => Bool)
  3. flatMap(func: T => Seq[U])
  4. sample(fraction: Float)
  5. union()
  6. mapValues(func: V => W)

常用宽依赖:

  1. groupByKey()
  2. reduceByKey(func: (V, V) => V)
  3. join()
  4. cogroup()
  5. crossProduct()
  6. sort(c: Comparator[K])
  7. sortByKey(desc: Bool)
  8. sortBy(k: K, desc: Bool)
  9. partitionBy(p: Partitioner[K])

如果仅仅是列举那些算子需要Shuffle的话,这一环节我就没必要写了。下面上点干货,我们来看看Shuffle是如何实现的。先来看看Spark的早期版本(1.1.0以前)如何做的:


spark常用算法 spark计算案例_数据_05


一个Shuffle操作分两个阶段:首先Shuffle write阶段把每个partition中的数据进行分桶(比如groupByKey操作就按Key分桶),然后把每个桶的文件写入磁盘(即上图中灰色partition分桶成Bucket)。然后把相同Key的Bucket的数据汇总成一个partition,接下来就可以在partition内部进行计算了。这样做有一个缺陷,就是过程中会产生大量的中间文件(num-partition*num-keys)。接下来Spark进行了一点改进:


spark常用算法 spark计算案例_HDFS_06


其实就是把一个Executor上不同core所执行的Task所产生的Bucket,都写到同一个文件里。这样确实减少了文件数量,但是依然治标不治本,文件数量还是很多。而在1.1.0版本之后,Spark做了比较大的改动:


spark常用算法 spark计算案例_数据_07


新的方式采用排序的方法,把一个partition中不同key的数据排序,并写道一个文件内(替代分别写到不同的文件中)。这样在Shuffle read的过程中,只需要根据不同索引,找到不同key在Bucket文件中的位置即可。这样就每个partition只需要输出一个中间文件。但是这样做需要引入排序操作,相比前面的HashShuffle来说更加耗时。

2.4 任务调度

下面就是本章节的重点,一段代码是如何转化为一系列的任务,并分发到每台机器上的每个CPU的呢?这里需要先介绍三个概念:Job、Stage、Task(这三句话要仔细读):

  • Job:每一个Action操作,以及其向前依赖的所有Transformation操作所构成的计算任务
  • Stage:一个Job内,每一个Shuffle操作及其向前的窄依赖操作所构成的计算任务
  • Task:一个Stage内,每一个partition上需要进行的计算任务


spark常用算法 spark计算案例_数据_08


上图中展示了一个Job的工作内容,其中最右边的Save操作是一个Action操作,将RDD数据写入到磁盘中。那么Save操作,以及其向前依赖的全部操作(这张图里的全部操作)就构成了一个Job。

三个红色虚线圈起来的部分就是三个Stage,把整条依赖链中的Shuffle操作砍断,剩下的几个部分就是几个Stage,这个将一个Job划分为多个Stage的过程由DAGSchduler来负责。

一个RDD内部的圆圈表示partition,对于每个partition(就是前面提到的一个part-xxxxx文件)进行的计算就是一个Task。Spark中有一个叫TaskSchduler的调度器来不断的根据当前的可用资源去取Task执行,队列可以按照先提交先执行(FIFO)或按权重执行(Priority)。


spark常用算法 spark计算案例_语法树_09


对于资源的配置: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。


spark常用算法 spark计算案例_spark常用算法_10


这里不得不提到broadcast用来加速Join的一个技巧:如果我们要用一个很小的表A去join一个大表B,直接join的话会产生数据IO。我们可以先把小表A进行广播,让每一个Executor上都保存一份小表A,这样就可以在每个Executor上通过map算子,对每个partition内部进行join,从而避免了Shuffle操作。


spark常用算法 spark计算案例_spark常用算法_11


  • cache


spark常用算法 spark计算案例_语法树_12


如上图中,程序有两个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代码。


spark常用算法 spark计算案例_HDFS_13


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
)


就会解析成下图的语法树,该语法树中仅仅表示每个语法之间的依赖关系,并没有解析输入输出是否合法等问题。


spark常用算法 spark计算案例_HDFS_14


3.1.2 语法分析

语法分析过程主要负责将前面的语法树与实际数据对应,并检查数据类型、输入输出等。


spark常用算法 spark计算案例_spark常用算法_15


3.1.3 逻辑计划优化

逻辑计划优化一般包含过滤下推,常量折叠,列剪枝等。对于一些行过滤条件更早进行意味着后面进行运算的数据量会减少,有助于加速计算。


spark常用算法 spark计算案例_数据_16


以及对于一些常量计算,可以事先算好,避免后面重复计算


spark常用算法 spark计算案例_数据_17


对于后面用不到的列,可以及时剪掉,这样在后面的计算中可以节省内存


spark常用算法 spark计算案例_HDFS_18


3.1.4 物理计划生成与决策

前面提到的逻辑计划优化中,只是改变了计算的顺序或筛掉一些不用的列。在物理计划生成阶段,会针对类似采用Join还是BroadcastJoin?HashAggregate还是SortAggregate?的问题,分别生成方案,并且通过一个损失函数来预估哪种方案最优,并采用最优的方案。


spark常用算法 spark计算案例_语法树_19


3.2 Join

在SparkSQL的join操作中,会把两张表分配为“流式遍历表”和“查找表”。遍历流式遍历表中的每条数据,并在查找表中去找key相等的数据,如果找到了就拼在一起作为新表的数据。


spark常用算法 spark计算案例_spark 算子例子_20


如果是Inner join,Spark会自动把比较大的表作为流式遍历表,小表作为查找表:


spark常用算法 spark计算案例_语法树_21


如果是Left join,会对查找失败的记录拼接一个nullRow并作为新表中的数据(Right join同理):


spark常用算法 spark计算案例_HDFS_22


如果是Outer join比较复杂,会将两个表分别按照key排序,然后两边都按照顺序取出记录,并进行拼接:


spark常用算法 spark计算案例_spark常用算法_23


如果是Left semi join,以左表为准,在右表中查找匹配的记录,如果查找成功,则仅返回左边的记录,否则返回null:


spark常用算法 spark计算案例_数据_24


如果是Left anti join,是以左表为准,在右表中查找匹配的记录,如果查找成功,则返回null,否则仅返回左边的记录:


spark常用算法 spark计算案例_语法树_25


如果其中一个表很小(小于10M),SparkSQL会自动进行broadcast join,这种情况下不会有Shuffle操作。这个Table B大小的阈值的可以通过参数spark.sql.autoBroadcastJoinThreadhoad=10M来设置。


spark常用算法 spark计算案例_数据_26


如果Table B的大小超过了阈值,Spark会默认按照sort join方式进行join。首先会将具有相同key的记录在同一个分区(需要做一次shuffle),map阶段根据join条件确定每条记录的key,基于该key做shuffle write,将可能join到一起的记录分到同一个分区中,这样在shuffle read阶段就可以将两个表中具有相同key的记录拉到同一个分区处理。


spark常用算法 spark计算案例_spark 算子例子_27