Spark性能调优官方指南

前言

由于大多数Spark计算基于内存的性质,群集中任何资源(CPU,网络带宽或内存)都可能成为Spark程序的性能瓶颈。通常,如果数据和内存合适,则瓶颈是网络带宽,但是有时,我们还需要进行一些调整,例如以序列化形式存储RDD,以减少内存使用量。本文将涵盖两个主要主题:数据序列化以及内存调优,数据序列化对于良好的网络性能至关重要,并且还可以减少内存使用,内存调优可以减少不必要的内存开销。我们还概述了几个较小的主题。

正文

1、数据序列化

序列化在任何分布式应用程序的性能中都起着重要作用。将对象序列化为慢速格式或占用大量字节的格式将大大减慢计算速度。通常,这是我们应该优化Spark应用程序的第一件事。Spark旨在在便利性(允许我们在操作中使用任何Java类型)和性能之间取得平衡。它提供了两个序列化库。

  • Java序列化:默认情况下,Spark使用Java的ObjectOutputStream框架对对象进行序列化,并且可以与用户创建的实现的任何类一起使用 java.io.Serializable。我们还可以通过扩展来更紧密地控制序列化的性能 java.io.Externalizable。Java序列化很灵活,但是通常很慢,并且导致许多类的序列化格式很大。
  • Kryo序列化:Spark还可以使用Kryo库(版本4)更快地序列化对象。与Java序列化(通常多达10倍)相比,Kryo显着更快,更紧凑,但是Kryo不支持所有 Serializable类型,并且要求我们预先注册要在程序中使用的类,以实现最佳性能。

我们可以通过使用SparkConf初始化作业 并通过以下语句来切换序列化器,conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")。此设置配置了在executor中和序列化到磁盘中的序列化器。从Spark 2.0.0开始,在对具有简单类型,简单类型数组或字符串类型的RDD进行序列化时,我们在内部使用Kryo序列化程序。

注意: 要使用Kryo注册用户的自定义类,我们需要使用registerKryoClasses方法。

2、内存调优

在进行内存调优时,我们需要首先考虑三个问题:1、java对象占用的内存大小。2、访问这些对象所消耗的成本。3、垃圾回收器的开销。

默认情况下,java对象的访问速度是很快的,但是和很容易造成其原始数据的2~5倍的内存消耗,这是由于以下几个原因造成的:

  • 每个不同的Java对象都有一个“对象头”,它大约16个字节,并包含诸如指向其类的指针之类的信息。对于其中原始数据很少的对象(例如一个Int字段只有8个字节,但是对象头却占了16字节),该对象可能大于数据。
  • Java String相对于原始字符串数据有大约40个字节的开销(因为它们将其存储在Chars 数组中并保留诸如长度之类的额外数据),并且由于UTF-16的内部用法,因此将每个字符存储为两个字节String编码。因此,一个10个字符的字符串很容易就消耗了60个字节的内存。
  • 常见的集合类(例如HashMap和LinkedList)使用链式数据结构,其中每个entry(例如Map.Entry)都有一个“包装”对象。该对象不仅具有对象头,而且还具有指向列表中下一个对象的指针(通常每个指针8个字节)。
  • 包装类也会占用内存,例如java.lang.Integer。

本节将首先概述Spark中的内存管理,然后讨论用户可以采取的特定策略,以更有效地使用其应用程序中的内存。特别是,我们将介绍如何确定对象的内存使用情况以及如何通过更改数据结构或以序列化格式存储数据来改善对象的使用情况。然后,我们将介绍调整Spark的缓存大小和Java垃圾收集器。

2.1 内存管理概述

Spark中的内存使用情况大体上属于以下两类之一:执行和存储。执行内存是指用于shuffle、join、sorts和aggregation中的计算的内存,而存储内存是指用于在集群中缓存和传播内部数据的内存。在Spark中,执行和存储共享一个统一的区域(M)。当不使用执行内存时,存储可以获取所有可用内存,反之亦然。如果有必要,执行可能会侵占存储空间,但只有在总存储内存使用率下降到某个阈值(R)以下时,才可以执行该操作。由于实现的复杂性,存储可能永远不会被占用。

这种设计确保能够最大化使用内存。首先,不使用缓存的应用程序可以将整个空间用于执行,从而避免了不必要的磁盘溢出。其次,使用缓存的应用程序可以保留最小的存储空间(R),以免其数据块被逐出。最后,这种方法可为各种工作负载提供合理的即用性能,而无需用户了解如何在内部划分内存。

这里有两种相关的配置,但通常情况下用户无需调整它们,因为默认值适用于大多数工作负载:

  • spark.memory.fraction:表示的大小M为(JVM堆空间-300MB)的一部分(默认值为0.6)。其余空间(40%)保留用于用户数据结构,Spark中的内部元数据,并在记录稀疏和异常大的情况下防止OOM错误。
  • spark.memory.storageFraction:将的大小表示R为的一部分M(默认为0.5)。 R是M其中的缓存块不受执行影响而退出的存储空间。

为了保证在JVM的堆内存中的老年代和永久代拥有足够多的内存,以及不一想到GC垃圾回收的效率,我们应该设置spark.memory.fraction的值。

2.2 确定内存消耗

确定数据集所需的内存消耗量的最佳方法是创建一个RDD,将其放入缓存中,然后查看Web UI中的“ Storage”页面。该页面将告诉我们RDD占用了多少内存。
要估算特定对象的内存消耗,我们可以使用SizeEstimator的estimate方法。这对于尝试使用不同的数据布局以减少内存使用量以及确定广播变量将在每个执行程序堆上占用的空间量很有用。

2.3 调整数据结构

减少内存消耗的第一种方法是避免使用一些增加额外开销的Java特性,例如基于指针的数据结构以对对象进行再包装等。有很多种实现方法:

  1. 设计数据结构时,将数据结构设计为对象数组和原始类型,而不是标准Java或Scala集合类(例如HashMap)。 fastutil 库为原始数据类型提供了非常方便的集合类,且兼容Java标准类库。
  2. 尽可能避免使用带有许多小对象和指针的嵌套结构(因为任何对象都会有对象头,这会造成更多内存消耗)。
  3. 考虑使用数字ID或枚举对象代替键的字符串。
  4. 如果RAM少于32 GB,则设置JVM标志-XX:+UseCompressedOops以使指针为四个字节而不是八个字节。我们可以在 spark-env.sh中添加这些选项。

2.4 序列化RDD存储

如果对象太大,导致优化后也无法存储,减少内存使用的一种更简单的方法是使用RDD持久性API中的序列化StorageLevels,例如MEMORY_ONLY_SER,以序列化形式存储它们。然后,Spark将每个RDD分区存储为一个大字节数组。由于必须动态地反序列化每个对象,因此以序列化形式存储数据的唯一缺点是访问时间较慢。如果想以序列化形式缓存数据,我们强烈建议使用Kryo,因为它序列化后的大小比Java序列化(当然也比原始Java对象)小。

2.5 垃圾回收器优化

如果我们需要不断地访问已经存储起来的RDD,则JVM垃圾回收可能会成为问题。(在只读取RDD一次然后对其执行许多操作的程序中,这通常不是问题。)当Java需要回收旧对象为新对象腾出空间时,它将需要遍历所有Java对象并查找未使用的。这里要记住的要点是,垃圾回收的成本与Java对象的数量成正比,因此使用具有较少对象的数据结构(例如使用Int 数组而不是LinkedList)可以大大降低此成本。一种更好的方法是如上所述以序列化形式持久化对象(将每一个RDD的分区都序列化成字节数组)。如果GC有问题,那么在尝试其他技术之前,首先尝试使用的方法是使用序列化缓存。

由于任务的工作内存(运行任务所需的空间量)与节点上缓存的RDD之间的干扰,GC也会成为问题。我们将讨论如何控制分配给RDD缓存的空间以减少这种情况的发生。

2.5.1 衡量GC的影响

GC调整的第一步是收集有关垃圾收集发生频率和花费GC时间的统计信息。这可以通过添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStampsJava选项来完成。下次运行Spark作业时,每次发生垃圾收集时,我们都会在工作日志中看到打印的消息。请注意,这些日志将位于群集的工作节点executor上(stdout位于其工作目录中的文件中),而不是驱动程序driver上。

2.5.2 高级GC调整

为了进一步调整垃圾回收,我们首先需要了解有关JVM中内存管理的一些基本信息:

  • Java Heap空间分为Young(年轻区)和Old(永久区)两个区域。年轻一代用于保存寿命短的对象,而老一代则用于寿命更长的对象。年轻一代又分为三个区域[Eden,Survivor1,Survivor2]。

  • 垃圾收集过程的简化描述:当Eden已满时,将在Eden上运行次要GC,并将来自Eden和Survivor1的活动对象复制到Survivor2。Old区域被交换。如果对象达到年龄阈值或Survivor2已满,则将其移到永久代。最后,当永久区接近满时,将调用FullGC。

在Spark中进行GC调整的目标是确保在永久区中仅存储长寿命的RDD,并确保年轻代具有足够的大小以存储短寿命的对象。这将有助于避免FullGC收集任务执行期间创建的临时对象。可能有用的一些步骤是:

  • 通过收集GC统计信息检查是否有太多垃圾需要回收。如果在任务完成之前多次调用FullGC,则意味着没有足够的内存来执行任务。

  • 如果MinorGC太多,但FullGC却不多,则为Eden分配更多的内存将有所帮助。您可以将Eden的大小设置为每个任务将需要多少的内存大小多一点的数值。假设确定Eden的大小为E,则可以设置Young一代的大小-Xmn=4/3*E。(按4/3比例放大也是为了考虑Survivor区域使用的空间。)

  • 在打印的GC统计信息中,如果OldGen即将满,可以通过减小spark.memory.fraction来减少用于缓存的内存量;与减慢任务执行速度相比,减少缓存对象以提高执行速度是非常值得的。或者,也可以考虑减小Young代的大小。这意味着减小我们刚才在上面设置的 -Xmn 值。如果没有设置-Xmn,可以尝试更改JVM NewRatio参数的值。许多JVM将此默认值设置为2,这意味着永久代占据了堆的2/3。永久代应该足够大,以使该比例超过spark.memory.fraction。

  • 使用尝试G1垃圾收集器-XX:+UseG1GC。在垃圾收集成为性能瓶颈的某些情况下,它可以提高性能。需要注意的是Executor的堆内存越大,我们也需要将G1Region的大小调整得越大,可以通过这个命令调整:-XX:G1HeapRegionSize

  • 举一个例子,如果任务从HDFS读取数据,那么任务需要的内存空间可以从读取的block数量估算出来。注意,解压后的blcok通常为解压前的2-3倍。所以,如果我们需要同时执行3或4个任务,block的大小为64M,我们可以估算出Eden的大小为4364MB。

  • 监控内存回收的频率以及消耗的时间并修改相应的参数设置。

我们的经历表明有效的内存回收优化取决于你的程序和内存大小。 在网上还有很多其他的优化选项, 总体而言有效控制内存回收的频率非常有助于降低额外开销。

3、其他注意事项

3.1 并行度

除非我们为每个操作设置足够高的并行度,否则群集资源将无法充分利用。Spark根据文件的大小自动设置要在每个文件上运行的“map”任务的数量(尽管您可以通过可选的参数来控制它,例如SparkContext.textFile,等等),并且对于分布式“reduce”操作(例如groupByKey和reduceByKey),它使用最大的RDD的分区数。我们可以将并行性度作为第二个参数传递或者通过设置系统参数spark.default.parallelism来改变默认值。通常,我们建议集群中每个CPU内核(core)执行2-3个任务。

3.2 Reduce Task的内存使用

有时,我们会收到OutOfMemoryError,原因不是因为内存不够存储RDD,而是因为我们的一项任务(例如中的reduce任务之一)的数据集太大。Spark的Shuffle操作(sortByKey,groupByKey,reduceByKey,join,等)会为每一个任务建立一个哈希表来进行分组,而这个hash表往往很大。此处最简单的解决方法是提高并行度,以使每个任务的输入集更小。Spark可以高效地支持短至200 ms的任务,因为它可以在多个任务中重用一个Executor JVM,并且任务启动成本低,因此您可以安全地将并行级别提高到集群中核心(core)的数量以上。

3.3 广播”大变量“

使用SparkContext的 广播功能可以有效减小每一个任务的大小以及在集群中启动作业的消耗。如果任务会使用驱动程序(driver program)中比较大的对象(例如静态查找表),考虑将其变成可广播变量。Spark会在master打印每一个任务序列化后的大小,所以你可以通过它来检查任务是不是过于庞大。通常来讲,大于20KB的任务可能都是值得优化的。

3.4 数据本地性

数据本地性可能会对Spark作业的性能产生重大影响。如果数据和对其进行操作的代码在一起,则计算速度往往会很快。但是,如果代码和数据是分开的,那么一个必须移到另一个。通常,从一个地方到另一个地方传输序列化代码要比传输块数据更快,因为代码大小比数据小得多。Spark围绕此数据本地性一般原则构建调度。

数据本地性是数据与处理它的代码之间的接近程度。根据数据的当前位置,可分为多个级别。从最远到最远的顺序:

  • PROCESS_LOCAL:数据与正在运行的代码位于同一JVM中。这是最好的位置。
  • NODE_LOCAL:数据在同一节点上。示例可能在同一节点上的HDFS中,或者在同一节点上的另一执行程序中。这比PROCESS_LOCAL更慢一些,因为数据必须在进程之间传输。
  • NO_PREF: 可以从任何地方快速访问数据,并且不受位置限制。
  • RACK_LOCAL:数据在同一服务器机架上。数据位于同一机架上的其他服务器上,因此通常需要通过单个交换机通过网络发送。
  • ANY :数据在网络上的其他位置,而不是在同一机架中。

Spark倾向于在最佳位置级别安排所有任务,但有时候并不能如我们所愿。在所有Executor上都没有可以直接处理的数据的情况下,Spark会切换到较低的本地级别。有两种选择:a)等待忙碌的CPU空闲以在存放数据的服务器上启动任务,或b)将数据移动到更远的地方启动新任务。

通常情况下,Spark会执行前一种策略,以期释放繁忙的CPU。一旦超时到期,它就开始将数据从很远的地方移到空闲的CPU中。每个级别之间的等待超时时间可以单独配置,也可以一起配置在一个参数中。如果任务很长并且位置不佳,则应该增加这些设置,但是默认设置通常效果很好。

总结

本文指出了Spark程序优化所需要关注的几个关键点——最主要的是数据序列化和内存优化。对于大多数程序而言,采用Kryo框架以及序列化能够解决性能有关的大部分问题。

猜你喜欢

转载自blog.csdn.net/qq_37163925/article/details/106792565
今日推荐