文章目录
一、概述
- 性能瓶颈:CPU、网络带宽、内存(最集中的问题源)
=> 性能优化的关键在于对内存的使用进行调优(10亿级别的数据量) - 优化的主要手段包括
(1)使用高性能序列化类库
(2)优化数据结构
(3)对多次使用的RDD进行持久化/Checkpoint
(4)使用序列化的持久化级别
(5)Java虚拟机的GC调优
(6)提高并行度
(7)广播共享数据
(8)数据本地化
(9)reduceByKey和groupByKey的合理使用
(10)Shuffle调优(最核心最重要)
二、诊断内存消耗
- 内存消耗在哪里?
(1)每个Java对象都包含一个 对象头(主要包括了meta元数据信息,比如指向它的类指针),占用 16个字节 。如果一个对象本身很小,比如只包括了一个int field,那么它的对象头实际上比对象本身还要大。
(2)Java的 String对象,比它内部的原始数据多出了40个字节。 因为它内部除了使用char数组来保存内部的字符序列,还得保存诸如数组长度之类的额外信息。而且,由于String使用的是 UTF-16 编码,每个字符会占用2个字节。比如,包含10个字符的String,总共会占40+10*2=60个字节。
(3)Java中的集合类型,比如HashMap和LinkedList,内部使用的是链表数据结构,所以对链表中的每一个数据,都使用了Entry类型包装。Entry对象不光有对象头,还有指向下一个Entry的指针,通常占用8个字节。
(4)元素类型为基本数据类型(8个)的集合,内部通常会使用自动包装机制进行“装箱”来存储元素。 - 如何估计程序消耗的内存?
(1)首先,自定义RDD的并行度,又两种方式:在parallelize(),textFile()等方法中,传入 第二个参数,设置RDD的task/partition的数量 或者 使用SparkConf.set(),设置一个名为 spark.default.parallelism 的参数,统一设置这个Apllication的所有RDD的partition数量。
(2)其次,在程序中将RDD cache到内存中,即调用RDD.cache()。
(3)最后,观察Driver的log,会发现类似“INFO BlockManagerMasterRPCEndpoint:Added rdd_0_1 in memory on mbk.local:50311(size:717.5KB,free:332.3MB)”的日志信息。这就显示了每个partition占用了多少内存。
(4)将这个内存信息乘以partition数量,即可得出RDD的内存占用量。
三、高性能序列化类库
- Spark默认会在一些地方对数据进行序列化,比如Shuffle。此外,若自定义算子使用到外部的数据(比如Java内置类型或自定义类型),也需要让其实现可序列化。
- Spark提供的两种序列化机制:Spark在序列化的便捷性和性能进行了权衡
(1)默认情况下,Spark更倾向于序列化的便捷性使用Java原生的序列化机制——基于ObjectInputStream和ObjectOutputStream的序列化机制。但Java序列化机制的性能比不高(序列化反序列化的速度相对较慢且序列化得到的数据相对讲到,比较占用内存空间)。因此,如果当前Spark应用对内存很敏感,使用默认的Java序列化机制并不是最好的选择。
(2)Kryo序列化机制比Java序列化机制更快,而且序列化后占用的空间更小,通常比Java序列化数据占用的空间要小10倍。之所以不是默认序列化机制,是因为有些类型虽然实现了Serializable接口,但Kryo也不一定能进行序列化;此外,若要得到最佳性能,Kryo还要求在Spark应用中,对所有需要序列化的类型进行注册。 - 使用Kryo序列化机制
(1)若要使用Kryo序列化机制,首先要用SparkConf设置一个参数,使用 new SparkConf().set(“spark.serializer”,“org.apache.spark.serializer.KryoSerializer”) 即可将Spark的序列化器设置为KryoSerializer。
(2)Kryo要求“需要序列化的类”预先进行注册以获得最佳性能——如果不注册的话,那么Kryo必须时刻保存全限类名,反而占用不少内存。Spark默认对Scala中常用的类型自动注册至Kryo,都在AllScalaRegistry中,但若在自定义算子中使用了外部的自定义类型,那么仍然需要将其进行注册。
//scala版本
val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[xxx]))
val sc = new SparkContext(conf)
//java版本
SparkConf conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(xxx.class)
JavaSparkContext sc = new JavaSparkContext(conf
- 优化Kryo类库
(1)优化缓存大小
若被注册的需要序列化的自定义类型本身特别大,比如包含了超过100个field,那么会导致要序列化的对象过大。此时需要对Kryo本身进行优化。由于Kryo内部的缓存可能不够存放那么大的Class对象,所以就需要调用SparkConf.set()设置一个名为 spark.kryoserializer.buffer.mb 的参数。它的默认值为2,即最大能缓存2MB对象,然后进行序列化。
(2)预先注册自定义类型
虽然不注册自定义类型,Kryo也能正常工作,但对于需要序列化的每个对象,都会保存一份它的全限类名,反而会耗费大量内存。因此通常建议预先注册好需要序列化的自定义类型。 - Kryo类库的普遍使用场景
算子使用到外部的大数据量对象的情况。例如,在外部自定义了封装所有配置项的对象MyConfiguration,里面包含了100m的数据量。然后在算子中使用到这个100m的外部大对象。
四、优化数据结构
-
优化范围
主要优化自定义算子逻辑使用到的局部数据,或者算子外部的数据。 -
如何优化?
(1)优先使用数组即字符串,而不是集合类。 即优先使用array和String,而不是ArrayList,LinkedList,HashMap等集合。举例:
<1>将List<Integer> list = new ArrayList<>()替换为int[] arr = new int[],array本身比List少了额外信息的存储开销,还能使用基本数据类型int来存储数据,比包装类型Integer存储要节省内存的多
<2>通常企业级应用中的做法 :对于HashMap、List等数据结构,统一拼接成特殊格式的字符串,比如将Map<Integer,Person> pers = new HashMap<>()替换为"id:name,address|id:name,address…"(2)避免使用多层嵌套的对象结构,使用JSON字符串替换。
(3)对于某些可以避免的场景,尽量使用int替换String ,因为String虽然比ArrayList,HashMap等数据结构高效得多,占用内存量少得多,但依然有额外的信息存储时的消耗。(注意:在Spark应用中,id不要使用常用的UUID了,因为无法转换为int,直接使用自增的int型id即可)
五、对多次使用的RDD进行持久化cache/persist或Checkpoint
六、使用序列化的持久化级别
- 在对多次使用的RDD进行持久化操作的基础上,还可以对其性能进行进一步优化。因为RDD的数据很有可能持久化至内存或磁盘中,若此时内存大小不是特别充足,完全可以使用序列化的持久化级别(先序列化再持久化),比如MEMORY_ONLY_SER / MEMORY_DISK_SER等,通过RDD.persist(StorageLevel.MEMORY_ONLY_SER)语法进行设置。
- 数据序列化后再持久化,RDD的每个partition的数据,都会序列化为一个巨大的字节数组,可以大大减小对内存的消耗(但唯一的缺点在于获取RDD数据时,需要对其进行反序列化,会增大其性能开销)。此外,数据量骤减后,若在写入磁盘,则磁盘IO消耗也比较小。
- 对于序列化的持久化级别,使用Kryo类库还可以进一步优化。
七、JVM GC机制调优
- GC的性能开销与内存中对象数量(数据量)成正相关。
- 对于GC性能,首先确保使用更高效的数据结构,比如array和String,其次在持久化RDD时,使用序列化的持久化级别且Kryo序列化,这样每个partition只是一个对象——一个字节数组。这样做的目的是减少内存的消耗,GC的频率也就降低且每次回收的数据量减少。
- GC是一条线程,运行时将暂停Task工作线程,直接导致了Task的执行停止,进而影响了Spark应用的运行速度,大幅降低了性能。
- 检测GC,如频率,耗时等
(1)在spark-submit脚本中,增加一个配置:
–conf “spark.executor.extraJavaOptions=-verbose gc -XX + PrintGCDetails -XX + PrintGCTimeStamps”
但要记住,该相关信息被输出到Worker上的日志中,而不是Driver的日志中。
(2)通过SparkUI(4040)观察每个stage的垃圾回收情况。 - 优化executor内存比例(一般调优的最高级别,再深入的JVM-Eden调优太复杂)
使用 new SparkConf().set(“spark.storage-memoryFraction”,“0.5”)即可将RDD缓存占用的内存空间降低,将更多空间给Task创建对象时使用,Task线程被GC终端额频率降低。(内部原理关键字:年轻代:EDEN,SURVIVOR1,SURVIVOR2,Minor GC;老年代:Full GC)
【防裂说明】
默认情况下,Executor的内存空间按照6:4划分,其中60%存放RDD分区缓存(如执行RDD.cache()对partition进行缓存)以及40%存放Task运行期间动态创建的对象。因此,在默认情况下,由于分配给Task运行期间创建对象的内存偏小,很可能导致频繁的GC查找、消除、回收、再填充。继而频繁中断Task线程,降低Spark应用程序的性能。
八、提高并行度
- Spark会自动设置”文件作为数据源“的RDD的并行度(依据其大小),如HDFS会给每一个Block创建一个partition,并依据划分后的partition设置并行度。对于reduceByKey等Shuffle操作,使用并行度最大的父RDD的并行度。可以手动使用textFile()、parallelize()等方法的第二个参数设置并行度,也可以通过参数 spark.default.parallelism 设置统一的并行度。
- Spark官方的推荐是,为集群中的每个CPU Core设置2-3个Task。示例如下:
在spark-submit设置了executor数量是10个,每个executor要求分配2个core,则该应用总共将使用20个core。此时可以设置new SparkConf().set(“spark.default.parallelism”,“60”),即每一个RDD的数据被划分为60份形成partition,每个partition启动1个Task,每个core执行3个Task连续运转
九、广播共享数据
默认情况下,算子将使用到的外部数据拷贝到每一个Task中,若使用中的外部数据量很大,重复的外部数据将在网络通信过程中占用大量的网络传输资源且消耗大量内存。
=> 使用Broadcast广播变量,在节点层面保留一份副本共享,而不是每个Task一份副本,大大减少了每个节点上的内存占用。
十、数据本地化(计算逻辑与数据)
- 通常来说,因为代码数据量较小,移动代码到其他节点,会比移动数据到代码所在的节点上去速度快得多,Spark也是基于这个数据本地化的原则来构建Task调度算法的。
- 几种数据本地化级别:
【说明】以前两级为例:本地化级别标识 说明 PROCESS_LOCAL 数据和对应的计算代码处于同一个JVM进程 NODE_LOCAL 数据和对应的计算代码处于同一节点上,但不在同一个进程中。比如在不同的executor进程中或者数据在HDFS文件的Block中 NO_PREF 数据从哪里过来,性能都是一样的 RACK_LOCAL 数据和对应计算代码在一个机架上 ANY 数据可能在任意地方,比如其他网络环境内,或者其他机架上
默认情况下,TaskSchedulerImpl从小到大去查找要处理的数据partition来启动Task,即尽量在包含了目标partition的executor中启动。此时目标executor可能正在运行多个Task没有空闲资源,Spark将在该级别(PROCESS_LOCAL)下进行一段时间等待(等待空闲的executor core执行Task)。若等待超时(其时间通过系列参数spark.locality设置,spark.locality.wait=3000默认,spark.locality.wait.node,spark.locality.wait.process,spark.locality.wait.rack),没有任何一个core被释放,那么久放大一个级别(NODE_LOCAL),去启动这个Task。Task将调用RDD.iterator(),通过executor关联的BlockManager来尝试获取数据(首先尝试getLocal()在本地查找数据,若没有将使用getRemote(),通过BlockTransferService连接到有数据的BlockManager来获取数据)。
十一、redeceByKey()与groupByKey()
若能使用reduceByKey则尽量使用,因为它会在Map端先进行本地combine以成倍聚合要传输到Reduce端的数据量,减少网络传输的开销。而groupByKey()单纯将ShuffleMapTask的数据原封不动地拉取到ResultMapTask内存中,所有的原始数据都会经过网络传输。因此,只有在reduceByKey等处理不了时,才使用groupByKey().map()来替代。
十二、※Shuffle的参数优化※
参数 | 说明 |
---|---|
spark.shuffle.consolidateFiles | 是否开启Shuffle Block File的合并,默认为false。 Consolidation的机制(同一批次内并行的ShuffleMapTask创建不同的文件,下一批次复用这些文件,往复【理解”组替换“】),大幅度降低磁盘I/O数量,主要提高Shuffle Write过程文件与磁盘的的性能。 |
spark.reducer.axSizeInFlight | reduce task一次拉取数据量的缓存大小,默认为48M。 适当加大该参数,减少拉取次数。 |
spark.shuffle.file.buffer | Map端写磁盘前bucket缓存大小,默认为2K。适当加大该参数,减少溢出写磁盘次数。 |
spark.shuffle.io.maxRetries | 拉取失败的最大尝试次数,默认为3。 与下一个参数一起解决,拉取MapTask时Executor的jvm正在full gc导致工作线程停止而大量目标文件由于超时而丢失。 |
spark.shuffle.io.retryWait | 拉取失败的重试间隔,默认为5。 |
spark.shuffle.memoryFraction | 指定Reduce端聚合的内存比例,默认为0.2。超出该比例将溢出写磁盘。 |