Spark性能权威调优指南
文章目录
由于大多数Spark计算是基于内存的,因此Spark程序的性能可能会受到集群中任何资源的限制:CPU、网络带宽或内存。通常情况下,如果数据可以放入内存,则瓶颈是网络带宽,但有时您还需要进行一些调优,例如将RDD存储为序列化形式以减少内存使用。本指南将涵盖两个主要主题:数据序列化(对于良好的网络性能至关重要,还可以减少内存使用)和内存调优。我们还将概述其他几个较小的主题。
数据序列化
序列化在任何分布式应用程序的性能中都起着重要的作用。序列化对象速度慢,或消耗大量字节的格式,会大大降低计算速度。因此,在优化Spark应用程序时,这通常是您首先需要调优的内容。Spark提供了两个序列化库:
- Java序列化:默认情况下,Spark使用Java的
ObjectOutputStream
框架对对象进行序列化,并且可以处理实现了java.io.Serializable
接口的任何类。您还可以通过扩展java.io.Externalizable
接口来更精确地控制序列化的性能。Java序列化非常灵活,但通常速度较慢,并且对于许多类来说,序列化后的格式较大。 - Kryo序列化:Spark还可以使用Kryo库(版本4)更快地对对象进行序列化。与Java序列化相比,Kryo更快且占用空间更小(通常高达10倍),但不支持所有
Serializable
类型,并且需要提前注册程序中要使用的类以获得最佳性能。
您可以通过在作业的SparkConf中初始化时调用conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
来切换到使用Kryo序列化。此设置不仅配置了用于在工作节点之间传输数据时使用的序列化器,还配置了将RDD序列化到磁盘时使用的序列化器。Kryo不是默认选项的唯一原因是它需要自定义注册,但我们建议在任何网络密集型应用程序中尝试使用Kryo。从Spark 2.0.0开始,在处理简单类型、简单类型数组或字符串类型的RDD时,我们内部使用Kryo序列化器。
Spark自动包括许多常用的Scala核心类的Kryo序列化器,这些类在Twitter chill库的AllScalaRegistrar中有介绍。
要使用Kryo注册自己的自定义类,请使用registerKryoClasses
方法。
val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)
Kryo文档介绍了更高级的注册选项,例如添加自定义序列化代码。
如果您的对象很大,还可以增加spark.kryoserializer.buffer
配置。此值需要足够大,以容纳您将要序列化的最大对象。
最后,如果您没有注册自定义类,Kryo仍然可以工作,但它将不得不在每个对象中存储完整的类名,这是一种浪费。
内存调优
在调优内存使用时有三个要考虑的因素:对象使用的内存量(您可能希望整个数据集都适合内存),访问这些对象的成本,以及垃圾收集的开销(如果对象更替频繁)。
默认情况下,Java对象访问速度很快,但是它们的空间消耗很容易比其字段内部的“原始”数据多出2-5倍。这是由于以下几个原因:
- 每个不同的Java对象都有一个"对象头",大约占用16字节,并包含诸如指向其类的指针等信息。对于非常少量数据的对象(例如一个
Int
字段),这可能比数据本身还要大。 - Java的
String
对象在原始字符串数据之上有大约40字节的开销(因为它们将其存储在Char
数组中,并保留额外的数据,如长度),并且由于String
在内部使用UTF-16编码,所以每个字符占用两个字节。因此,一个包含10个字符的字符串可能会消耗60个字节。 - 常见的集合类,如
HashMap
和LinkedList
,使用链接数据结构,其中每个条目(例如Map.Entry
)都有一个“包装”对象。这个对象不仅具有头部,而且还有指向列表中下一个对象的指针(通常每个指针占用8字节)。 - 基本类型的集合通常将它们存储为"装箱"对象,例如
java.lang.Integer
。
本节将首先概述Spark中的内存管理,然后讨论用户可以采取的特定策略,以更有效地利用应用程序中的内存。特别是,我们将描述如何确定对象的内存使用情况以及如何改进它——无论是通过更改数据结构还是通过以序列化格式存储数据。然后,我们将介绍调整Spark缓存大小和Java垃圾收集器的方法。
内存管理概述
Spark中的内存使用主要分为两个类别:执行内存和存储内存。
执行内存用于洗牌、连接、排序和聚合等计算,而存储内存用于缓存和在集群中传播内部数据。
在Spark中,执行内存和存储内存共享一个统一的区域(M)。当不使用执行内存时,存储可以获取所有可用的内存,反之亦然。执行可能会逐出存储,但只有在总存储内存使用量低于某个阈值(R)时才会这样做。
换句话说,R描述了M中的一个子区域,其中缓存块永远不会被执行逐出。
由于实现复杂性,存储不能逐出执行。
这种设计确保了几个期望的属性。首先,不使用缓存的应用程序可以将整个空间用于执行,避免不必要的磁盘溢出。其次,使用缓存的应用程序可以保留最小存储空间(R),其中它们的数据块不会被执行逐出。最后,这种方法为各种工作负载提供了合理的开箱即用性能,无需用户了解内部内存如何划分。
尽管有两个相关的配置,但通常用户不需要调整它们,因为默认值适用于大多数工作负载:
spark.memory.fraction
将M的大小表示为(JVM堆空间 - 300MB)的一个分数(默认值为0.6)。剩余的空间(40%)用于用户数据结构、Spark内部元数据和防止稀疏和异常大记录引起的OOM错误。spark.memory.storageFraction
将R的大小表示为M的一个分数(默认值为0.5)。R是M中的存储空间,其中缓存块不会被执行逐出。
应该设置spark.memory.fraction
的值,以便将此堆空间舒适地安排在JVM的旧代或“tenured”代中。有关详细信息,请参见下面有关高级GC调优的讨论。
确定内存消耗
估算数据集所需的内存消耗量的最佳方法是创建一个RDD,将其放入缓存,并查看Web UI中的“Storage”页面。该页面将告诉您RDD占用了多少内存。
要估算特定对象的内存消耗量,可以使用SizeEstimator
的estimate
方法。这对于尝试使用不同的数据布局来减少内存使用量,以及确定广播变量在每个执行器堆上占用的空间量非常有用。
调整数据结构
减少内存消耗的第一种方法是避免增加开销的Java特性,例如基于指针的数据结构和包装对象。有几种方法可以做到这一点:
- 设计数据结构时,优先选择对象数组和原始类型,而不是标准的Java或Scala集合类(例如
HashMap
)。fastutil库提供了与Java标准库兼容的原始类型方便的集合类。 - 尽可能避免具有大量小对象和指针的嵌套结构。
- 考虑使用数字ID或枚举对象而不是字符串作为键。
- 如果RAM小于32 GB,则设置JVM标志
-XX:+UseCompressedOops
,将指针大小从8字节减少到4字节。您可以在spark-env.sh
中添加这些选项。
序列化RDD存储
当尽管进行了调整,对象仍然太大以有效存储时,减少内存使用的一种更简单的方法是以序列化形式存储它们,使用RDD持久化API中的序列化StorageLevels,如MEMORY_ONLY_SER
。然后,Spark将每个RDD分区存储为一个大的字节数组。
以序列化形式存储数据的唯一缺点是访问速度较慢,因为必须即时反序列化每个对象。
我们强烈推荐使用Kryo进行数据序列化,如果要以序列化形式缓存数据,它比Java序列化(当然也比原始Java对象)占用更小的空间。
垃圾回收调优
当您的程序存储的RDD产生大量"churn"时,JVM的垃圾回收可能成为一个问题。(在仅读取RDD一次然后对其执行多个操作的程序中,通常不是问题。)当Java需要逐出旧对象以腾出空间给新对象时,它将需要跟踪所有Java对象并找到未使用的对象。这里主要要记住的是,垃圾回收的成本与Java对象数量成正比,因此使用对象较少的数据结构(例如,Int
数组而不是LinkedList
)大大降低了这个成本。如果内存回收是一个问题,则尝试的其他技术之前,最好的方法是使用序列化缓存。
由于任务的工作内存(运行任务所需的空间)和节点上缓存的RDD之间的干扰,GC也可能成为一个问题。我们将讨论如何控制分配给RDD缓存的空间以减轻这种干扰。
衡量GC的影响
GC调优的第一步是收集有关垃圾回收频率和GC所花费时间的统计信息。可以通过在Java选项中添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
来完成此操作。(有关将Java选项传递给Spark作业的信息,请参见配置指南。)下次运行Spark作业时,您将在工作节点的日志中看到每次进行垃圾回收时打印的消息。请注意,这些日志将位于集群的工作节点上(在其工作目录中的stdout
文件中),而不是驱动程序上。
高级GC调优
为了进一步调优垃圾回收,我们首先需要了解一些关于JVM中内存管理的基本信息:
- Java堆空间分为两个区域:Young和Old。Young代用于保存短期对象,而Old代用于具有较长生命周期的对象。
- Young代进一步分为三个区域[Eden, Survivor1, Survivor2]。
- 垃圾回收过程的简化描述:当Eden区满时,在Eden和Survivor1中存活的对象进行minor GC,并将其复制到Survivor2。然后交换Survivor区域。如果对象已经很老或者Survivor2已满,则将其移动到Old代。最后,当Old代接近满时,会触发full GC。
Spark中的GC调优目标是确保只有长期存活的RDD存储在Old代中,而Young代足够大以存储短期对象。这将有助于避免由任务执行期间创建的临时对象导致的full GC。以下是一些可能有用的步骤:
- 通过收集GC统计信息来检查是否存在过多的垃圾回收。如果在任务完成之前多次触发full GC,意味着没有足够的内存可用于执行任务。
- 如果存在过多的minor GC但没有太多的major GC,增加Eden的内存可能会有所帮助。可以将Eden的大小设置为每个任务所需内存的过估计值。如果Eden的大小确定为E,则可以使用选项
-Xmn=4/3*E
来设置Young代的大小。(乘以4/3是为了考虑到Survivor区域使用的空间。) - 在打印的GC统计信息中,如果Old代接近满,降低用于缓存的内存量,即减小
spark.memory.fraction
的值;与其减慢任务执行速度,不如缓存较少的对象。或者考虑减小Young代的大小,即降低-Xmn
(如果上面已经设置)。如果没有设置,尝试更改JVM的NewRatio
参数的值。许多JVM的默认值为2,意味着Old代占用堆的2/3的空间。它应该足够大,使得这个比例超过spark.memory.fraction
。 - 尝试使用
-XX:+UseG1GC
使用G1GC垃圾收集器。在某些情况下,它可以提高性能,特别是当垃圾回收成为瓶颈时。请注意,在大型执行器堆大小的情况下,可能需要使用-XX:G1HeapRegionSize
增加G1区域大小。 - 例如,如果您的任务从HDFS读取数据,则可以使用从HDFS读取的数据块的大小来估算任务使用的内存量。请注意,解压缩块的大小通常是块大小的2倍或3倍。因此,如果我们希望有3到4个任务的工作空间,并且HDFS块大小为128 MB,则可以估算Eden的大小为
4*3*128MB
。 - 监视垃圾回收的频率和所花费的时间如何随新设置而变化。
根据我们的经验,GC调优的效果取决于您的应用程序和可用的内存量。在线上还有更多的调优选项可以了解,但总体来说,管理full GC发生的频率有助于减少开销。
可以通过在作业的配置中设置spark.executor.extraJavaOptions
来指定执行器的GC调优标志。
其他考虑因素
并行级别
除非为每个操作设置足够高的并行级别,否则集群将无法充分利用。Spark会根据文件的大小自动设置每个文件上要运行的“map”任务数量(尽管可以通过SparkContext.textFile
等可选参数来控制),对于分布式“reduce”操作(例如groupByKey
和reduceByKey
),它使用最大父RDD的分区数。可以将并行级别作为第二个参数传递(请参阅spark.PairRDDFunctions
文档),或者通过设置配置属性spark.default.parallelism
来更改默认值。一般来说,在集群中每个CPU核心建议使用2-3个任务。
Reduce任务的内存使用
有时,会出现OutOfMemoryError错误,不是因为RDD不适合内存,而是因为一个任务的工作集过大,例如groupByKey
中的某个reduce任务。Spark的洗牌操作(sortByKey
、groupByKey
、reduceByKey
、join
等)在每个任务中构建一个哈希表以执行分组操作,这通常会占用较大的空间。最简单的解决方法是增加并行级别,以便每个任务的输入集更小。Spark可以有效地支持任务长度为200毫秒,因为它在多个任务之间重用一个执行器JVM,并且任务启动成本低,因此您可以安全地将并行级别增加到超过集群中的核心数。
广播大型变量
使用SparkContext
中提供的广播功能可以大大减少每个序列化任务的大小和启动跨集群的作业的成本。如果任务在其中使用了驱动程序中的任何大型对象(例如静态查找表),考虑将其转换为广播变量。Spark会在主节点上打印每个任务的序列化大小,因此您可以根据这个值来判断任务是否太大;一般来说,大于20 KB的任务可能值得优化。
数据本地性
数据本地性对Spark作业的性能有重大影响。如果数据和处理数据的代码在一起,计算往往会更快。但是,如果代码和数据分离,就必须将它们移动到一起。通常情况下,从一个地方到另一个地方传输序列化的代码比传输数据块更快,因为代码大小远小于数据。Spark的调度是围绕数据本地性的一般原则构建的。
数据本地性是数据与处理数据的代码之间的距离。根据数据的当前位置,有几个级别的本地性,按照从最接近到最远的顺序排列:
PROCESS_LOCAL
:数据位于运行代码的同一JVM中。这是最好的本地性。NODE_LOCAL
:数据位于同一节点上。例如,数据可能存储在同一节点上的HDFS中,或者在同一节点上的另一个执行器中。这比PROCESS_LOCAL
慢一些,因为数据必须在进程之间传输。NO_PREF
:数据可以从任何地方等快速访问,并且没有本地性偏好。RACK_LOCAL
:数据位于同一机架的服务器上。数据位于同一机架上的不同服务器上,因此需要通过网络发送,通常通过单个交换机进行。ANY
:数据位于网络中的其他位置,不在同一机架上。
Spark倾向于在最佳本地性级别上调度所有任务,但这并不总是可能的。在没有任何空闲执行器上有未处理数据的情况下,Spark会切换到较低的本地性级别。有两个选择:a)等待直到忙碌的CPU释放出来,以便在同一服务器上的数据上启动一个任务;或者b)立即在较远的位置启动一个需要将数据移动到那里的新任务。
Spark通常会稍微等待一下,希望忙碌的CPU会释放出来。一旦超时时间到期,它就开始将数据从远处移动到空闲的CPU上。可以单独配置每个级别之间的回退等待超时时间,或者将它们全部配置在一个参数中;有关详细信息,请参阅配置页面上的spark.locality
参数。如果您的任务很长且本地性差,请增加这些设置,但默认设置通常效果良好。
总结
别之间的回退等待超时时间,或者将它们全部配置在一个参数中;有关详细信息,请参阅配置页面上的spark.locality
参数。如果您的任务很长且本地性差,请增加这些设置,但默认设置通常效果良好。
总结
总结起来,这是一个简短的指南,指出了调优Spark应用程序时你应该了解的主要问题,尤其是数据序列化和内存调优。对于大多数程序,切换到Kryo序列化,并以序列化形式持久化数据,将解决大多数常见性能问题。