因为大多数的Spark计算是在内存中进行的,所以Spark应用的瓶颈在于集群的硬件资源:CPU、网络带宽或者内存。在大多数情况下,如果内存够用,网络带宽就会成为应用性能的瓶颈,但在有些时候,你仍然需要去做一些调整,比如用序列化的形式去存储RDD,以减少内存的使用。这篇指导将覆盖两个主题:数据的序列化(数据的序列化方式是好的网络性能以及减少内存使用的关键)和内存调整。我们也会讨论几个小的主题。
Data Serialization(数据序列化)
在任何的分布式应用的性能方面,序列化方式都扮演者一个极为重要的角色。如果序列化方式解析对象的速度太慢或者消耗的字节数量过大,将会极大的放慢计算的速度。通常,这将是你在优化Spark程序时需要优先考虑调整的地方。Spark的目标是平衡便利(允许你使用任何Java中的类型进行操作)和性能。他提供了两种序列化库:
- Java serialization:Spark的默认的对象序列化方式是使用Java的ObjectOutputStream 框架,这种方式允许你使用任何实现了
java.io.Serializable
接口的类。Java serialization非常灵活但速度太慢,并且会导致很多的类的序列化结果太大。 - Kryo serialization:Spark同时提供了Kryo库(版本号4)来使对象的序列化速度更快。Kryo和Java serialization比较,显著的加快了对象序列化的方式,并大大降低了序列化结构的大小(大于10倍),但是Kryo并不支持所有可序列化的类型,并且他要求你注册你希望改善性能的那些类。
你可以在初始化Spark作业的时候通过调用SparkConf的set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
来切换Kryo序列化方式。这项配置不仅仅更改了工作节点之间进行数据混洗的时候所用的序列化器,同时更改了将RDD写入磁盘的时候的序列化器。Spark没有将Kryo作为默认的序列化器的原因在于需要开发者自己注册需要序列化的类型,所以我们建议在IO密集型的应用中使用Kryo作为序列化器。从Spark2.0.0开始,我们在内部开始使用Kryo序列化器作为混洗简单数据类型及其数组类型的RDD的序列化器。
Spark自动包含了来自于Twitter chill库中的常用的Scala类的Kryo序列化器的注册。
要将你自己的类注册成Kryo序列化支持的类,可以使用registerKryoClasses
方法。
val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)
Kryo documentation中描述了更多的高级选项,例如添加定制的序列化代码。
如果你的对象列表很大,你可能需要增加spark.kryoserializer.buffer
config,这些配置需要你设置足够大的值来存储你将要序列化的对象。
最后,如果你没有注册你定制的类型,Kryo仍然可以工作,但是,Kryo将会为每一个对象存储完整的类名称,这将会带来浪费。
Memory Tuning(内存优化)
在进行内存优化的时候有三个需要考虑的方面:你的数据对象所需要使用的内存总数,访问这些对象的花销以及垃圾收集机制(如果你需要对这些数据对象进行频繁的转换)
默认情况下,Java对象可以快速的进行访问,但是比起停留在原位置的原始数据来说,Java对象会消耗2-5倍的空间。这是因为如下几个原因:
- 每一个不同的Java对象都包含一个对象头,这个对象头占用16个字节的空间,包含了指向其类型的指针等信息。对于一个只包含了很少数据的对象来说,对象头的空间会大于数据占用的空间。
- Java字符串比原始的字符数据多使用了40字节的空间,因为要存储字符数组相关的一系列信息,例如字符串长度,并且将每一个字符占用2个字节的空间,因为Java内部的字符编码使UTF-16 。因此一个超过10个字符的字符串对象会很容易的占用60个字节的空间
- 通用的集合类,比如HashMap和LinkedList,使用链接式的数据结构,这些集合内部存储的每一个元素都是封装过的对象(比如
Map.Entry
),这些对象不仅仅包含一个对象头,而且还包含一个指向列表中下一个节点的指针(一般是8个字节)。 - 主要类型的集合通常将他们以装箱的形式进行存储,比如
java.lang.Integer
。
本章将开始对Spark的内存管理进行综述,然后讨论一些可以使用户在Spark应用中更有效的使用内存的策略。我们会着重描述如何确定应用中的对象的内存使用情况以及优化她们的方法 - 这些方法要么是改变数据的结构,要么是通过序列化的方式来存储数据。然后我们将介绍如何调整Spark的缓存大小和Java的垃圾收集机制。
Memory Management Overview(内存管理综述)
Spark中大多数的内存使用分为两类:执行和存储。执行内存主要用与计算中的混洗,连接,排序和聚合等操作,存储内存主要用来缓存数据以及在集群内部传播数据。在Spark中,执行和存储共享统一的内存区域。如果执行器没有使用内存,那么存储功能能够请求所有可用的内存(如果存储功能没有使用任何内存,执行功能也可以请求所有可用内存)。当然执行功能在内存的使用方面对存储功能具有优先权,如果必要的话执行功能可以抢占存储功能占用的内存,但是只能讲存储占用的内存降低到一个阈值,无法完全抢占。换句话说,内存中有一个子区域的缓存块将永远无法被抢占。存储无法抢占执行功能的内存,这是因为计算实现的复杂度造成的。
尽管有两项相关的配置,但是对于绝大多数工作来说,不建议用户去调整配置的默认值。
-
spark.memory.fraction
表示Spark作业使用的内存区域与Jvm堆内存的占比(默认值是0.6)。剩余的空间(40%)用来存储用户的数据结构,Spark内部的元数据,以及用来防止由稀疏何异常大的记录所导致的OOM错误。 -
spark.memory.storageFraction
表示Spark内存区中存储区所占的比例(默认是0.5)。存储区就是可以防止执行功能抢占的内存区。
spark.memory.fraction
的值的设置应该确保其占用的内存能够容纳JVM堆内存中老年代和永久代的大小。我们可以在下面的高级GC调整中进行详细讨论
Determining Memory Consumption(确定内存消耗)
最好的确定数据集内存消耗情况的方法是创建一个RDD并将其缓存,然后通过Spark管理界面(Web UI)的存储页面去查看。该页面将会告诉你RDD占用的内存大小。
去估计一个特定对象的内存消耗,可以使用SizeEstimator
类的estimate
方法。这个方法可以用来测试不同的数据布局对内存的占用情况,除此之外,也可以用来确定广播变量对每一个执行器的堆内存的占用情况。
Tuning Data Structures(调整数据结构)
第一个降低内存消耗的方法是避免由于Java的功能所过度使用的内存,比如基于指针的数据结构以及包装类对象。下面有这几种方法:
- 设计你自己的数据结构代替Java或者Scala集合类来保存对象数组以及主要的数据类型(比如HashMap),FastUtil 库为主要数据类型提供了方便使用的集合类并且兼容Java的标准库。
- 避免嵌套小对象或者指针的使用。
- 尽量用数字类型的键来代替字符类型的键
- 如果机器的内存小于32GB,可以设置JVM的参数
-XX:+UseCompressedOops
用4个字节指针代替8个字节的指针。你可以添加这些选项到spark-env.sh中。
Serialized RDD Storage(序列化RDD的存储)
尽管你做了这些调整,但是你的对象仍然太大,无法做到高效的存储,一个最简单的降低内存使用的方法就是用序列化的形式存储他们,可以通过RDD persistence API中的序列化存储级别来配置,例如MEMORY_ONLY_SER
。一旦设置了序列化存储级别,Spark就会将每一个RDD分区存储成大的字节数组。这样做的唯一劣势就是会增加访问时间,因为在使用的时候需要先进行反序列化。如果你打算使用序列化的方式来缓存RDD,我们强烈建议使用Kryo,Kryo的序列化的结果远远小于Java的序列化机制。
Garbage Collection Tuning(垃圾收集的调整)
当你的程序中,在RDD的存储方面,有很多的打乱操作时,JVM的垃圾收集将会成为一个问题。(如果你的程序只是读取RDD一次然后在其上进行一些操作,那么垃圾回收将不会成为一个问题)当JVM需要回收旧的对象,为新的对象腾出空间时,JVM需要扫描几乎所有的Java对象然后发现不在使用的对象。主要的点就在于记住垃圾收集的开销与Java对象的数量成正比,所以使用尽可能少的Java对象会大大降低垃圾收集的花费。一个比较有效的方法就是用序列化的方式来缓存对象,这样就可以使用一个对象(字节数组)来存储一个RDD分区。如果你的GC成为了一个问题,那么在尝试其他技术之前,可以先尝试serialized caching(序列化缓存)技术。
在另一种情况下,GC也会成为一个问题,就是当没有足够的内存来执行计算的时候,需要回收缓存RDD的内存时。下面我们将讨论如何控制RDD内存的分配来减少这种情况的发生。
Measuring the Impact of GC(评估GC的影响)
在调整GC的时候,第一步要做的就是统计收集GC发生的频率和花费的总时间。我们可以通过设置-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
Jvm参数来收集GC的的信息。(可以通过configuration guide来查看如何在Spark作业中设置Java参数)下次你的Spark作业在运行的时候,你将会看到打印在工作节点线程中的GC发生的消息。注意,这些日志会打印在你的Spark集群的工作节点上(在工作目录的stdout文件上),而不是在你的驱动程序那里。
Advanced GC Tuning(高级GC调整)
如果想要进一步优化GC,我们首先得理解一些JVM内存管理的基础信息:
- Java的堆内存区分为新生代和老年代两个部分。新生代用来保存短生命周期的对象,老年代用于存储长生命周期的对象。
- 新生代进一步分为三个区域:Eden(出生区),Survivor1(存活区1), Survivor2(存活区2)。
- 对垃圾收集的步骤做一个简单的描述:当Eden区已满,会在Eden区进行一次次级GC,将Eden和Survivor1区中仍然存活的对象复制到Survivor2区。这些Survivor区域会发生交换。如果一个对象存活时间足够长或者Survivor2区已满,他将会被放入老年区。最后,如果老年区接近满了,一次完整的GC将被执行。
在Spark中GC优化的目标是确保只有长时间存活的RDDs存储在老年代中并且确保有充分的青年代空间存储短生命周期对象。这样可以避免需要通过完整的GC去收集一些任务执行过程中创建的临时对象。下面这些步骤应该会有所帮助
- 通过收集GC的状态检查是否发生了过多了垃圾收集。如果在一次任务结束前发生了多次的完整GC,这意味着对于所执行的任务来说,并没有足够的可用内存。
- 如果发生了多次的Minor GC但是Major GC发生的次数却不多,这个时候可以通过为Eden区分配更多的空间来改善这种状况。你可以为Eden区设置一个比较高的估值。如果Eden去的大小为E,你可以通过选项
-Xmn=4/3*E
来设置年轻代的大小。(通过4/3这个比例可以计算出survivor区域的大小) - 当GC状态被打印了出来,如果老年代接近满,可以通过降低
spark.memory.fraction
的值降低缓存对内存的使用量;缓存较少的对象要好于减慢任务执行的速度。当然,我们也可以考虑减少年轻代的大小。这意味给-Xmn选项设置一个较低的值。如果不想这样做,可以尝试更改JVM的NewRatio参数。大多数JVM默认的NewRatio参数值是2,这意味着老年代占用了2/3的堆内存空间。这个比例超过了spark.memory.fraction
,应该足够大了. - 可以通过
-XX:+UseG1GC
尝试G1GC方法。在一些GC成为性能瓶颈的场景,使用G1垃圾收集器能够提升性能。注意:如果堆内存空间很大,那么通过-XX:G1HeapRegionSize
增加G1区域的大小就变的非常重要了。 - 举个例子,如果的你的任务需要从HDFS中读取数据,那么任务需要的内存总数可以通过HDFS数据块的大小进行估算。注意:HDFS中的数据块是经过压缩的,所以读入内存后需要解压,因此实际的大小是原来块大小的2至3倍。如果我们需要能执行3到4个任务的工作空间,并且HDFS的块大小是128M,所以我们估计Eden区的大小为43128。
- 监控一下新的GC配置下,GC发生的时间和频率。
根据我们的经验,最有效的GC优化依赖你的应用和可用的总内存。在网上有更多关于GC优化的描述,但是,站在更高层次看,控制完整GC的发生频率,能够有效降低GC的开销。
执行器的GC优化可以通过配置作业的spark.executor.extraJavaOptions
来进行。
Other Considerations(其他)
Level of Parallelism(并行度)
除非你为每一个操作设置了最高的并行度,否则不会利用集群中所有的结点。Spark会根据map
操作所处理的每一个文件的大小来自动设置并行度(虽然你能够通过可选的参数来控制他们比如SparkContext.textFile
),对于那些分布式reduce
操作,比如groupByKey
和reduceByKey
,Spark会使用最大的上游RDD的分区数来确定并行度。当然你可以通过将并行度作为第二个参数传给相关的操作(可以查看 spark.PairRDDFunctions
的文档),或者设置spark.default.parallelism
的值来改变默认的并行度。一般而言,我们建议在集群中,每2或3个任务使用一个CPU核。
Memory Usage of Reduce Tasks(Reduce任务的内存使用)
有些时候,你将会遇到OutOfMemoryError,这并非因为你的RDDs大小超过了可用内存,而是因为你的任务集合中的某一个任务,比如groupByKey
这样的Reduce操作,他太大了。Spark的混洗操作(sortByKey
,groupByKey
,reduceByKey
等)会为每一个任务创建一个哈希表来执行分组,这个哈希表通常会很大。最简单的解决此类错误的方式就是提高并行度,提高了并行度每一个该操作的任务的输入集就会变小。Spark可以有效的支持任务在200ms结束,因为他在很多的任务之间重复使用了执行器的JVM,并且有着较低的任务启动花费,所以你能够安全的提升并行度的值,使其大于整个集群的内核数。
Broadcasting Large Variables(广播大型变量)
使用 broadcast functionality
能够极大的减少每一次序列化任务使用的内存大小,以及在集群上启动一次作业的花费。如果你的任务使用了来自于驱动程序的大型对象,可以考虑使用广播变量来调整他。Spark会将每一个任务序列化后的大小打印在Master上,所以你能够看到并且决定你的任务是否过大;通常情况下,任务大于20KB就值的去进行优化。
Data Locality(数据局部性)
数据局部性对Spark作业的性能有着巨大的影响。如果数据和代码在同一个结点上那么计算就会变的很快。但是如果代码和数据是分开的,那么其中一个要移动到另一个所在的结点。典型地,移动序列化后的代码比移动数据块要快的多,这是因为代码的大小远远小于数据块的大小。Spark在调度作业的时候遵守数据局部性的一般原则。
数据局部性表示数据与处理他的程序之间的距离。这里有几种基于数据当前位置的局部化的级别。顺序是由近至远:
-
PROCESS_LOCAL
数据和处理程序在同一个JVM中,这是最好的情况 -
NODE_LOCAL
数据和处理程序在同一个结点上。比如数据在HDFS的一个结点上,同时一个执行器也在同一个结点上。这比PROCESS_LOCAL
要慢一些,因为数据要在进程之间转移 -
NO_PREF
在任何地方访问数据都是一样的,没有任何局部性的优先权 -
RACK_LOCAL
数据和程序位于同一个机架上的不同机器上,因此数据需要通过网络进行传输,典型地通过一个单开关 -
ANY
数据可以在网络中的任何位置,但与处理程序不在同一个机架上。
Spark喜欢讲所有任务安排到最好的局部性级别上,但是这并不总是可能的。在某些情况下,一些空闲的执行器上没有未处理的数据了,Spark会切换较低的局部性等级。这时候有两个选项:A)等待非空闲并且有未处理数据的执行器上的繁忙的CPU空闲的时候,在数据所在的服务器上启动一个新的任务,或者B)立刻在空闲的执行上启动1个新的任务然后请求移动数据。
Spark会选择哪一个呢?Spark先选择A选项,等待繁忙的CPU一段期望的时间,如果这段时间后,CPU仍然繁忙,那么Spark就会选择B选项。
这个在各个等级之间的期望时间可以分别单独设置,或者可以通过参数的形式整体来设置;我们可以在configuration page上查看spark.locality
参数的详细信息。如果你的任务时间较长或者局部化特性比较差,你可以适当地增大这些配置的值,但是默认值通常就够用了。
Summary(总结)
本文是一篇简短的指导,指出了当你需要优化Spark应用时,你应该关注哪些点。最重要的两个点是数据序列化和内存优化。对于大多数程序,使用Kryo序列化以及在缓存数据的时候使用序列化形式将解决大部分常见的性能问题。