从本篇文章开始,将开启spark学习和总结之旅,专门针对如何提高spark性能进行总结,力图总结出一些干货。
无论你是从事算法工程师,还是数据分析又或是其他与数据相关工作,利用spark进行海量数据处理和建模都是非常重要和必须掌握的一门技术,我感觉编写spark代码是比较简单的,特别是利用Spark SQL下的DataFrame接口进行数据处理,只要有python基础都是非常容易入门的,但是在性能调优上,许多人都是一知半解,写的spark程序经常陷入OOM或卡死状态。这时深入了解spark原理就显得非常有必要了。
本系列总结主要针对Hadoop YARN模式。
RDD是spark中最基本的数据抽象,存储在exector或node中,它代表一个 “惰性,”“静态”,“不可变”,“分布式“的数据集合,RDD基本介绍在网上上太多了,这里就不做详细介绍了,主要讲下以下内容:
转换操作:返回的是一个新的RDD,常见的如:map、filter、flatMap、groupByKey等等
执行操作:返回的是一个结果,一个数值或者是写入操作等,如reduce、collect、count、first等等
spark中计算RDD是惰性的,也即RDD真正被计算(执行操作,例如写入存储操作、collect操作等)时,其转换操作才会真正被执行。
spark为什么采用惰性计算:
在MapReduce中,大量的开发人员浪费在最小化MapReduce通过次数上。通过将操作合并在一起来实现。在Spark中,我们不创建单个执行图,而是将许多简单的操作结合在一起。因此,它造成了Hadoop MapReduce与Apache Spark之间的差异。
惰性设计的好处:
① 提高可管理性
可以查看整个DAG(将对数据执行的所有转换的图形),并且可以使用该信息来优化计算。
② 降低时间复杂度和加快计算速度
只运算真正要计算的转换操作,并且可以根据DAG图,合并不需要与drive通信的操作(连续的依赖转换),例如在一个RDD上同时调用map和filter转换操作,spark可以将map和filter指令发送到每个executor上,spark程序在真正执行map和filter时,只需访问一次record,而不是发送两组指令并两次访问分区。理论上相对于非惰性,将时间复杂度降低了一半。
例如:
val list1 = list.map(i -> i * 3) // Transformation1
val list2 = list1.map(i -> i + 3) // Transformation1
val list3 = list1.map(i -> i / 3) // Transformation1
list3.collect() // ACTION
假设原始列表(list) 很大,其中包含数百万个元素。如果没有懒惰的评估,我们将完成三遍如此庞大的计算。如果我们假设一次这样的列表迭代需要10秒,那么整个评估就需要30秒。并且每个RDD都会缓存下来,浪费内存。
使用惰性评估,Spark可以将这三个转换像这样合并到一个转换中,如下:
val list3 = list.map(i -> i + 1)
它将只执行一次该操作。只需一次迭代即可完成,这意味着只需要10秒的时间。
试想下,如果采用的是非惰性的设计,那么无法在真正运行之前生成DAG图,那么就是“看一步代码,执行一步代码”,对于不需要与drive通信的转换操作,需要每步都要访问所有分区,十分耗时。那么如果采用了惰性设计,在运行之前会生成DAG图,可以合并不需要与drive通信的操作(连续的依赖转换),只需要访问所有分区一次即可。相当于站在全局的角度进行了优化。
RDD本身包含其复制所需的所有依赖信息,一旦该RDD中某个分区丢失了,该RDD有足够需要重新计算的信息,可以去并行的,很快的重新计算丢失的分区。
在spark application的生命周期中,RDD始终常驻内存(在所在的节点内存),这也是其比MapReduce更快的重要原因。
spark中提供了三种内存管理机制:
① in-memory as deserialized data
这种常驻内存方式速度快(因为去掉了序列化时间),但是内存利用效率低。
② in-memory as serialized data
该方法内存利用效率高,但是速度慢
③ 直接存在disk上
对于那些较大容量的RDD,没办法直接存在内存中,需要写入到DISK上。该方法仅适用于大容量RDD。
要持久化一个RDD,只要调用其cache()或者persist()方法即可。在该RDD第一次被计算出来时,就会直接缓存在每个节点中。而且Spark的持久化机制还是自动容错的,如果持久化的RDD的任何partition丢失了,那么Spark会自动通过其源RDD,使用transformation操作重新计算该partition。
cache()和persist()的区别在于,cache()是persist()的一种简化方式,cache()的底层就是调用的persist()的无参版本,同时就是调用persist(MEMORY_ONLY),将数据持久化到内存中。如果需要从内存中清楚缓存,那么可以使用unpersist()方法。
我们来仔细分析下持久化和非持久化的区别:
非持久化:
持久化:
显然对于要复用多次的RDD,要将其进行持久化操作,此时Spark就会根据你的持久化策略,将RDD中的数据保存到内存或者磁盘中。以后每次对这个RDD进行算子操作时,都会直接从内存或磁盘中提取持久化的RDD数据,然后执行算子,而不会从源头处重新计算一遍这个RDD,再执行算子操作。 所以在写spark代码时:尽可能复用同一个RDD。
这里常有个误区:
val rdd1 = ... // 读取hdfs数据,加载成RDD
rdd1.cache // 持久化操作
val rdd2 = rdd1.map(...)
val rdd3 = rdd1.filter(...)
rdd1.unpersist // 释放缓存
rdd2.take(10).foreach(println)
rdd3.take(10).foreach(println)
如果按上述代码进行持久化,则效果就如同没有持久化一样。原因就在于spark的lazy计算。
代码应该如下:
val rdd1 = ... // 读取hdfs数据,加载成RDD
rdd1.cache
val rdd2 = rdd1.map(...)
val rdd3 = rdd1.filter(...)
rdd2.take(10).foreach(println)
rdd3.take(10).foreach(println)
rdd1.unpersist
rdd2执行take时,会先缓存rdd1,接下来直接rdd3执行take时,直接利用缓存的rdd1,最后,释放掉rdd1。所以在何处释放RDD也是非常需要细心的。 请在action之后unpersisit!!!
shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作。比如reduceByKey、join等算子,都会触发shuffle操作。shuffle操作需要将数据进行重新聚合和划分,然后分配到集群的各个节点上进行下一个stage操作,这里会涉及集群不同节点间的大量数据交换。由于不同节点间的数据通过网络进行传输时需要先将数据写入磁盘,因此集群中每个节点均有大量的文件读写操作,从而导致shuffle操作十分耗时(相对于map操作)。
窄依赖:父RDD 与 子RDD的分区是一对一(map操作)或多对一(coalesce)的,不会有shuffle过程;并且子RDD的分区结果与其key和value值无关,每个分区与其他分区亦无关。
上面左图可对应map操作分区,右图对应coalesce操作。
宽依赖:父RDD与子RDD的分区是一对多的关系,并且是按一定方式进行重分区,会有shuffle过程产生,比较耗时,可能会引发spark性能问题。常见的宽依赖操作如:groupByKey、reduceByKey、sort、sortByKey等等。
注意:coalesce操作如果是将10个分区换成100个分区,由少分区转成大分区将会发生shuffle过程。coalesce操作场景主要是rdd经过多层过滤后的小文件合并。rdd的reparation方法与coalesce相反,主要是为了 处理数据倾斜,增加partiton的数量使得每个task处理的数据量减少,肯定会有shuffle过程产生(repartition其实调用的就是coalesce,只不过shuffle = true (coalesce中shuffle: Boolean = false))。
一个spark应用主要由一系列的spark Job组成,而这些spark Job由sparkContext定义而来。当SparkContent启动时,一个driver和一系列的executor会在集群的工作节点上启动。每个executor都有个JVM虚拟环境,一个executor不能跨越多个节点。
上图表示在一个分布式系统上启动一个spark application的物理硬件层面流程。
需要注意以下两点:
简而言之:一个spark Application由多个Job组成,Job由提交代码中的Action操作定义,而一个Action操作由多个Stage组成,Stage的分割由宽依赖进行分割的,而每个Stage又由多个Task组成。一个Task对应一个分区,一个task会被分配到一个executor上执行。
每个Job都对应一个DAG图,每个DAG有一系列的Stage组成。
def simpleSparkProgram(rdd : RDD[Double]): Long ={
//stage1
rdd.filter(_< 1000.0)
.map(x => (x, x) )
//stage2
.groupByKey()
.map{ case(value, groups) => (groups.sum, value)}
//stage 3
.sortByKey()
.count()
}
注意:
对于DAG的深刻理解非常重要,如果理解不深刻则可能定位问题的效率不高。比如常见的数据倾斜。当理解了这些,如果出现了数据倾斜,可以分析job,stage和task,找到部分task输入的严重不平衡,最终定位是数据问题或计算逻辑问题。