本文根据官方性能优化指南和自身经验总结。
官方性能优化指南链接:http://spark.apache.org/docs/1.6.0/tuning.html
tunnig:名词,调谐;调整;调音。(music) calibrating something (an instrument or electronic circuit) to a standard frequency
鉴于Spark基于内存计算这一天性,以下集群资源可能会造成Spark程序的瓶颈:CPU,带宽和内存。通常情况下,如果内存足够的情况下,瓶颈只可能出现在网络带宽方面;但有时,你也需要做一些例如序列化优化来降低内存使用率。这份指导主要集中于两方面:数据序列化,这是充分提升网络表现和降低内存消耗、内存优化的关键;我们也会简要阐述一些小技巧。
序列化在任何分布式应用的运行中扮演了重要的角色。采用那些序列化慢的格式、或者消费巨量字节时将会严重拖慢计算效率。通常情况下,调整数据的序列化方式是你优化Spark程序时首先需要做的事。Spark程序试图在简洁(循序你在代码中使用任何Java的数据类型)和效率之间取得一种平衡。Spark提供了两种序列化库。
又可以通过设置初始化时的SparkConf和调用conf.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")来切换到Kryo模式。这项配置不仅会在工作节点进行数据混洗时用到Kryo序列化,而且在将RDD序列化到硬盘时也会使用到kryo。Kryo之所以没有成为默认设置是因为使用者需要自行注册一些类,但是我们建议在一些网络密集型应用中尝试使用kryo序列化。从Spark2.0.0开始,当对于简单类型,简单类型数组和字符串类型的RDD进行混洗时,Spark已经使用Kryo进行了内部整合。
Spark已经自动对很多常用的核心Scala类(包含于AllScalaRegistrar,位于Twitter chill library)进行了Kryo序列化注册。
要使自定义类应用Kryo注册,你需要registerKryoClasses方法:
SparkConf sparkConf = new SparkConf().setAppName("Kryo");
Class>[] classs={MyClass.class,YourClass.class};
sparkConf.registerKryoClasses(classs);
JavaSparkContext sc = new JavaSparkContext(sparkConf);
kryo 文档描述了进阶注册选项,例如添加自定义序列化编码。
如果你的对象很大,你可能需要增加spark.kryoserializer.buffer。这个值需要足够大,以至于能够容纳下被序列化的最大的对象。
如果你不注册你的自定义类,Kryo仍然会执行下去,但它将会存储每个对象的类全名,这真是一种浪费。
在优化内存使用率时主要有三方面可考虑的因素:你创建的对象占用的内存量(你也许想将整个数据据装进内存),访问这些数据的代价和累计回收的开销(当你的对象在内存中具有较高轮换率时)
默认情况下,访问Java对象是快速的。但一不留神就会消耗2到5倍的空间用于存储对象中的原始属性变量,这主要是出于以下原因:
这一部分将首先简要概述一下Spark的内存管理,然后列举一些特殊的策略,来帮助你在优化你的应用时采取更高效的方式。我们将着重描述如何确定对象的内存占用和如何改变数据结构和序列化方式来降低内存占用。然后,我们会介绍如何优化Spark的缓存大小和Java垃圾回收。
Spark的内存使用大致可划分两类:执行和存储。执行存储指的是计算(shuffles,join,sorts和aggregations)时用的内存,而存储内存指的是用于缓存和在集群内部传播的数据。在Spark中,执行和存储共享统一的区域(M)。当执行模块没有占用内存时,存储模块可以获取全部内存(统一区域),反之亦然。执行模块可以驱逐存储模块,当且仅当全部内存使用落到某个设定的阈值时(R)。换句话说,R从M划分出一个亚区,这个亚区的缓存不可被驱逐。出于实现的复杂性的考虑,存储模块无法驱逐执行模块。
这个设计确保了一些吸引人的特性。1、如果应用不使用缓存的话,计算模块可以使用整个内存空间进行计算,排除不必要的硬盘溢写。2、如果一个应用可以通过R预定一个最低的存储空间用于缓存,那这些缓存对于驱逐是免疫的。3、这提供了可靠的开箱即用的方式来应对不同的工作,即便你不是一个对内部存储分配了如指掌的专家。
尽管Spark提供了两个相关的配置项,但大部分用户并不需要去调整它们,因为配置项的默认值已经足够应对大多数工作任务。
spark.memory.fraction值的配置应当使得JVM中的堆内存与老代和永久代的空间相协调。具体配置见下文GC优化调整细节。
判断一个数据集到底消耗多少内存的最佳方式是:将数据集加载到RDD并将其缓存下来,然后去Spark Web UI查看“Storage”页面。这个页面将告诉你,你的RDD正在申请多大的内存。
要预估某个指定对象的内存消耗时,请使用SizeEstimator的estimate方法,这是对于哪些想试验一下如何通过改变数据类型来消减内存和判断某个广播变量将在每个执行器申请多大内存的朋友来说是个好工具。
降低内存消耗的首要方法就是避免使用添加额外开销的Java特征,例如基于指针的数据结构和包装对象。具体小贴士如下:
当你的对象太大以至于以上优化均被无视的情况下,有一个副更简单的药可以拯救你的对象,那就是将它存储为序列化格式来降低内存使用。通过使用序列化级别来将RDD持久化,例如 MEMORY_ONLY_SER。随后,Spark将RDD的每个分区存储成一个个字节数组。这粒药丸只有一个副作用,那就是访问这些序列化的数据是需要多耗费些时间,因为在读取前需要先反序列化这些数据。如果你觉得你的Spark程序需要吃药的话,我们强烈建议你使用Kryo这一序列化格式来缓存你的数据,因为相比Java自带的序列化方式,Kryo可以让你的对象更瘦(这就是抽脂和整容流行的原因)。
当你的程序存储的RDD需要频繁轮换时,JVM垃圾回收可能会出现问题。(当对一个RDD仅读取一次,然后在其上进行多次操作时并不会带来问题)当Java需要回收老对象占用的空间时,它将扫描你所有的对象来找到其中不被使用的。需要指出的一点是,垃圾回收的消耗和你的Java对象个数成正比,因此你所应用的数据结构拥有的对象越少越好(例如使用int数组代替LinkedList)。一个更好的方法是使用序列化格式来持久化你的对象,如上所述:一旦序列化后,每个RDD将只对应一个对象(一个字节数组)。所以当存在GC问题时,在尝试其他技巧前,你首先要做的是使用序列化的缓存技术。
由于工作节点上任务工作内存和RDD缓存之间的冲突也会导致GC问题。我们将会讨论如何分配空间去存储RDD缓存来缓解这个问题。
第一步是收集关于垃圾处理的频率和GC消耗时间的统计数据。这个可以通过添加如下Java选项-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps来实现。
./bin/spark-submit --name "My app" --master local[4] --conf spark.eventLog.enabled=false
--conf "spark.executor.extraJavaOptions=-XX:+PrintGCDetails -XX:+PrintGCTimeStamps" myApp.jar
下次Spark启动时,每次GC的操作日志将会打印出来。需要注意的是这些日志将出现在你的集群工作节点上(在工作目录的标准输出文件里),而不是你的驱动程序里。
为了准备更深入的垃圾回收优化,我们先要理解一些关于JVM内存管理的基本知识:
这将帮你避免全面GC去收集Spark运行期间产生的临时对象。一些实用的小技巧如下:
我们的经验建议是,GC优化的成效依赖与你的应用和可用内存的多少。网上也有许多优化策略,但是需要更深的知识基础,例如通过控制全量GC发生的频率来降少总开销。
通过设置spark.executor.extraJavaOptions可以实现对执行器中GC的优化调整。
如果合理分配每个操作的并行度,将极大的发挥集群的优势。Spark按照文件的大小自动设置map任务数来处理每个文件(当然你可以通过设置SparkContext.textFile的可选参数来控制并行程度),并且对于分布式的reduce操作,例如groupByKey和reduceByKey他们使用父RDD的最大分区数来设置并行数。你也可以通过传递第二个参数来控制并行级别(参考spark.PairRDDFunctions 文档)或者设置启动时的并行级别来改变默认值(spark.default.parallelism),我们建议你为每个CPU分配2-3个并行任务。
有时,你会收到一个OOM的错误,因为你的RDD超出了内存的大小,这有可能是因为某个任务的工作集太大造成的,例如groupByKey这个reduce任务。Spark的混洗操作(例如sortByKey,groupByKey,reduceByKey,join等)在每个子任务中构造并维护了一个哈希表,来完成归类操作,通常情况下此表很大。最简单的修复方法是提升并行程度,以便使每个任务的输入足够小。Spark可以高效的支持短如200ms的任务,因为它可以跨多任务重用执行器的JVM,并且它的任务加载和启动开销非常小,因此你可以放心的增加并行度,甚至可以设置比集群核心总数更多的并行任务数。
使用SparkContext的广播功能极大的减少每个序列化人物的大小,并且降低集群中任务加载的开销。如果你的任务使用任何来自驱动器的大对象(例如一个静态查找表),应该考虑将这个大对象加载到广播变量中去。Spark可以打印序列化的任务的大小,因此你可以通过查看输出来判断你的作业是否太大了;通常情况下,一个大于20KB的对象是值得放进广播变量中来进行优化的。
数据的存放位置对于Spark作业的执行效率具有重要影响。如果数据和操作它的代码在一起的话,计算将是非常高效的。但是,如果代码和数据是分离的,一方需要移动到另一方那里。显而易见的是移动代码比移动数据高效的多,因为代码的字节数源小于数据。Spark是基于这一常规原则来构建她的数据存放策略的。
数据本地化是使得数据和在它之上的操作距离更近。这里列举了一些存放数据的级别,这些级别的划分是基于数据存储位置的,按照由近及远的顺序。
Spark程序理想状态是调度所有的子任务都处于最佳的存储策略之下,但这通常只是理想
这是一份优化Spark程序的简短向导。主要指出两项你需要注意的地方:数据序列化和内存优化。对于大多数程序,切换到Kryo序列化方式和使用序列化持久化你的数据将能解决大部分常见效率问题。
欢迎分享你的优化实践和心得