Spark 如何调优

根据官方顺序结合自己的实践,介绍一下Spark的调优的几个部分
目录如下:

第一部分 数据序列化

第二部分 内存调优

  • 内存管理概览
  • 决定内存的使用
  • 数据结构调优
  • 序列化RDD存储
  • 垃圾回收调优

第三部分 其他考虑因素

  • 并行度
  • Reduce 任务的并行度使用
  • 广播大变量
  • 数据本地化

数据倾斜问题处理

  • 倾斜可能的现象 原因
  • 倾斜的解决方案

下面进行展开讨论

数据序列化

由于大部分Spark计算在内存的特性,Spark的程序的瓶颈可能是集群资源,cpu,带宽和内存中的任意一种。很常见的是,如果内存大小合适,带宽就成为瓶颈,但是有时候,你仍然需要做一些调优,比如用序列化的形式存储RDD以减少内存的使用。本文将会包含两个主题,数据序列化,它对于好的网络情况是至关重要的,并且可以减少内存的使用。还有就是内存的调优。除此之外,我们也简单描述了一些小的点。
数据序列化:
数据序列化在任何分布式应用的性能中都扮演着重要的角色。序列化慢,或者使用大量字节的的格式,都将会极大的拖慢计算。通常,这将是你尝试优化一个Spark应用第一件要做的事。Spark目标就是达到便利性和性能的一个平衡点。便利性就是可以让你再你的操作中使用任何Java的类型。这里提供了两种序列化的方式。

  • Java serialization:默认情况下,Spark对象序列化使用Java 的ObjectOutoutStream框架,任何你创建的实现了java.io.Serializable的类都可以生效。你也可以通过扩展java.io.Externalizable.来控制序列化的性能。Java的序列化非常灵活,但是非常的慢,对于大部分的类会导致序列化的空间很大。
  • Kryo serialization: Spark 同样可以使用Kryo (version 2)更快的序列化对象。Kryo相比java的序列化更快,更多压缩比例,通常比java高10倍以上,但是不支持所有的Serializable 类型,并且为了达到最好的性能,需要你把用到的class进行注册。
    你可以通过在初始化你的job的时候在SparkConf中 调用conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") 开启Kryo。这个配置使得序列化不仅仅作用于work 节点之间进行shuffle而且作用于序列化RDD到磁盘中。Kryo不是默认配置的唯一原因就是自定义的注册要求。但是我们强烈建议在任何网络密集型的应用中尝试使用Kryo。从Spark2.0.0开始,当对简单数据类型 ,以及简单数据类型的数组和String类型 RDD 进行Shuffle的时候内部使用kryo进行序列化。
    Spark 自动把许多经常用到的Scala核心的类加入到Kryo的序列化器中。
    使用registerKryoClasses 注册你自定义的类
val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)

Kryo documentation
讲述了跟多高级的注册选项,比如增加自定义的序列化代码。
如果你的对象很大,你也可以调大the spark.kryoserializer.buffe config 这个值需要足够大以能够容纳你将要序列化的最大的对象。
最后,如果你不注册自定义的类,Kryo 也会工作,但是将会存储每个对象的全类名,这样也比较浪费空间。

内存调优

在优化内存使用有三个需要考虑的点: 对象使用的内存的大小(你可能希望所有的数据都可以存储在内存中),访问这些对象的消耗和垃圾回收的过载(如果你有大量对象)
默认情况下,Java的对象访问很快,但是相比它包含的原始的数据会轻易的占用2-5倍的空间。原因有几个如下:

  • 不同的java对象有一个 对象头(object header),这个对象头占用16bytes并且包含了一些诸如指向这个class指针的信息。对于一个只有小数据的(比如 只有一个int属性的)对象,这个对象头就比数据大多了。
  • Java Strings 相比原始的string 数据要多40个字节(因为他存储了字符数组以及保存了一些其他的数据 比如长度),并且每个字符由于使用内部的UTF-16编码会占用两个字节。因而一个10个字符的string可以很容易的占用60个字节。
  • 通用的集合类,比如HashMap和LinkedList,使用了链式的数据结构,而链式数据结构对么一个Entry都会有一个wrapper的对象。这个对象不仅仅有一个header,而且有一个8字节的指向下一个对象的的指针。
  • 原始数据类型的集合类通常存储的时候会进行装箱操作,比如 java.lang.Integer.
    本节将会开始大体较少一下Spark中内存的管理,然后讨论用户在自己的应用中可以采用的提高内存使用效率的具体的策略。
内存管理概览 Memory Management Overview

Spark中内存的使用主要有两块:执行和存储。执行内存是指用于在shuffle,joins,sorts and aggregation中用于计算的内存。而存储内存是指用于在集群中缓存和传播内部数据的内存。在Spark中,执行内存和存储内存共享一块区域(M)。如果执行内存没有使用的话,存储的可以获得所有的内存,反之亦然。执行内存可以抢占存储内存如果必要的话,但是只能到达总的存储内存降低到一个阈值(R)。换句话说,R描述了M内的一个子区域,这个区域的保存的数据永远不会被抢占。由于实现的复杂性,存储内存不能抢占执行内存。

这个设计确保了一些期望的特征。首先,不需要cache的应用可以使用全部的空间进行执行,从而避免了不必要的磁盘溢写(disk spill)。第二,使用cache的应用可以保有一个小的存储空间(R)(这里的数据可以防止被抢占的时候清除)。最后,这个方法提供了一个黑盒的功能 从而不需要用户非常精通内部的内存是如何划分的。
尽管这又两个相关的配置,但是一般的用户不需要调整这两个参数,因为默认值可以满足大部分的工作情况。

  • spark.memory.fraction M的大小,也就是所占Java 堆(300M)内存的比例, 默认是0.6。剩下的空间(40%)用于用户数据结构和spark内部的一些metadata以及针对于少有大记录的导致的OOM的安全守卫等。
  • spark.memory.storageFraction 表示 R区域的大小,所占M的比例(默认是0.5),R是M内存除了不会被执行内存抢占的内存空间。

查看内存的消耗

最好的查看一个dataset消耗内存大小的办法就是创建一个RDD,然后放入cache,然后到web ui上看Storage。ui上会告诉你这个RDD占用了多少内存。
为了估计一个具体对象内存占用可以使用SizeEstimator’s estimate 方法。对于不同的数据结构这个方法很有效,同样可以查看一个广播变量会在每个executor的堆中占用多少内存。

优化数据结构

第一个减少内存使用的方法就是避免Java的特征,比如header,造数据结构的指针,以及包装对象。这有几个方法:

  1. 将你的数据结构尽量使用对象的数组以及原始类型,而不是标准的java和scala的集合。 fastutil库提供了便利的集合类,并且和java的标准库相兼容。
  2. 尽可能的避免小对象嵌套的数据结构
  3. 考虑使用数字型id或者枚举类型而不是String类型作为key。
  4. 如果你的内存小于32GB,设置JVM参数 -XX:+UseCompressedOops 从而是指针是4个字节而不是8个字节。你可以在spark-env.sh中进行配置。

序列化RDD的存储

当尽管进行了这样的优化,你的对象人啊果然太大了不能有效的存储,一个更简单的减少内存使用的方式就是用序列化的方式存储。使用徐乐华的存储级别在RDD persistence API,比如 MEMORY_ONLY_SER. Spark将会存储每个RDD partition成一个大的字节数组。唯一的缺点就是访问会变慢,因为必须反序列化这些对象。我们强烈建议使用 Kryo 如果你需要以序列化的方式cache这些数据。因为它相比java的序列化方式使用的空间更小。

GC调优

如果你的程序中存储一个很大的RDD的话,垃圾回收将会是一个很大的问题。它不是一个一次读入RDD然后进行很多操作的问题。当Java需要为新的对象腾空间而删除老的对象的时候,它需要跟踪所有java对象,找到未被使用的对象。这里需要记住的点就是垃圾回收的成本和java对象的数量是成比例的。因此使用包含更少对象的数据结构会极大降低这种成本。一个比较好的方法就是用序列化的方式格式化对象,这样对于每一个rdd的partition就会只有一个对象(一个字节数组)。在尝试其他技术方法之前,针对于gc问题,第一个需要尝试的方法就是使用 serialized caching
当每个节点tasks的工作内存和RDD存储内存相互干扰的时候也会碰到GC的问题。我们将会讨论如何控制分配给RDDcache的空间从而避免这种情况。

评估GC的影响

GC调优的第一步就是 如何收集gc发生频率以及耗时的数据。可以通过添加Java 选项 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 实现。(向Spark传入java参数可以参考configuration guide )。下一次job执行的时候,你将会在workder打印的日志里看到每次垃圾回收的信息。注意,这些日志是在集群的worker节点的 stdout 日志里,而不是你的driver程序里。

高级GC调优

为了进一步优化垃圾回收,我们首先需要知道JVM内存管理的一些基础的信息:

  • Java 的堆空间分成年轻代和老年代两个区域。年轻代用于保存生命短的对象,而老年代用于保存长生命周期的对象。
  • 年轻代又进一步划分为,三个区域 Eden,Survivor1,Survivor2
  • 垃圾回收过程的一个简单描述: 当Eden满了的时候,eden上会运行一个minor的gc,eden和survivor1上存活的对象将会被拷贝到survivor2上。两个survivorh会交换。如果一个对象已经足够老了,或者Survivor2也满了,它将会被移动到老年代。最后老年代也快满了的时候,会触发一个Full GC。
    Spark中GC调优的目标就是保证长期存活的RDD存储在老年代而年轻代的大小能够存储短生命周期的对象。这样可以避免因为收集任务执行过程中产生的临时对象的Full GC.下面的步骤将会很有用。
  • 通过收集GC的数据判断是否有太多垃圾回收。如果在任务执行结束前发生了多次full GC,说明执行任务的内存不够用。
  • 如果发现很多的minor GC,但是full GC 并不多,分配跟多的Eden将会有帮助。你可以分配给Eden高于task执行所需要的内存的大小。如果Eden大小是E,你可以设置年轻代的大小,通过参数 -Xmn=4/3*E,按4/3比例的增大对于survivor空间的大小同样适用。
  • 如果GC 数据打印出来,如果老年代基本满了,可以减少用户cache的内存,通过降低 spark.memory.fraction 这个参数。减少缓存的对象比减慢task的执行会更好。另外,也可以考虑减少年轻代的大小。也就是降低 你上面设置的-Xmn 。如果不这么做的话,可以尝试修改JVM的NewRatio 参数,许多JVM的默认值是2,也就是说,老年代占用了堆内存的2/3。这就足够打了以至于这个比例超过了spark.memory.fraction.
  • 尝试使用 G1 垃圾回收算法 通过配置参数 -XX:+UseG1GC。在GC成为瓶颈的很多场景下,它可以提高性能。需要注意的是,如果是用了比较大的堆内存的话,增加G1 region size (-XX:G1HeapRegionSize) 非常重要。
  • 举个例子,如果你的任务从HDFS读取数据,任务使用的内存的大小,可以通过从HDFS读取的数据大小评估出来。需要注意的是解压缩的数据一般是block大小的2-3倍。因此如果希望task有足够的空间执行,对于一个128m的block,评估需要一个 43128MB的空间。
  • 变更配置后观察垃圾回收的频率。

其他的 一些考虑

并行度

除非设置操作的的并行度足够高,否则集群不能完全被利用。Spark能够根据文件的大小自动的设置map 任务的数量(尽管你也可以通过可选的参数来控制。另外对于分布式的reduce操作,比如 groupByKey 和reduceByKey,将会使用最大的父RDD的分区数。你可以通过第二个参数传入并行度(可以参考 spark.PairRDDFunctions)或者通过spark.default.parallelism 配置默认值。通常我们推荐每个CPU 2-3个任务。

Reduce 任务内存的使用

很多时候OutOfMemoryError不是因为RDD在内存中存不下了,而是因为你任务中的某一个工作集,比如groupByKey的一个任务,太大了。Shuffler 操作为了进行group会为每一个task创建一个hashtable,通常这个table都会很大。最简单的办法就是增加并行度,从而每个task的输入集合就会更小。Spark能够有效的支持最短200msde task。因为可以在多个task之间共享executor的JVM,因此启动任务的成本很低,因此你可以安全的增加并行度并超过你集群的核的数量。

广播变量

使用SparkContext 中的 broadcast functionality 可以极大的减少每个序列化task的大小和启动一个job的成本。如果你的任务使用了来自driver程序中的一个大的对象,可以考虑将它转化为一个广播变量。Spark会在master上打印出每个task的大小,因此你可以看下这个,决定你的task是否过大,一般情况下,任务超过20kb很值得优化。(这块有点不理解)

数据的本地化

数据的本地化对于job性能的影响是巨大的。如果数据和运行它的代码在一起那么执行起来应该会很快。但是如果代码合数据是分开的,其中一个会被移动到另一个上。一般移动的序列化的代码会比移动数据块更多,因为代码比数据要小的多。Spark 创建调度的时候就是基于数据本地化的原则。
数据本地化程度就是数据有多靠近执行它的代码。根据数据的当前位置,有几个本地化的级别。以下按照顺序从近到远:

  • PROCESS_LOCAL 数据和执行的代码在同一个JVM内。这是最好的本地化程度。
  • NODE_LOCAL 代码和数据在同一个节点上。举个例子就是 数据在同一个节点上的HDFS,或者同一个节点的executor。这个比PROCESS_LOCAL要慢,因为需要在不同的进程间传输数据。
  • NO_PREF 数据从哪里访问都一样快,不需要位置优先。比如说SparkSQL读取MySql中的数据
  • RACK_LOCAL 数据在相同机架的服务器上,数据在同一个机架的不同服务器上上,一般需要通过同一个交换机传输。
    -ANY 跨机架,数据在非同一机架的网络上,速度最慢
    Spark 会按照最好的本地化程度优先调度所有的task,但是通常不可能。如果任何空闲的executor上没有未被处理的数据,Spark将会降低本地化程度。这里有两个选项: a 等待直到数据所在的节点的cpu空闲,在启动task。 b 立即启动一个更远的新的任务,这就需要移动数据。
    Spark通常的做法就是会等待一会希望繁忙的cpu会空闲下来。一旦超时了,就会把数据移动到更远的空闲的cpu上。每个level的超时时间可以单独配置,可以一个参数配置。可以查看 configuration page
    spark.locality 了解详情。如果你的任务执行很久并且本地化程度很低,那么你可以增加这几个配置,但是一般情况下默认值是ok的。

总结

这是一个简单的指南,指出了你在运行一个Spark 应用的时候需要的关注点。最重要的就是数据的序列化和内存的调优。对于大部分的程序而言,使用Kryo序列化和以序列化的形式持久化数据将会解决大部分的性能问题。

你可能感兴趣的:(Spark 如何调优)