Spark之调优指南

Spark之系统调优指南

我们写好的代码像是一些信息的有序组合,硬件资源就像是能量。我们就是想让信息去最大限度的利用好这些能量去达完成我们的目标。具体的我们要尽可能的去压榨CPU资源,减小网络传输,减少内存的浪费,避免磁盘IO带来的时间效率的低下。下面从几个维度去总结一些Spark的优化。

以下为石杉老师课程笔记的整理:

内存

内存花费在哪里

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,来存储元素。

 

启示:

  • 尽量减少Java对象包装类的使用。
  • 用嵌套的数据类型(比如json)代替Java中集合的使用,这里尽可能的使用多的CPU去操作这些字符串但是减少了内存。
  • 字符串占用的空间确实很大,有时候能够用整型的时候就尽量用整型。

 

序列化与持久化机制

序列化机制

在任何分布式系统中,序列化都是扮演着一个重要的角色的。如果使用的序列化技术,在执行序列化操作的时候很慢,或者是序列化后的数据还是很大,那么会让分布式应用程序的性能下降很多。所以,进行Spark性能优化的第一步,就是进行序列化的性能优化。

 

Spark自身默认就会在一些地方对数据进行序列化,比如Shuffle。还有就是,如果我们的算子函数使用到了外部的数据(比如Java内置类型,或者自定义类型),那么也需要让其可序列化。

而Spark自身对于序列化的便捷性和性能进行了一个取舍和权衡。默认,Spark倾向于序列化的便捷性,使用了Java自身提供的序列化机制——基于ObjectInputStream和ObjectOutputStream的序列化机制。因为这种方式是Java原生提供的,很方便使用。

但是问题是,Java序列化机制的性能并不高。序列化的速度相对较慢,而且序列化以后的数据,还是相对来说比较大,还是比较占用内存空间。因此,如果你的Spark应用程序对内存很敏感,实际上默认的Java序列化机制并不是最好的选择。

 

Spark两种序列化机制

1、Java序列化机制:默认情况下,Spark使用Java自身的ObjectInputStream和ObjectOutputStream机制进行对象的序列化。只要你的类实现了Serializable接口,那么都是可以序列化的。而且Java序列化机制是提供了自定义序列化支持的,只要你实现Externalizable接口即可实现自己的更高性能的序列化算法。Java序列化机制的速度比较慢,而且序列化后的数据占用的内存空间比较大。

 

2、Kryo序列化机制:Spark也支持使用Kryo类库来进行序列化。Kryo序列化机制比Java序列化机制更快,而且序列化后的数据占用的空间更小,通常比Java序列化的数据占用的空间要小10倍。Kryo序列化机制之所以不是默认序列化机制的原因是,有些类型虽然实现了Seriralizable接口,但是它也不一定能够进行序列化;此外,如果你要得到最佳的性能,Kryo还要求你在Spark应用程序中,对所有你需要序列化的类型都进行注册。

 

如果要使用Kryo序列化机制,首先要用SparkConf设置一个参数,使用new SparkConf().set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")即可,即将Spark的序列化器设置为KryoSerializer。这样,Spark在内部的一些操作,比如Shuffle,进行序列化时,就会使用Kryo类库进行高性能、快速、更低内存占用量的序列化了。

使用Kryo时,它要求是需要序列化的类,是要预先进行注册的,以获得最佳性能——如果不注册的话,那么Kryo必须时刻保存类型的全限定名,反而占用不少内存。Spark默认是对Scala中常用的类型自动注册了Kryo的,都在AllScalaRegistry类中。但是,比如自己的算子中,使用了外部的自定义类型的对象,那么还是需要将其进行注册。

 

如果要注册自定义的类型,那么就使用如下的代码,即可:

Scala版本:

val conf = new SparkConf().setMaster(...).setAppName(...)

conf.registerKryoClasses(Array(classOf[Counter] ))

val sc = new SparkContext(conf)

Java版本:

SparkConf conf = new SparkConf().setMaster(...).setAppName(...)

conf.registerKryoClasses(Counter.class)

JavaSparkContext sc = new JavaSparkContext(conf)

 

使用Kryo的注意事项

1、优化缓存大小

如果注册的要序列化的自定义的类型,本身特别大,比如包含了超过100个field。那么就会导致要序列化的对象过大。此时就需要对Kryo本身进行优化。因为Kryo内部的缓存可能不够存放那么大的class对象。此时就需要调用SparkConf.set()方法,设置spark.kryoserializer.buffer.mb参数的值,将其调大。

默认情况下它的值是2,就是说最大能缓存2M的对象,然后进行序列化。可以在必要时将其调大。比如设置为10。

2、预先注册自定义类型

虽然不注册自定义类型,Kryo类库也能正常工作,但是那样的话,对于它要序列化的每个对象,都会保存一份它的全限定类名。此时反而会耗费大量内存。因此通常都建议预先注册号要序列化的自定义的类。

持久化

如果有RDD被多次使用则使用持久化机制,如果是迭代运算多次用到一个RDD的话可以使用Checkpoint。有的其它的临时数据也可以进行序列化之后再持久化。类似于这样的语法RDD.persist(StorageLevel.MEMORY_ONLY_SER)

 

JVM在Spark中的调优

如果在持久化RDD的时候,持久化了大量的数据,那么Java虚拟机的垃圾回收就可能成为一个性能瓶颈。因为Java虚拟机会定期进行垃圾回收,此时就会追踪所有的java对象,并且在垃圾回收时,找到那些已经不在使用的对象,然后清理旧的对象,来给新的对象腾出内存空间。垃圾回收的性能开销,是跟内存中的对象的数量,成正相关的。所以,对于垃圾回收的性能问题,首先要做的就是,使用更高效的数据结构,比如array和string;其次就是在持久化rdd时,使用序列化的持久化级别,而且用Kryo序列化类库,这样,每个partition就只是一个对象——一个字节数组。我们可以对垃圾回收进行监测,包括多久进行一次垃圾回收,以及每次垃圾回收耗费的时间。只要在spark-submit脚本中,增加一个配置即可,--conf "spark.executor.extraJavaOptions=-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps"。

但是要记住,这里虽然会打印出Java虚拟机的垃圾回收的相关信息,但是是输出到了worker上的日志中,而不是driver的日志中。但是这种方式也只是一种,其实也完全可以通过SparkUI(4040端口)来观察每个stage的垃圾回收的情况。

优化Executor内存比例

在Executor中,内存可以大致划分为两个部分。一部分用来存储RDD的数据,另一部分会承载Task以及其相应的对象。而对象部分占用空间越多就越可能发生GC,所以我们要调大Task部分所分得的内存比例。默认情况下,Spark使用每个executor 60%的内存空间来缓存RDD,那么在task执行期间创建的对象,只有40%的内存空间来存放。如果发现垃圾回收频繁发生。那么就需要对那个比例进行调优,使用new SparkConf().set("spark.storage.memoryFraction", "0.5")即可(该比例代表存储RDD数据所占的比例,有效的降低该比例就能增大Java对象所占用的空间),可以将RDD缓存占用空间的比例降低,从而给更多的空间让task创建的对象进行使用。对于RDD持久化,完全可以使用Kryo序列化,加上降低其executor内存占比的方式,来减少其内存消耗。给task提供更多的内存,从而避免task的执行频繁触发垃圾回收。

 

减少FullGC

FullGC是用来清理整个堆空间的,包括新生代老年代。所以FullGC会造成很大的开销所以要避免FullGC。直接造成FullGC的原因就是老年代的内存不足,主要是有大的对象又放了进来或者是由于Survivor区域太小导致一些临时的对象放了进来。这时我们可以适当地增大Survivor区、老年代的大小。增大Eden区增大Survivor在Eden的占比。

小节

实务上,根据经验来看,对于垃圾回收的调优,调节executor内存的比例就可以了。因为jvm的调优是非常复杂和敏感的。除非是,真的到了万不得已的地方,而且自己本身对jvm相关的技术很了解,那么此时进行eden区域的调优是可以的。

 

提高并行度

实际上Spark集群的资源并不一定会被充分利用到,所以要尽量设置合理的并行度,来充分地利用集群的资源。才能充分提高Spark应用程序的性能。Spark会自动设置以文件作为输入源的RDD的并行度,依据其大小,比如HDFS,就会给每一个block创建一个partition,也依据这个设置并行度。对于reduceByKey等会发生shuffle的操作,就使用并行度最大的父RDD的并行度即可。

可以手动使用textFile()、parallelize()等方法的第二个参数来设置并行度;也可以使用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")来设置合理的并行度,从而充分利用资源。

广播共享数据

如果你的算子函数中,使用到了特别大的数据,那么,这个时候,推荐将该数据进行广播。这样的话,就不至于将一个大数据拷贝到每一个task上去。而是给每个节点拷贝一份,然后节点上的task共享该数据。这样的话,就可以减少大数据在节点上的内存消耗。并且可以减少数据到节点的网络传输消耗。

 

数据本地化

数据本地化对于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上。

实务上可以设置参数,spark.locality系列参数,来调节Spark等待task可以进行数据本地化的时间。spark.locality.wait(3000毫秒,这个时间可以让task等待更长的时间去获得更高的运行优先级级别)、spark.locality.wait.node、spark.locality.wait.process、spark.locality.wait.rack。

 

ReduceByKey VS GroupByKey ?

GroupByKey

ReduceByKey 

ReduceByKey会在Map端进行预聚合大大减少了数据量也就大大减少了网络的传输。

 

Shuffle性能优化

consolidate机制

想要明白Shuffle的优化就必须明白Shuffle的具体流程(可以参考的文章),通过Shuffle我们可以得知开启consolidate机制可以减少Map端分桶的文件数。为什么少创建文件会提高效率,首先创建文件也是会付出成本,其次我们可以减少读取文件的数量进而减少随机IO的操作。

优化之后

对应到参数spark.shuffle.consolidateFiles:是否开启shuffle block file的合并,默认为false。

我们可以将这个参数值设为true开启这个机制。

spark.reducer.maxSizeInFlight参数

这个参数是指reduce端拉取数据的大小,适当调大这个参数的值可以减少数据拉取的次数减少网络连接的开销。

spark.shuffle.file.buffer参数

map task的写磁盘缓存,默认32k。我们可以调大这个参数值去减少写磁盘的次数,减少IO。

spark.shuffle.io.maxRetries参数

拉取失败的最大重试次数,默认3次。调大参数就可以多试几次免得整个Application被kill掉。当然配合的参数还有spark.shuffle.io.retryWait:拉取失败的重试间隔,默认5s。

spark.shuffle.memoryFraction参数

reduce端的溢写比,调大这个溢写比可以充分利用缓存减少IO。

最后非常感谢石杉老师的讲解!由于版本比较旧,欢迎大家批评。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Elong_Hu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值