Spark- 性能优化
由于Spark 的计算本质是基于内存的,所以Spark的性能城西的性能可能因为集群中的任何因素出现瓶颈:CPU、网络带宽、或者是内存。如果内存能够容得下所有的数据,那么网络传输和通信就会导致性能出现频惊。但是如果内存比较紧张,不足以放下所有的数据(比如在针对10亿以上的数据量进行计算时),还是需要对内存的使用进行性能优化的,比如说使用一些手段来减少内存的消耗。
Spark性能优化,其实主要就是在于对内存的使用调优。因为通常情况下来说,如果你的Spark应用程序计算的数量比较小,并且你的内存足够使用,那么只要运维可以保障网络通畅,一般是不会有大量的性能问题的。但是Spark应用程序的性能问题往往出现在针对大数据量(比如10级别的)进行计算时出现。因此通常来说,Saprk性能优化,主要时对内存进行性能优化。当然,除了内存调优之外,还有很多手段可以优化Spark应用程序的性能。
Spark性能优化技术
1.使用高性能序列化库
2.优化数据结构
3.对多次使用的RDD进行持久化/checkpoint
4.使用序列化的持久化级别
5.Java虚拟机垃圾回收调优
6.提高并行度
7.广播共享数据
8.数据本地化
9.reduceByKey和groupByKey的合理使用
10.Shuffle调优(核心中的核心,重中之重)
诊断内存的消耗
内存都花费在哪里了?
1.每个Java对象,都有一个对象头,会占用16个字节,主要包含了一些对象的元信息,比如只想他的类的指针。如果一个对象本身很小,比如就包含了一个 int 类型的field,那么它的对象头实际上比对象自己还要大。
2.Java的string对象,会比它内部的原始数据,要多出40个字节。因为它内部使用char数组来保存内部的字符序列的,并且还得保存诸如数组长度之类的信息。而且因为String 使用的时 UTF-16编码,所以每个字符都会占用2个字节,比如,包含10个字符的String ,会占用60个字节。
3.Java中的集合类型,比如HashMap 和LinkedList,内部使用的是链表数据结构,所以对链表中的每一个数据,都是用了Entry对象来包装。Entry对象不光有对象头,还有指向下一个Entry 的指针,通常占用8个字节。
4.元素类型为原始数据类型(比如int)的集合,内部通常会使用原始数据类型的包装类型,比如 Integer来存储元素。
如何判断你的程序消耗了多少内存?
这里有一个非常简单的办法来判断,你的spark程序消耗了多少内存。
1.首先,自己设置的RDD的并行度,有两种方式:要不然,在parallelize()、testFile()等方法中,传入第二个参数,设置RDD的task / partition 的数量;要不然,用 SparkConf.set() 方法,设置一个参数,spark.default.parallelism,可以统一设置这个 application 所有RDD 的partition 数量。
2.其次,在程序中将RDD cache 到内存中,调用RDD.cache() 方法即可。
3.最后,观察 Driver 的log,你会发现类似于:“INFO BlockManagerMasterActor:Added rdd_0_1 in memory on mbk.local:50311(size:717.5KB, free: 332.3MB)”的日志信息。这就显示了每个 partition 占用了多少内存
4.将这个内存信息乘以partition 数量,即可得出RDD 的内存占用量。
数据序列化概述
在很合分布式系统中,序列化都是扮演着一个重要角色的。在分布式系统中数据肯定要进行序列化,要么是把数据序列化后写入到内存,要么把数据序列化后写入到磁盘,要么把数据序列化后通过网络进行传输。如果使用的序列化技术,在执行序列化的时候很慢,或者是西兰花后的数据还是很大,消耗内存,那么会让分布式应用程序的性能下降很多。所以进行Spark 性能优化的第一步,就是进行序列化的性能优化。
优化数据结构
要减少内存的消耗,除了使用高效的序列化类库以外,还有一个很重要的事情,就是优化数据结构。从而避免Java语法特性中多导致的额外内存的开销,比如基于指针的Java数据结构,以及包装类型。
有一个关键的问题,就是优化什么数据结构?其实主要就是优化你的算子函数,内部使用到的局部数据,或者是算子函数外部的数据。都可以进行数据结构的优化。优化之后,都会减少其对内存的消耗和占用。
1.优先使用数组和字符串,而不是集合类。也就是说,优先用 array,而不是ArrayList、LinkedList、HashMap等集合。
比如,有个List<Integer> list = new ArrayList<Integer>(),将其替换为 int[] arr = new int[]。这样的话,array既比List 少了额外信息的存储开销,还能使用原始数据类型(int)来存储数据,比List中庸Integer这种包装类型存储数据,要节省内存的多。
还比如,通常企业级应用中的做法是,对于HashMap、List这种数据,统一用String 拼接成特殊格式的字符串,比如Map<Integer,Person> persons = new HashMap<Integer,Person>()。可以优化为,特殊字符串格式:id:name,address|id:name,address ...。
2.避免使用多层嵌套的对象结构。比如说,public class Teacher{private List<Student> students = new ArrayList<Student>()}。就是非常不好的例子。因为Teacher类的内部有嵌套了大量的小Student对象。
比如说,对于上述例子,也完全可以使用特殊的字符串来进行数据的存储。比如,用json字符串来存储数据,就是一个很好的选择。
{"teacher":1,"teacherName":"leo",student:[{"studentId":1,"studentName":"tom"}]}
3.对于有些能够避免的场景,尽量使用int替代String,因为String虽然比ArrayList、HashMap等数据结构搞笑多了,占用内存量少了,但是之前分析过,还是有额外的信息的消耗。比如之前用String表示id,那么现在完全可以使用数字类型的int,来进行替代。
这里提醒,在spark应用中,id就不要用常用的uuid了,因为无法转成int,就用自增的int类型的id即可。
对多次使用的RDD进行持久化或Checkpoint
如果程序中,对于某个RDD,基于它进行了多次 transformation或者action 操作。那么就非常有必要对其进行持久化操作,以避免对一个RDD反复进行计算。
此外,如果要保证在RDD的持久化数据可能丢失的情况下,还要保证高性能,那么可以对RDD进行Checkpoint操作。
Java虚拟机垃圾回收调优
java虚拟机垃圾回收调优的背景
如果在持久化RDD的时候,持久化了大量的数据,那么Java虚拟机的垃圾回收就可能曾为一个性能瓶颈。因为Java虚拟机会定期进行垃圾回收,此时就会去追钟所有的Java对象,并且在垃圾回收时,找到那些已经不再使用的对象,然后清理旧的对象,来给新的对象腾出内存空间。
垃圾回收的性能开销,是跟内存中的对象的数量,成正比的。所以,对于垃圾回收的性能问题,首先要做的是,使用更高效的数据结构,比如array和String;其次就是在持久化 rdd 时,使用序列化的持久化级别,而且用Kryo 序列化类库,这样,每个partitIon 就只是一个对象——一个字节数组。
GC对spark性能影响的原理
垃圾回收器(Garbage Collector),简称为GC。主要工作,寻找哪些对象已经不再使用了,然后就去清理掉这些对象。
GC对性能的影响,就在于,如果内存中数据量比较大的话,那么可能会很频繁的就造成内存空间爆满,不够用了,此时就会造成 GC 频繁发生,那么本身GC就是有性能消耗的,而且还频繁发生,那么对于性能当然有影响了。
此外,如果数据量过大的话,那么每次GC的时候,要回收的数据量,是不是也特别的大,那么会导致GC的速度比较慢。
除此之外,GC发生的时候,GC是一条线程,那么比如说,我们的 task 运行的线程叫做工作线程。GC运行的时候,GC线程会让工作线程直接停下来,让GC线程单独运行。这样的话,是不是就直接导致我们的 task 执行的停止。那么就影响了我们的 spark 应用程序的运行速度,也就是降低了性能。
所以说,频繁发生GC,肯定是不好的!因为频繁GC,导致 task 工作线程频繁停止,那么整个 spark 应用程序的性能就会大幅度下降。
检测垃圾回收
我们对垃圾回收进行监测,包括多久进行一次垃圾回收,以及每次垃圾回收耗费的时间,只要在 spark-submit 脚本中,增加一个配置即可
-conf "spark.executor.extraJavaOptions=-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps"
但是要记住,这里虽然会打印出Java虚拟机的垃圾回收的相关信息,但是是输出到了Worker 上的日志中,而不是driver的日在日志中。
但是这种方式也只是一种,其实也完全可以通过SparkUI (4040端口)来观察每个stage的垃圾回收的情况。
优化Executor内存比例
对于垃圾回收来说,最重要的是调节RDD缓存占用的内存空间,与算子执行时创建的对象占用的内存空间的比例。默认情况下,Spark使用每个Executor 60%的内存空间来缓存RDD,要么在task 执行期创建的对象,只有40%的内存空间来存放。
在这种情况下,很有可能因为你的内存空间的不足,task创建的对象过大,那么一旦发现40%的内存空间不够用了,就会触发Java虚拟机的垃圾回收操作。因此在极端情况下,垃圾回收操作可能会频繁出发。
在上诉情况下,如果发现垃圾回收频繁发生。那么就需要对那个比例进行调优,使用conf.set("spark.storage.memoryFraction","0.5") 即可,可以将RDD缓存占用空间的比例降低,从而给更多的空间让task创建的对象使用。
因此,对于RDD持久化,完全可以使用 kyro 序列化,加上降低其executor 内存占比的方式,来减少其内存消耗。给task提供更多的内存,从而避免task的执行频繁触发垃圾回收。
高级垃圾回收调优
Java堆空间被划分成了两块块空间,一个是新生代,一个是老年代。新生代放的是短时间存活的对象,老年代放的是长时间存活的对象。年轻代又被划分了三块空间,Eden、Survivor1、Survivor2
首先,Eden区域和Survivor1区域用于存放对象。创建的对象首先放入Eden区域和Survivor1区域,如果Eden区域满了,那么就会触发一次Minor GC,进行新生代的垃圾回收。Eden和Survivor1区域存活的对象,会被移动到Survivor2区域中,移动完了之后,Eden和Survivor1中剩余的,就都是不再使用的对象,那么GC然后将他们移除内存空间。Survivor1和Survivor2的角色调换,Survivor1变成了备用。如果一个对象,在新生代,多次minor GC都没有将其清除,说明它是长时间存活的对象,那么就会将其移动到老年代中。
如果老年代的空间满了,那么就会触发full GC,进行老年代的垃圾回收操作。
问题:到目前为止,说的是正常的情况,那么非正常情况是什么?就是minor GC发生的时候,将存活的对象放入备用的Survivor区域,结果发现放满了,放不下了,这个时候就会将对象移入老年代。
所以就会出现有短时间存活的对象进入老年代,白白占用内存空间,而且老年代很可能会因此快速被短时间存活的对象给占满了,就会触发full GC操作。回收老年代对象。所以,严重问题是,如果Eden区域不够大,很可能出现,大量数据频繁进入老年代,full GC频繁发生,导致task工作线程停止影响性能。
Spark中,垃圾回收调优的目标是,只有真正长时间存活的对象,才能进入老年代,短时间存活的对象,只能待在年轻代。不能因为某个Survivor区域空间不够,在Minor GC时,就进入了老年代。从而造成短时间存活的对象,长期待在老年代中占据了空间,而且Full GC时要回收大量的短时间存活的对象,导致Full GC速度缓慢。
如果发现在task执行期间,大量Full GC发生了,那么说明,年轻代的Eden区域给的空间不够大,此时可以执行一些操作来优化垃圾回收行为:
1.包括降低 spark.storage.memoryFraction 的比例,给年轻代更多的空间来存放短时间存活的对象;
2.给Eden 区域分配更大的空间,使用 -Xmn 即可,通常建议给 Eden 区域,预计大小的4/3;
3.如果使用的是HDFS文件,那么很好估计Eden区域大小,如果每个Executor 有4个task,然后每个HDFS压缩块解压缩后大小是3倍,此外每个hdfs块的大小是64M,那么Eden区域的预计大小就是:4*3*64MB,然后呢,再通过 -XMN参数,将Eden区域大小设置为4*3*64*4/3。
总结:
其实根据经验来看,对于垃圾回收的调优,尽量的做法是,调节executor 内存的比例就可以了。以为jvm的调优是非常复杂和敏感的。除非是真的到了万不得已的地步,自己本身又对jvm相关的技术很了解,那么此时进行Eden区域的调节,调优是可以的。
一些高级的参数
-XX:SurvivorRatio=4 :如果值为4,那么就是两个Survivor跟Eden的比例是2:4,也就是说每个Survivor占据的年轻代的比例是1/6,所以你也可以尝试调大Survivor区域的大小
-XX:NewRatio=4:调节新生代和老年代的比例
提高并行度
实际上Spark集群的资源并不一定会被充分利用到,所以要尽量设置合理的并行度来充分地利用集群的资源。才能充分提高Spark应用程序的性能。
Spark会自动设置以文件作为输入源的RDD的并行度,依据其大小,比如HDFS,就会给每一个block 创建一个partition,也依据这个设置并行度。对于reduceByKey 等会发生 shuffle 的操作,就使用并行度最大的父RDD的并行度即可。
可以手动使用 textFile()\parallize()等方法的第二个参数来设置并行度;也可以使用spark.default.parallelism 参数来设置统一的并行度。Spark官方的推荐是,给集群中的每个cpu core设置2~3个task
比如说,spark-submit 设置了executor数量是10个,每个executor要求分配2个core,那么application总共会有20个core,此时可以设置new SparkConf().set("Spark.default.parallelism","60")来设置合理的并发度,从而充分利用资源。
这个参数一旦设置了,就是说所有RDD的partition 都设置成60个,也就是说每个RDD的数据,会被拆分为60份。那么针对RDD的partition,一个partition会启动一个task来进行计算,所以对于所有的算子操作,都只会创建60个task,在集群中运行。
所以,假设集群中明明有10个CPU,结果你硬是要设置了5个task,那么相当于,有5个CPU core 是空闲的。其实最好的资源白白浪费了!!
其实,最好的请况是每个CPU core 都不空闲,一直不断运转着。那么这时候我们对集群上资源的使用率是最高的,也意味着我们得spark application的性能是最高的吧。
我们还不一定,如果只是设计10个task,让每个cpu core一个task吧,完全可以设置20~30个task,为什么?因为每个task执行的顺序和完成的时间可能都是不一样的。那么如果正好是10个task,可能某个task很快就完成了,那么那个CPU就又空闲下来,资源又浪费了。
所以官方是推荐我们,设置集群总CPU数量的两倍~三倍的并行度,这样的话,每个CPU core可能分配到2~3个task线程。那么集群资源就不大可能出现空闲的情况 ,会连续运转,最大的发挥出他的功效来。
广播共享变量
如果你的算子函数中,使用到了特别大的数据,那么这个时候,推荐将该数据进行广播。这样的话可就不至于将一个大数据拷贝到每一个task上面,而是给每个节点拷贝一份,然后节点上的task共享该数据。这样的话就可以减少大数据在节点上的内存消耗,并且可以减少数据到节点的网络传输消耗。
默认情况下,算子函数中使用到外部的数据,会被拷贝到每一个task中,此时如果使用到的外部数据很大的话,那么是不是会在每个节点上占用大量的内存,而且会产生大量的网络数据传输,大量的网络通信造成性能的开销。比方说,共享的数据是100M,那么这里又6个task,就会占用600M的内存。这种事情会发生在我们想仿效MapReduce的map-side join,就是说在每个task运行的时候,要去把输入的数据,每一个表进行join,那么其实是可以这样做的,在运行join之前,先把表中的数据读取出来,加载到driver上,编程比如Java中的ArrayList,然后在算子函数中使用它,进行join操作。
这种情况下就应该对使用的外部大数据进行broadcast广播,然后让其在每个节点上就一根副本,而不是每个task一份副本,大大减少了每个节点上的内存占用空间。 val broadcastConf = sc.broadcast(myConf) 这样的话就可以大大减少每个节点的内存消耗,并且可以减少网络数据传输的性能消耗。
数据本地化
数据本地话对于Spark job性能有着巨大的影响,如果数据以及要计算它的代码是在一起的,那么性能当然会非常高。但是,如果数据和计算它的代码是分开的,那么其中之一必须到另一方的机器上,通常说,移动代码到其他节点会比移动数据到代码所在的节点上去,速度要快的多,因为代码比较小。Spark也正是基于这个数据本地化的原则来构建task掉度算法的。
数据本地化,指的是,数据离计算它的代码有多近,基于数据距离代码的距离,有几种数据本地化级别:
1.PROCESS_LOCAL:数据和计算它的代码在同一个JVM进程中。
2.NODE_LOCAL:数据和计算它的代码在一个节点上,但是不在一个进程中,比如在不同的executor进程中,或者是数据在HDFS文件的block中。
3.NO_PREF:数据从哪里来,性能都是一样的。
4.RACK_LOCAL:数据和计算它的代码在一个机架上。
5.ANY:数据可能在任意地方,比如其他网络环境内,或者其他机架上。
Spark倾向于使用最好的本地化级别来调度task,但是这是不可能的。如果没有任何未出理的数据在空闲的executor上,那么Spark就会放低本地化级别。这时有两个选择:第一,等待,知道executor的CPU释放出来,那么就分配task 过去;第二,立即在任意一个executor上启动一个task。
Spark默认会等待一会儿,来期望task要处理的数据所在的节点上的executor空闲出一个CPU,从而将task分配过去,只要超过了时间,那么Spark就会将task分配到其他任意一个空闲的executor上。task会去调用RDD的 iterator() 方法,然后通过executor关联的blockManager,来尝试获取数据,首先尝试从 getLocal() 在本地找数据,如果没有找到的话,那么就用getRemote(),通过BlockTransferService,连接到有数据的BlockManager,来获取数据,或者是没有持久化过的,那么就computeOrReadCheckpoint()。如果都没有就要去其他节点上的Executor上分配节点,然后继续执行。
可以设置参数,spark.locality 系列参数,来调节Spark 等待task可以进行数据本地化的时间。spark.localwait(3000毫秒)、spark.locality.wait.node、spark.locality.wait.process、spark.locality.wait.rack。
reduceByKey和groupByKey
val counts = lineRDD.reduceByKey(_+_)
val counts = lineRDD.groupByKey().map(wordCounts=>(wordCounts._1,wordCount._2.sum))
如果能使用reduceByKey,那就用reduceByKey,因为它会在map端,先进行本地combine,可以大大减少要传输到reduce端的数据量,减少网络传输的开销
只有在reduceByKey处理不了时,才用groupByKey().map() 来替代。
Shuffle性能调优
new SparkConf().set("spark.shuffle.consolidateFiles","true") spark.shuffle.consolidateFlies: 是否开启consolidateFiles的合并,默认为false spark.reducer.maxSizeInFlight: reduce task 拉取的缓存,默认48M spark.shuffle.file.buffer: map task的写磁盘缓存,默认32K spark.shuffle.io.maxRetries: 拉取失败最大重试次数,默认3次 spark.shuffle.io.retryWait: 拉取失败的重试间隔,默认5s spark.shuffle.memoryFraction: 用于reduce端聚合的内存比例,默认0.2,超过比例就会溢出到磁盘上