Spark调优(数据序列化和内存调优)

翻译自Spark官网文档:https://spark.apache.org/docs/2.1.0/tuning.html

前言

    由于大多数Spark计算的内存使用特性,集群中的任何资源都可能成为Spark计算程序中的瓶颈:CPU,网络带宽或是内存。大多数情况下,如果内存可以容纳数据量,那么瓶颈就会是网络带宽,但有时,用户也需要去做一点调优的工作,例如以序列化的格式存储RDD,来减少内存使用。本文主要关注两个主题:数据序列化,对网络性能和内存使用来说很重要,和内存调优。同时也会讨论一些较小的主题。

一、数据序列化

    序列化在分布式应用中起到很重要的作用。那些会让对象序列化过程缓慢,或是会消耗大量字节存储的序列化格式会大大降低计算速率。通常这会用户在优化Spark应用程序中的第一件事。Spark旨在在便利(允许您使用您的操作中的任何Java类型)和性能之间实现平衡。它提供了下面两种序列化库:

  1. Java serialization:Spark默认使用Java的ObjectOutputStream框架来序列化对象,可以对任何实现了java.io.Serializable的任何类进行序列化。用户也可以通过继承来实现更紧密的序列化性能控制。
  2. Kryo serialization:Spark也可以使用Kryo库(version 2)来实现更快的对象序列化。Kryo比Java序列化更快、数据格式更紧凑,但不支持所有的Serializable类型。用户如果希望使用Kryo来获取更好的性能,需要先去注册应用程序中会使用到的类。

    用户可以在初始化任务时通过设定SparkConf中的conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")来切换序列化框架为Kryo。这里的序列化配置不仅可以对worker节点之间的shuffle数据起作用,还可以在将RDD序列化到disk上时起作用。Kryo不是默认序列化选择的唯一原因它要求了用户的注册行为,但是我们建议在所有网络密集型应用程序中使用它。从Spark2.0.0开始,我们在传输简单类型或是字符串类型的Shuffle RDD时会默认使用Kryo序列化。

    Spark自动对许多在Twitter chill库中的AllScalaRegistrar被覆盖的常用的Scala类注册了Kryo。

    注册用户自身的类到kryo时,可以使用registerKryoClasses方法:

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

    Kryo的文档https://github.com/EsotericSoftware/kryo描述了更多进阶的注册选项,例如增加用户序列化代码等等。

    如果用户的对象很大,也需要去增加spark.kryoserializer.buffer配置项。这个值需要达到足以保存你将要序列化的最大的对象。

    最后,如果你不注册用户类,kryo也可以工作,但是它将会存储每个对象的全类名,会造成存储空间的浪费。

二、内存调优

    在对内存的使用进行调优时有三个考虑点:用户对象的内存使用量(用户可能希望整个数据集都保存在内存中),访问这些对象的开销和垃圾回收的开销(如果用户的对象周转率很高)。

    默认情况下,java对象的访问是很快的,但很容易就会消耗比字段中原始数据多2-5倍的空间。这是以下几个原因导致的:

  1. 每个不同的Java对象都有一个“object header”,这个头部大概会占用16bytes的空间并且会包含指向类的指针等信息。对于一个数据量很小的对象(例如一个Int对象),它会比数据占用的空间更大。
  2. Java字符串比原始字符串数据多了大约40个字节的开销(因为它们是以Chars数据的形式存储的,并且保存了一些例如length的额外信息),并且由于字符串内部的UTF-16编码,会将它存储为两个bytes。所以一个有10个character的字符串会很容易消耗60bytes。
  3. 常用的集合类,例如HashMap和LinkedList,使用链式数据结构,它对于每个entry(例如Map.Entry)会有一个"wrapper"对象。这个对象不仅包含头部信息,还包含了一个指向列表中下一个对象的指针(通常会占用8bytes)。
  4. 原始类型的集合通常将它们存储为“boxed”对象,如java .lang. integer

    本章会以Spark的内存管理机制的概述开始,然后讨论用户能在应用程序中采用的更有效的内存策略。特别地,我们还会讨论如何确定你的对象的内存使用量,以及如何通过改变数据结构或是在序列化格式中进行排序来对内存使用进行改进。最后我们会讨论Spark的内存调优和java的垃圾回收器。

2.1 内存管理概述

    Spark的内存使用基本上可以分为两大类:执行内存和存储内存。执行内存指的是在shuffle,join,和aggregation计算中使用的内存,存储内存指的是集群中缓存和传播内部数据使用的内存。在Spark中,执行和存储共享一个统一的区域M。当没有执行内存使用时,存储可以获得全部的可用内存,反之亦然。执行在必要的时候可能会驱逐内存,但只有在总存储内存使用量地域某个阈值R时才会触发。用另一句话来说,R描述在统一内存M中一定不会被驱逐的缓存block子集。由于实现的复杂性,存储不会进行内存驱逐。

    这种设计方案确保了几个令人满意的特性。首先,不使用缓存的应用可以使用全部内存来用于执行,从而消除不必要的磁盘溢出。其次,使用缓存的应用程序可以保留最小的不受驱逐的数据库存储空间R。最后,这种方法为各种工作负载提供了合理的开箱即用性能,不需要用户了解内存如何内部划分的专门知识。

    尽管有两个相关的配置,但是通常用户不需要对它们进行调整,因为默认值适用于大多数工作负载:

  1. spark.memory.fraction 代表整体JVM堆内存中M的百分比(默认0.6)。剩余的空间(40%)是为用户数据结构、Spark内部metadata预留的,并在稀疏使用和异常大记录的情况下避免OOM错误。
  2. spark.memory.storageFraction 代表M中R的百分比(默认0.5)。R是M中提供给缓存数据块避免受到执行驱逐的存储空间。

    spark.memory.fraction的值应该设置为可以适配JVM的老年代或终身代的使用。具体可以参考下面的GC章节。

2.2 内存消耗确定

    评估数据集所需的内存消耗的最好方法是创建一个RDD,放到内存里,并且通过web UI来查看存储使用量。这个页面会告诉你这个RDD占用了多少内存。

    估算某一个特定对象的内存消耗,可以使用SizeEstimatorestimate方法,这对于尝试不同的数据布局来减少内存使用,以及确定一个广播变量将占用每个执行器堆的空间量是很有用的。

2.3 数据结构调优

    减少内存消耗的首选方法是避免使用会增加开销的java特性,例如基于指针的数据结构和包装器对象。下面是集中解决方法:

  1. 将数据结构设计为更倾向于数组结构基本类型,而不是标准的Java或是Scala集合类(例如. HashMap)。fastutil库提供了与java标准库兼容的原始类型的集合。
  2. 尽可能避免包含需要小对象和指针的嵌套结构
  3. 考虑使用数字ID或是枚举对象而不是字符串key
  4. 如果你的RAM小于32GB,设置JVM参数 -XX:+UseCompressedOops 来让指针变为4个字节而不是8个字节。可以将这个配置加载spark-env.sh中

2.4 序列化RDD存储

    当尽管进行了调优,但你的对象仍然太大,无法有效存储时,一个更简单的方法是使用序列化的格式来存储它们以此来减少内存的使用,使用RDD persistance API来设置序列化的存储级别,例如MEMORY_ONLY_SER。Spark将RDD的每一个分区作为一个大的字节数组进行存储。以序列化格式存储数据的唯一缺点是访问速度较慢,因为不得不在使用中反序列化每一个对象。如果您想以序列化的形式缓存数据,那么我们强烈建议使用Kryo,因为它比Java序列化(当然也要比原始Java对象)小得多。

2.5 垃圾回收调优

    当你的程序中存储的RDD有大量的替换和变更时,JVM垃圾回收可能会造成问题。它在只读取一次RDD并在其上运行许多操作的程序中通常不会造成问题。当Java需要将旧对象驱逐出去来为新对象腾出空间时,它需要跟踪所有的Java对象来找到未引用的对象。这里需要记住的要点是,垃圾收集的成本与Java对象的数量成正比,因此使用较少对象的数据结构(例如使用int的数组而不是LinkedList)会极大地减少消耗。一个更好的方法是以序列化的形式持久化对象,如上所述:每个RDD的分区只会有一个对象(一个字节数组)。在尝试其他技术之前,首先要尝试的是使用序列化的缓存。

    由于任务的工作内存(运行任务所需的空间量)和在节点上缓存的RDDs之间的干扰, GC也可能是一个问题。我们将讨论如何控制分配给RDD缓存的空间以减轻这个问题。

2.5.1 测量GC的影响

    GC调优的第一步是收集GC发生频率和GC时间的统计。可以通过增加 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 的Java选项来实现。http://spark.apache.org/docs/latest/configuration.html#Dynamically-Loading-Spark-Properties中详细描述了将Java参数传递给Spark Job的方法。下次Spark应用程序运行时,就可以看到Woker节点的log会打印出GC信息。注意这些log是在集群中的workder节点,而不是driver程序中。

2.5.2 GC调优

    为了进一步优化垃圾收集,我们首先需要了解JVM中关于内存管理的一些基本信息:

  1. Java对内存被分为两个区域,新生代和老年代。新生代是为了保存寿命较短的对象,而老年代是为了保持寿命更长的对象。
  2. 新生代被进一步划分为三个区域: Eden,Survivor1,Survivor2
  3. 垃圾收集过程的简化描述:当Eden区使用占满时,一个minor GC会在Eden中发生,仍然存活的对象会从Eden和Survivor1区域中复制到Survivor2。如果一个对象存活的时间够久或是Survivor2区域空间占满时,它会移动到老年代。最后当老年空间接近占满时,会触发full GC。

    Spark中的GC调优的目的是为了确保只有长期存在RDD会存储在老年代中,新生代有足够大的空间来存储短期对象。这有助于在任务执行期间避免收集临时对象造成的full GC。下面是一些可用步骤:

  1. 通过收集GC状态来检查是否有太多GC。如果在一个任务完成之前触发了好几次full GC,意味着任务执行的可用内存不足。
  2. 如果有许多minor GC但是没有太多major GC,可以为Eden分配更多内存。可以通过估计任务的来村来设置Eden的大小。如果Eden的大小被设定为E,可以通过-Xmn=4/3*E来设置新生代的大小。(4 / 3的比例是为了Survivor使用的空间)
  3. 在打印出来的GC状态中,如果老年代接近占满,可以通过减低spark.memory.fraction来减少用于缓存的内存。缓存较少的队相比减慢任务执行速率要好。另外,也可以考虑减少新生代的大小。这意味着降低-Xmn的设置。或者尝试获取JVM的NewRatio参数,许多JVM默认设置为2,意味着老年代占据了2/3的堆内存。它应该足够大,一直未这个比例超过了spark.memory.fraction、
  4. 通过设置-XX:+UseG1GC来使用G1GC垃圾回收器。在某些情况,垃圾收集是一个瓶颈,它可以提高性能。注意,在堆内存够大时,需要通过-XX:G1HeapRegionSize来增大G1区域大小。
  5. 如果你的任务是从HDFS中读取数据,可以使用从HDFS读取的数据块的大小来估计任务所使用的内存数量。注意,解压缩块的大小通常是块大小的2-3倍,因此,如果我们希望获得3-4个任务空间,而HDFS的块大小是128MB,我们可以估计Eden的大小为4*3*128MB。
  6. 更改设置后持续监视GC的频率和时间

    我们的经验表明,GC调优的效果取决于您的应用程序和可用内存的数量。在网上有更多的调优选项,管理频繁的GC发生的频率可以帮助减少开销。

    执行器的GC调整标志可以通过设置作业配置中的"spark.executor.extraJavaOptions"来指定

三、其他

3.1 并行级别

    除非每一个操作的并行度都设置的足够高,要不然集群不会被充分利用。Spark自动根据文件的大小设定了运行在其上的map任务的数量(也可以通过SparkContext.textFile参数来控制),并且对于分布式的reduce操作,例如groupBykey和reduceByKey,它会使用父RDD中最大的分区数量。你可以将并行度作为一个次级参数床底,或是设置在配置文件spark.default.parallelism来改变默认配置。通常情况下,我们推荐为集群中的每个CPU分配2-3个任务。

3.2 Reduce任务的内存使用

    有些时候,你会因为task中的数据集,例如groupByKey,太大而造成OutOfMemoryError,而不是RDD和内存不匹配。Spark的shuffle操作(sortByKey,groupByKey,reduceByKey,join等等)会在每个任务中创建一个hash table来执行grouping操作,这个操作经常会很大。最简单的处理方案是增加并行度,让每个任务获取到的数据集更小。Spark对于短于200ms的任务执行的很好,因为它在多个任务中重用一个executor JVM,任务的启动成本很低,因此,你可以安全地将并行级别增加到您的集群中的核心数量。

3.3 广播大变量

    使用SparkContext中的广播特性,你可以极大地减少序列化任务的大小,和集群中的启动任务开销。如果你的任务用到了driver中的一个大的对象(例如一个static lookup table),可以考虑将它变为广播变量。Spark将每个任务的序列化大小打印在主服务器上,因此您可以查看它来决定您的任务是否太大;一般来说,大于20kb的任务很可能是值得优化的

3.4 数据本地性

    数据本地性对于Spark任务的性能有很大的影响。如果数据和操作的代码在一起,那么计算往往很快。但是由于代码和数据是分离开的,它们中总会有一方要向另一方传递。通常,将序列化的代码从一个地方发送到另一个地方比传输数据块要快,因为代码的大小比数据要小得多。Spark构建了它围绕数据局部性原则的调度。

    数据本地性是数据和处理它的代码之间的距离。下面有基于数据当前维值的几种本地性设置。通过选取最短距离来达成最快的处理速度:

  1. PROCESS_LOCAL 数据在运行代码的同一个JVM中。这是最优选择
  2. NODE_LOCAL 数据在同一个节点上。例如可能在同一个节点上的HDFS上,或是在同一个节点上的另一个处理器中。这比PROCESS_LOCAL稍微慢一点,因为这涉及到进程间的数据通信
  3. NO_PREF 数据可以从任何地方同样快速地访问,并且没有本地偏好
  4. RACK_LOCAL 数据位于相同的服务器机架上。数据在同一个机架上的另一台服务器上,所以需要通过网络发送,通常需要通过一个网关
  5. ANY 数据是在网络上的其他地方,而不是在同一个机架上

    Spark希望把所有的任务都安排在最合适的位置上,但这并不会总是可行的。在没有任何空闲执行机的情况下,Spark会切换到较低的局部性。有两种选择:a. 在同一个服务器上等待CPU空闲,再提交任务 b. 立即在一个其他执行机上开始执行任务,并将数据移动过去

    Spark通常情况下会等待CPU空闲。一旦等待时间超时,它会开始移动数据到较远的空闲CPU上。每个级别之间的等待超时可以单独配置,也可以在一个参数中组合在一起。具体配置参考spark.locality。默认配置通常效果较好,可以根据任务特性来修改这些配置。

四、总结

    本文是针对Spark应用程序调优中需要注意的主要问题的一个简单指南,主要关注数据序列化和内存调优。对大多数应用来说,切换到Kryo序列化并persist序列化数据可以解决大多数性能问题。


展开阅读全文

没有更多推荐了,返回首页