Spark常用调优方法

来源:互联网 发布:淘宝全屏优惠券代码 编辑:程序博客网 时间:2024/04/29 19:57

大多数Spark任务的工作流程无非:从文件系统读取数据->在内存中计算(还包括shuffle数据)->写入文件这样的步骤。从流程上看其涉及到的因素有CPU(计算)、带宽(传输任务至Executor、Shuffle数据、广播数据等)、内存(存储RDD、缓存RDD等)、磁盘IO性能(读写文件),因此,整个集群中涉及到这些因素的方面都有可能会成为Spark任务的性能瓶颈。磁盘的读写基本只存在于任务的开始和结尾,其成为性能瓶颈的几率不大。通过序列化对象的方式可以使集群在多数情况下将其所需要计算的数据存储于内存中,此时,集群的带宽就有可能成为瓶颈。此文主要介绍两方面内容用于Spark调优:1、数据序列化,通过较优的数据序列化方式,可以使任务减少对网络带宽和内存的依赖;2、内存。

数据序列化优化

数据序列化在任何的分布式计算应用中都扮演者重要的角色。差的序列化方式一般表现为序列化和反序列化速度较慢、序列化后的内容很大,这无疑会严重影响分布式计算系统的性能,因此调优序列化方式往往是分布式任务调优的第一步。作为分布式计算应用的杰出代表,Spark也不例外,它默认向用户提供了两种序列化方式:
1. Java默认的序列化方式,Spark在默认情况下使用Java自带的序列化方式,这种方式的优势是其通用性-支持任意实现了Serializable接口的类的实例。当然你也可以实现Externalizable接口来更加细粒度的控制序列化的性能。但是通常情况下,这种序列化方式相比于后面我们要介绍的这种序列化方式要慢,且序列化结果较大。
2. Kryo序列化,Spark也可以使用序列化速度更快的Kryo方式来序列化数据,并且,这种方式的序列化结果通常更小(与Java序列化结果通常是10倍左右的关系)。其缺点是不支持序列化任意的Java类型,需要在程序中手动注册需要序列化的类型来达到其最优效果。
在Spark应用中,墙裂推荐使用Kryo序列化方式,要知道,从Spark 2.0.0以后,都默认使用Kryo来序列化Java中源生类型,源生类型的数组,String类型。Spark中默认不使用Kryo序列化复杂类的唯一原因是需要在程序中手动注册类。要改变Spark默认的序列化方式为Kryo只需要在初始化SparkConf时进行如下设定

conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")

这样,在Spark任务需要Shuffle数据或者cache数据时,都会使用Kryo序列化方式了。Spark应用其实已经通过Twitter chill包的AllScalaRegistrar类自动注册了Scala中常用的数据类型至Kryo,因此只需要通过如下方式注册自定义类至Kryo便可以放心使用Kryo了。

val conf = new SparkConf().setMaster(...).setAppName(...)conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))val sc = new SparkContext(conf)

如果在程序中所需要序列化的单个对象实例非常大,这时需要修改spark.kryoserializer.buffer参数,使其大到足矣容下程序中的最大的一个对象。上面提到了使用Kryo序列化方式需要在程序中手动注册,但其实这并不是必须的。如果不事先注册类至Kryo的话,其序列化的每一个此类的对象的结果中都会带有类的全限定名称,会浪费很多内存。验证代码如下

package recruit.scalar.com.elevenbee.work.bean.model.personclusterimport scala.beans.BeanPropertycase class Position(@BeanProperty var lng:Double=0, @BeanProperty var lat:Double=0){    //Auxiliary constructors    def this()=this(0,0)    override def toString = s"Position($lng, $lat)"}object Position{    def apply(position: Array[Double]):Position=Position(position(0),position(1))}
package recruit.scalar.com.elevenbee.Exercise.experimentimport java.io.{BufferedOutputStream, FileOutputStream}import com.esotericsoftware.kryo.Kryoimport com.esotericsoftware.kryo.io.Outputimport org.objenesis.strategy.StdInstantiatorStrategyimport org.scalatest.FunSuiteimport recruit.scalar.com.elevenbee.work.bean.model.personcluster.Positionclass TestKryo$Test extends FunSuite {    test("KryoTest"){        val position: Position = new Position(0.2,0.3)        val kryo: Kryo = new Kryo()        kryo.register(classOf[Position])//手动注册        kryo.setReferences(false)        kryo.setInstantiatorStrategy(new StdInstantiatorStrategy)        val output: Output = new Output()        val outPutStream: BufferedOutputStream = new BufferedOutputStream(new FileOutputStream("byte.bin"))        output.setOutputStream(outPutStream)        val bytes: Array[Byte] = new Array[Byte](10000)        output.setBuffer(bytes)        kryo.writeClassAndObject(output,position)        println(new String(bytes))        output.close()        outPutStream.close()    }}

如果有手动注册那一行,其打印结果如下

?�333333?ə�����```如果将其注释掉的话,其打印结果如下

 �recruit.scalar.com.elevenbee.work.bean.model.personcluster.Position?�333333?ə�����
“`
可以看到,如果不事先注册的话,其序列化内容会比原来大很多,原来在网上查资料的时候,还有人说注册类的时候一定要写全限定名,经过我测试,是没有必要的。写全限定名和直接写类名使用Kryo序列化出来的结果一样。

内存优化

优化集群内存主要会改善以下三个场景:1、存储所有对象所使用的内存;2、访问这些对象所消耗的时间;3、垃圾回收所消耗的时间。通常情况下,访问一个Java对象很快,但是,存储一个对象所需要的空间是其内部所有域实际所占空间的2-5倍。究其原因主要有一下4点:
1. 为了表示一个对象所属的类,每一个对象都有一个16b的header指向其对应的class,如果一个对象本身域很少的话,其header所占空间可能比其本身所存储的数据更多。
2. Java使用UTF-16编码,因此String中的每一个char占两个字节,但是每一个String对象需要维护额外的40b来存储诸如(实际用于存储char的数组,string的长度等信息)。因此一个10个char的String,本身内容可能只占20b,但是就要占用60b来维护整个String对象的信息。
3. 通用的集合类如HashMap或者LinkedList会使用链表数据结构来保存数据信息。使用这种集合类来保存数据信息会消耗很多内存,因为其每一个节点除了要保存本身数据外,还要维护一个对象的header,以及一个或者两个pointer指向其后继和前驱。
4. 集合类在处理Java的源生类型时,会将其包装成其封装类,这会将一个primitive类型变成了对象类型,额外的消耗空间。
本小节先做Spark对内存使用的概观,然后详细的讨论在应用中如何使用某一种特定的策略来提高任务执行时内存使用的性能。并且会详细讲解如何使用某种具体的策略来优化整个Spark任务的执行效率。特別的,将会介绍程序中的对象怎样使用内存,如何提高其使用效率-通过改变持有对象的方式或者将持有的对象序列化村粗起来。最后会介绍如何优化Spark的缓存数据大小和JVM的垃圾收集。

Spark内存使用概观

Spark中,集群机器的内存主要用来做两件事:程序执行和存储。执行程序消耗的内存是指用来做join,sorts,aggregation,shuffle等操作所使用的内存。而存储消耗的内存主要是指Spark用来cache和广播数据时所使用的内存。在Spark中程序执行所使用的内存和存储所使用的内存并没有物理隔离,他们使用同一片内存(假设为M)。也就是说,在另外一方没有使用内存的时候,己方可以独占整个内存来使用。但是如果大家都在使用内存,并且内存不够的时候呢?这个时候执行内存拥有更高的优先级,当剩余的内存大小小于某一个阈值(假设为R)的时候,如果执行内存还在增大的话,存储所使用的内存将会被清除出内存区域。
这种设计会使Spark程序具有以下特点:1、拆箱机用,在不需要有丰富的内存调优经验来划分执行内存和存储内存各占多少的情况下,可以使Spark程序的性能不是特别差。2、没有做RDD缓存的程序使用全部的内存区域,从而避免了不必要的文件溢写。3、使用了RDD缓存的程序可以保留R大小的空闲区域以防被执行内存赶出内存区域。
尽管Spark提供了两个参数来对R和M进行调节,但是一般情况下不需要人员去手动调节。
1. spark.memory.fraction 参数代表的就是M区域占整个JVM heap区域的比例,默认为0.6。JVM剩下的40%的区域用来存储Spark的元数据、用户数据。虽然spark中单个的大对象很少,但是也有可能出现,保留的此部分区域也有防止这种大对象出现时发生OOM的作用。
2. spark.memory.storageFraction 代表的是R占M的比例,默认0.5。此参数代表了缓存数据不被赶出内存区域的最小内存值。

查看RDD所占内存情况

查看指定RDD所占用内存情况的最好办法就是,并将其放进spark的cache中,然后去SparkUI上看Storage Tab上它所占用内存的大小就好了。
如果只是为了查看某一个大实例所占用的内存大小,可以使用SizeEstimator类的estimate方法。这种方式特别适用于在测试使用各种数据结构来存储数据所使用内存大小的场景。如果要测试使用Broadcast广播变量所需要的内存时,也可以使用这种方法。

优化存储结构

优化内存使用的第一步就是使用合适的数据结构来存储数据,以避免Java使用自己的特性增加额外的内存使用(如int被包装成Integer这种)。通常避免使用的数据结构是基于指针的Java封装类型(LinkedList),因为它不但会将primitive类型变为封装类型,还要在基础数据上增加指针变量。节省内存的最佳实践一般如下:
1. 尽量使用数组、primitive类型来代替Java或者Scala的集合类型(ArrayList等)。fastutil包提供了大量的工具类来兼容primitive类型。
2. 尽量避免使用拥有大量小对象和引用的对象。
3. 在做聚合等操作时,考虑使用数字类型或者枚举类型的ID作为键值。
4. 如果集群中的机器只有32GB不到的RAM,给JVM设置-XX:+UseCompressedOops参数来使用4字节的指针而不是8字节。

序列化数据

如果使用改变数存储结构的方式后,持有的数据还是太大。或者就必须得使用Java或者Scala的集合类型。那么可以考虑使用数据序列化的方式,例如在RDD持久化的时候,考虑使用MEMORY_ONLY_SER级别的存储方式。这样在存储的时候,Spark会将每一个partition存储为一个大的字节数组。序列化存储的唯一缺点就是在访问的时候,需要实时反序列化,这会导Spark任务执行的速度稍微慢一点。墙裂建议在要使用序列化方式Cache数据的时候,使用Kryo的序列化方式。

JVM垃圾回收策略集调优

当Spark程序中存在大量对象实例生成的操作的时候,JVM的垃圾收集策略就有可能成为整个Spark任务的瓶颈。当JVM需要垃圾清理时,它将花费大量的时间来整理不再被使用的对象,以回收其内存。因此Spark任务中,可以说垃圾回收的花费正比于JVM中Java对象实例的个数。这也就是说,在实际应用中,通常更提倡使用含有较少Java对象的存储结构(如array相比于linkedList和ArrayList就要好很多)。另外一种更好的方法是将数据序列化起来,这样,RDD的每一个partition会被序列化成一个数组,这样每个partition就只有一个对象了,这种优化方式是首选。上面提到过,Spark的内存分为执行内存和存储内存,切他们没有物理隔离,因此就会相互产生影响(执行内存不够时会强制存储内存退出)。这样,JVM的垃圾收集将会更加复杂。记下来会讨论如何控制分配给RDD cache的内存来缓和这种复杂性。

衡量GC是否成为瓶颈

GC调优的第一步就是统计GC出现的频次及其消耗的时间,可以通过传参数-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 给Spark任务完成。当Spark任务开始运行时,一旦有GC出现,这些信息将会打印到worker的log中。注意,是worker中而不是driver。

高阶GC调优

在进一步讨论GC调优之前,应该先明白JVM中内存管理的几个概念:
1. Java的堆被划分为年轻代和老年代。年轻代用来存储寿命较短的对象实例,而老年代用以存储哪些寿命较长的对象。
2. 年轻代跟进一步的被分为Eden,Survivor1和Survivor
3. JVM GC的简要过程如下:当Eden区满了的时候,Minor GC开始,将Eden区和Survivor1上的活着的对象拷贝到Survivor2上。如果活着的对象中有年龄较长的或者Survivor2内存不够了情况下,此对象会被移动到老年代。当老年代接近满的时候,JVM开始full GC。
Spark任务中GC调优的目标是保证只有长时间或者的对象才进入老年代,并且年轻代有足够的容量来保存短寿命的对象。这样可以极大程度的减少Full GC的的频率。检查GC是否需要调优的最佳实践一般如下:
1. 通过gc日志查看gc的频率是否比较高。另外如果任务执行过程中有多次Full GC,这一般表名执行Spark任务的内存已经不够了。
2. 如果任务执行过程中有大量的Minor GC但是并没有很频繁的Major GC,这时给Eden区分配更大一点的内存通常会有所帮助。通常可以将Eden区域的大小设置成任务所需要内存的大小。JVM中没有直接指定Eden取大小的参数,假设Eden的大小确定了为E,那么你可以指定年轻代的大小为4/3*E。多出来的部分给Survivor区域使用。
3. 查看GC日志,如果老年代经常性的发生Full GC那么通过调小spark.memory.fraction参数来调小存储内存所占的比例进而使执行内存更多一点,毕竟与减慢整个Spark任务的执行速度比,减少cache的对象实例个数的重要性微乎其微。也可以看情况适当通过调小-Xmn来调小年轻代,或者改变JVM的NewRation参数来达到同样的目的。很多JVM默认的NewRation参数值为2,即老年代占整个堆的2/3。
4. 尝试使用G1垃圾回收器,此收集器优异的性能可以提高很多以垃圾回收为瓶颈的任务。需要注意的是,在Executor的堆很大的时候,通过-XX:G1HeapRegionSize参数来增加G1 region size。
5. 加入Spark任务从HDFS中读取数据,读取的数据所占内存空间的大小可以从读取数据所占HDFS空间大小来判定。通常HDFS中的文件都是压缩过的,解压后会是原来的2-3倍。
6. 监控通过以上参数调优后,垃圾回收的频率和时间有没有变小。
由以往的经验得知,GC调优是要根据具体的应用和使用可用的内存大小而定的。通常减小Full GC的频率可以使得Spark任务更有效率。更多JVM调优可参考JVM参数调优

其它

并行度

如果Spark任务的并行度不够的话,集群的资源并不会被充分的被利用。Spark任务默认使用所需要读取的文件的数量当做”map”的并行数(可使用SparkContext.textFile的第二个参数指定并行度),而对于groupByKey或者reduceByKey这种”reduce”任务,Spark则使用其所有直接父RDD中最大的那个分区数当做其并行度。你可以通过读取文件时增加额外的参数或者设置spark.default.parallelism来改变默认的并行数。最佳实践是每个CPU核上有2-3个task。

数据倾斜

有的时候,即使你集群的内存足够大,并且足矣容下所需要操作的数据,你还是会得到OOM。这通常是由于groupByKey等这种聚合操作中,某个键的值太多导致某一台Executor上的内存不够用了。这是因为Spark Shuffle类的操作(如sortByKey,groupByKey,reduceByKey,join等)通过构建大HashTable的方式来进行group。这种情况下可以通过增加程序的并行度,来减少分配到每一个task上的值的数量。Spark对于短任务(200ms执行时间)支持也特别好,因为它会在所有的task间复用同一个executor,每一个task 加载的开销会很小。在实际应用中可以尽可能大的调高程序的并行度。

广播大变量

还有一个程序调优的方法是使用SparkContext的broadcast功能来进行大变量的广播。如果你在算子操作中使用到了driver程序中的某个很大的实例(如一个List等),这时应该考虑将其转化成广播变量,就像这样val broadCastedCarNo: Broadcast[List[String]] = spark.sparkContext.broadcast(sampleCarNo.toList).Spark任务会将其所序列化的每一个task的大小等信息在driver上打印出来,所以,可以通过这些信息来评估,task是否过于大了。通常来将每一个task的大小不应该超过20KB,如果超过了就应该是用广播变量来进行优化了。

数据本地化程度

数据本地化程度的程度会对Spark任务的执行效率有很大影响。如果数据和使用它的代码在一起,那么Spark任务执行起来将会非常快。但是,一旦程序代码和其操作的数据没有在一起(分布在集群中不同的节点上),那么就要移动数据或者代码,使他们处于同一个环境内。一般来讲,由于代码比数据要小得多,移动代码比移动数据要快。Spark针对本地化程度有一条自己的调度方案。
在介绍Spark的调度方案之前,先看下数据本地化程度的定义和分级。数据本地化成都指的是程序和其要使用到的数据的远近关系。其分级如下(近->远)
1. PROCESS_LOCAL 数据和程序处于同一个JVM中,这是数据本地化的最高级别。
2. NODE_LOCAL 数据和程序处于同一个节点(机器)。例如HDFS通常和Spark共用一个集群,但是处于不同的executor中,由于数据要在不同的进程中传输,这比PROCESS_LOCAL要略微慢一点。
3. NO_PREF 数据在任何地方处理都一样。
4. RACK_LOCAL 数据和程序在同一个集群内,但是没有在同一个节点,因此要通过网络来传输数据。
5. ANY 数据可以在任意地方,有可能要通过互联网下载数据。
使用本地化程度最高级别效率肯定是最高的,但一般不现实。例如在空闲的executor上,已经没有未处理的数据,而有未处理数据的机器在忙于其它task的时候,Spark就会将本地化程度调低。这种情况下一般有两个选择:a)等到比较忙的机器空闲下来时,在其上启动一个处理未处理数据的task。b)在空闲的机器上立即启动一个新任务,但是要将为处理的数据传输到指定机器。
Spark应用的策略是,稍微等一段时间,看忙的机器是否有可能空闲下来。一旦等待的时间过了,它就会在空闲机器上处理新开一个task,并将数据传输至指定机器上进行处理。Spark所使用的本地化级别可参考Spark配置 。

原创粉丝点击