Spark性能优化

[亡魂溺海] 提交于 2019-12-06 03:09:44

Spark性能优化

1)避免创建重复RDD

2)尽可能复用同一个RDD

3)对多次使用的RDD进行持久化

4)尽量避免使用shuffle类算子

5)使用map-side预聚合的shuffle操作

6)使用高性能的算子

7)广播大变量

8)使用Kryo优化序列化性能

9)优化数据结构

10)资源参数调优

1)避免创建重复RDD

​ 对于同一份数据,只应该创建一个RDD,不能创建多个RDD来代表同一份数据。

2)尽可能复用同一个RDD

​ 除了要避免在开发过程中对一份完全相同的数据创建多个RDD之外,在对不同的数据执行算子 操作时还要尽可能地复用一个RDD。比如说,有一个RDD的数据格式是key-value类型的,另 一个是单value类型的,这两个RDD的value数据是完全一样的。那么此时我们可以只使用key-value类型的那个RDD,因为其中已经包含了另一个的数据。对于类似这种多个RDD的数据有重叠或者包含的情况,我们应该尽量复用一个RDD,这样可以尽可能地减少RDD的数量,从而尽可能减少算子执行的次数。

3)对多次使用的RDD进行持久化
Spark中对于一个RDD执行多次算子的默认原理是这样的:每次你对一个RDD执行一个算子操作时,都会重新从源头处计算一遍,计算出那个RDD来,然后再对这个RDD执行你的算子操作。因此对于这种情况,建议是:对多次使用的RDD进行持久化,此时Spark就会根据你的持久化策略,将RDD中的数据保存到内存或者磁盘中。以后每次对这个RDD进行算子操作时,都会直接从内存或磁盘中提取持久化的RDD数据,然后执行算子,而不会从源头处重新计算一遍这个RDD,再执行算子操作。

​ persist():手动选择持久化级别,并使用指定的方式进行持久化

Spark持久化级别
持久化级别 含义
MEMORY_ONLY 使用未序列化的Java对象格式,将数据保存在内存中。如果内存不够存放所有的数据,则数据可能就不会进行持久化。cache()使用的持久化策略
MEMORY_AND_DISK 使用未序列化的Java对象格式,优先尝试将数据保存在内存中,吐过内存不够存放所有的数据,会将数据写入磁盘文件中。
MEMORY_ONLY_SER 含义同MEMORY_ONLY,但会将RDD中的数据进行序列化,RDD的每个partition会被序列化成一个字节数组。
MEMORY_AND_DISK_SER 含义同MEMORY_AND_DISK,但会将RDD中的数据进行序列化,RDD的每个partition会被序列化成一个字节数组。
DISK_ONLY 使用未序列化的Java对象格式,将数据全部写入磁盘文件中。
MEMORY_ONLY_2, MEMORY_AND_DISK_2...... 对于以上任意一种持久化策略,如果加上后缀_2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上。这种基于副本的持久化机制主要用于进行容错,加入某个节点挂掉,节点的内存或磁盘中的持久化数据丢失了,那么后续对RDD计算时还可以使用该数据在其他节点上的副本。
如何选择一种最合适的持久化策略

​ 默认情况下,性能最高的当然是MEMORY_ONLY,但前提是你的内存必须足够大,可以绰绰有余地存放下整个RDD的所有数据。不进行序列化与反序列化的数据的操作,避免了部分性能开销,对于这个RDD的后续算子操作,都是基于纯内存的数据的操作,不需要从磁盘文件中读取数据,性能很高。但是必须要注意的是,在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果RDD中数据比较多时,直接用这种持久化级别,会导致JVM的OOM内存溢出异常。

​ 如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果RDD中的数据量过多的话,还是可能会导致OOM内存溢出的异常。

​ 如果纯内存的级别都无法使用,那么建议使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略。因为既然到了这一步,就说明RDD的数据量很大,内存无法完全放下。序列化后的数据比较少,可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中,内存缓存不下才会写入磁盘。

​ 通常不建议使用DISK_ONLY和后缀为_2的级别,因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有RDD。后缀为_2的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性,否则不建议使用。

4)尽量避免使用shuffle类算子

​ 在Spark作业运行过程中,最消耗性能的地方就是shuffle过程。Shuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉去各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输是shuffle性能较差的主要原因。

​ 因此在开发过程中,要尽量避免使用会导致shuffle的算子,尽量使用map类的非shuffle算子,可以大大减少性能开销。

使用广播变量与map替代join

val rddOld = rdd1.join(rdd2)//会导致Shuffle操作

val rdd2Data = rdd2.collect()//将rdd2的数据收集回来
val rdd2DataBroadcast = sc.broadcast(rdd2Data)//将rdd2作为广播变量
val rddNew = rdd.map(rdd2DataBroadcast......)//对相同的key进行拼接

//每个Executor存放一份广播变量
//建议将数据量比较少(几百M到一两个G)的rdd作为广播变量

5)使用map-side预聚合的shuffle操作

​ 如果因为业务需要,一定要使用shuffle操作,无法用map类的算子来替代,那么尽量使用可以map-side预聚合的算子。所谓map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。

6)使用高性能的算子

​ 1)groupByKey ==> reduceByKey / aggregateByKey //具体看第五点

​ 2)map ==> mapPartitions

​ mapPartitions类的算子,一次函数调用会处理一个partition所有的数据,而不是一次函数调用处理一条,性能相对来说会高一些。但是有的时候,使用mapPartitions会出现OOM的问题。因为单次函数调用就要处理掉一个partition所有的数据,如果内存不够,垃圾回收是无法回收掉大多对象的,很可能出现OOM异常,所以使用这类操作时要慎重。

​ 3)foreach ==> foreachPartitions

​ 原理类似于mapPartitions。在实践中发现,foreachpartitions类的算子,对性能的提升还是很有帮助的。比如在foreach函数中,将RDD中所有数据写MySQL,那么如果是普通的foreach算子,就会一条数据一条数据地写,每次函数调用可能就会创建一个数据库连接,此时就势必会频繁地创建和销毁数据库连接,性能是非常低下;但是如果用foreachPartitions算子一次性处理一个partition的数据,那么对于每个partition,只要创建一个数据库连接即可,然后执行批量插入操作,此时性能是比较高的。实践中发现,对于1万条左右的数据量写MySQL,性能可以提升30%以上。

​ 4)使用filter之后进行coalesce操作

​ 通常对一个RDD执行filter算子过滤掉RDD中较多数据后(比如30%以上的数据),建议使用coalesce算子,手动减少RDD的partition数量,将RDD中的数据压缩到更少的partition中去。因为filter之后,RDD的每个partition中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个task处理的partition中的数据量并不是很多,有一点资源浪费,而且此时处理的task越多,可能速度反而越慢。因此用coalesce减少partition数量,将RDD中的数据压缩到更少的partition之后,只要使用更少的task即可处理完所有的partition。在某些场景下,对于性能的提升会有一定的帮助。

​ 5)partition + sort ==> repartitionAndSortWithinPartitions

​ repartitionAndSortWithinPartitions是Spark官网推荐的一个算子,官方建议,如果需要在repartition重分区之后,还要进行排序,建议直接使用repartitionAndSortWithinPartitions算子。因为该算子可以一边进行重分区的shuffle操作,一边进行排序。shuffle与sort两个操作同时进行,比先shuffle再sort来说,性能是要高的。

7)广播大变量

​ 有时在开发过程中,会遇到需要在算子函数中使用外部变量的场景(尤其是大变量,100M以上的集合),那么久应该使用广播变量来提升性能。

​ 在算子函数中使用到外部变量时,默认情况下,Spark会将该变量复制多个副本,通过网络传输到task中,此时每个task都有一个变量副本。如果变量本身比较大的话,那么大量的变量副本在网络中传输的性能开销,以及在各个节点的Executor中占用过多内存导致的频繁GC,都会极大地影响性能。因此对于上述情况,如果使用的外部变量比较大,建议使用Spark的广播变量,对该变量进行广播。广播后的变量,会保证每个Executor的内存中,只驻留一份广播变量,而Executor中的task执行时共享该Executor中的那份共享副本。这样的话,可以大大减少变量副本的数量,从而减少网络传输的性能开销,并减少对Executor内存的占用开销,降低GC的频率。//代码看第四点

8)使用Kryo优化序列化性能

​ 在Spark中,主要有三个地方涉及了序列化:

​ 1)在算子函数中使用到外部变量时,该变量会被序列化后进行网络传输
2)将自定义的类型作为RDD的泛型类型时。所有自定义类型对象,都会进行序列化,因此在这种情况下,也要求自定义的类型必须实现Serializable接口
3)使用可序列化的持久化策略时(比如MEMORY_ONLY_SER),Spark会将RDD中的每个partition都序列化成一个大的字节数组

​ 对于这三种出现序列化的地方,我们都可以通过使用Kryo序列化类库,来优化序列化和反序列化的性能。Spark默认使用的是Java的序列化机制,也就ObjectOutputStream/ObjectInputStream API来进行序列化和反序列化。但是Spark同时支持使用Kyro序列化库,Kryo序列化库的性能比Java序列化库的性能要高很多。Spark之所以默认没有使用Kryo作为序列化库,是因为Kyro要求最好要注册所有需要进行序列化的自定义类型,因此对于开发者来说,这种方式比较麻烦。

​ 以下是使用Kryo的代码示例,我们只要设置序列化类,再注册要序列化的自定义类型即可

val conf = new SparkConf().setMaster(...).setAppName(...)
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")//设置序列化器为KyroSerializer
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))//注册要序列化的自定义类型

9)优化数据结构

​ Java中,有三种类型比较耗费内存:

​ 1)对象,每个Java对象都有对象头、引用等额外的信息,因此比较占用内存空间

​ 2)字符串,每个字符串内部都有一个字符数组以及长度等额外信息

​ 3)集合类型,因为集合类型内部通常会使用一些内部类来封装集合元素,比如Map.Entry

​ 因此Spark官方建议,在Spark编码实现中,特别是对于算子函数中的代码,尽量不要使用上述三种数据结构,尽量使用字符串替代对象,使用原始类型替代字符串,使用数组替代集合类型,这样可以减少内存占用,从而降低GC频率,提升性能。

10)资源参数调优

num-executors
参数说明:该参数用于设置Spark作业总共要用多少个Executor进程来执行。Driver在向YARN集群管理器申请资源时,YARN集群管理器会尽可能按照你的设置来在集群的各个工作节点上,启动相应数量的Executor进程。这个参数非常之重要,如果不设置的话,默认只会给你启动少量的Executor进程,此时你的Spark作业的运行速度是非常慢的。
参数调优建议:每个Sparkk作业的运行一般设置50~100个左右的Executor进程比较合适,设置太少或太多的Executor进程都不好。设置的太少,无法充分利用集群资源;设置的太多的话,大部分队列可能无法给予充分的资源。
--------------------------------------------------------------------------------
executor-memory
参数说明:该参数用于设置每个Executor进程的内存。Executor内存的大小,很多时候直接决定了
Spark作业的性能,而且跟常见的JVM OOM异常,也有直接的关联。
参数调优建议:每个Executor进程的内存设置4G~8G较为合适。但是这只是一个参考值,具体的设置还是得根据不同部门的资源队列来定。可以看看自己团队的资源队列的最大内存限制是多少,num-executors乘以executor-memory,是不能超过队列的最大内存量的。此外,如果你是跟团队里其他人共享这个资源队列,那么申请的内存量最好不要超过资源队列最大总内存的1/3~1/2,避免你自己的Spark作业占用了队列所有的资源,导致别的同学的作业无法运行。
--------------------------------------------------------------------------------
executor-cores
参数说明:该参数用于设置每个Executor进程的CPU core数量。这个参数决定了每个Executor进程并行执行task线程的能力。因为每个CPU core同一时间只能执行一个task线程,因此每个Executor进程的CPU core数量越多,越能够快速地执行完分配给自己的所有task线程。
参数调优建议:Executor的CPU core数量设置为2~4个较为合适。同样得根据不同部门的资源队列来定,可以看看自己的资源队列的最大CPU core限制是多少,再依据设置的Executor数量,来决定每个Executor进程可以分配到几个CPU core。同样建议,如果是跟他人共享这个队列,那么num-executors * executor-cores不要超过队列总CPU core的1/3~1/2左右比较合适,也是避免影响其他同学的作业运行。
--------------------------------------------------------------------------------
driver-memory
参数说明:该参数用于设置Driver进程的内存。
参数调优建议:Driver的内存通常来说不设置,或者设置1G左右应该就够了。唯一需要注意的一点是,如果需要使用collect算子将RDD的数据全部拉取到Driver上进行处理,那么必须确保Driver的内存足够大,否则会出现OOM内存溢出的问题。
--------------------------------------------------------------------------------
spark.default.parallelism
参数说明:该参数用于设置每个stage的默认task数量。这个参数极为重要,如果不设置可能会直接影响你的Spark作业性能。
参数调优建议:Spark作业的默认task数量为500~1000个较为合适。很多同学常犯的一个错误就是不去设置这个参数,那么此时就会导致Spark自己根据底层HDFS的block数量来设置task的数量,默认是一个HDFS block对应一个task。通常来说,Spark默认设置的数量是偏少的(比如就几十个task),如果task数量偏少的话,就会导致你前面设置好的Executor的参数都前功尽弃。试想一下,无论你的Executor进程有多少个,内存和CPU有多大,但是task只有1个或者10个,那么90%的Executor进程可能根本就没有task执行,也就是白白浪费了资源!因此Spark官网建议的设置原则是,设置该参数为num-executors * executor-cores的2~3倍较为合适,比如Executor的总CPU core数量为300个,那么设置1000个task是可以的,此时可以充分地利用Spark集群的资源。
--------------------------------------------------------------------------------
spark.storage.memoryFraction
参数说明:该参数用于设置RDD持久化数据在Executor内存中能占的比例,默认是0.6。也就是说,默认Executor 60%的内存,可以用来保存持久化的RDD数据。根据你选择的不同的持久化策略,如果内存不够时,可能数据就不会持久化,或者数据会写入磁盘。
参数调优建议:如果Spark作业中,有较多的RDD持久化操作,该参数的值可以适当提高一些,保证持久化的数据能够容纳在内存中。避免内存不够缓存所有的数据,导致数据只能写入磁盘中,降低了性能。但是如果Spark作业中的shuffle类操作比较多,而持久化操作比较少,那么这个参数的值适当降低一些比较合适。此外,如果发现作业由于频繁的gc导致运行缓慢(通过spark web ui可以观察到作业的gc耗时),意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。
--------------------------------------------------------------------------------
spark.shuffle.memoryFraction
参数说明:该参数用于设置shuffle过程中一个task拉取到上个stage的task的输出后,进行聚合操作时能够使用的Executor内存的比例,默认是0.2。也就是说,Executor默认只有20%的内存用来进行该操作。shuffle操作在进行聚合时,如果发现使用的内存超出了这个20%的限制,那么多余的数据就会溢写到磁盘文件中去,此时就会极大地降低性能。
参数调优建议:如果Spark作业中的RDD持久化操作较少,shuffle操作较多时,建议降低持久化操作的内存占比,提高shuffle操作的内存占比比例,避免shuffle过程中数据过多时内存不够用,必须溢写到磁盘上,降低了性能。此外,如果发现作业由于频繁的gc导致运行缓慢,意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。

静态内存管理

​ Spark最初采用的静态内存管理机制。内存交给JVM去管理,存储内存、执行内存和其他内存的大小在Spark应用程序运行期间均为固定的。用户可以应用程序启动前进行配置。

堆内内存的分配

1)Storage内存区域:由spark.storage.memoryFraction控制(默认为0.6,占系统内存的60%)

2)Execution内存区域:由spark.shuffle.memoryFraction控制(默认为0.2,占系统内存的20%)

3)其他:取决于上面两部分的大小(默认为0.2,占系统内存的20%)

1)Storage内存区域:

​ 1)可用的Storage内存:用于缓存RDD数据和broadcast数据,由spark.storage.safetyFraction决定(默认为0.9,占Storage内存的90%)

​ 2)用于unroll:缓存iterator形式的Block数据,由spark.storage.unrollFraction决定(默认为0.2,占Storage内存的20%)

​ 3)预留:可预防OOM

2)Execution内存区域:

​ 1)可用的Execution内存:用于缓存在shuffle过程中的中间数据,由spark.shuffle.safetyFraction控制(默认为0.8,占Execution内存的80%)

​ 2)预留:可预防OOM

3)其他:

​ 用户定义的数据结构或Spark内部元数据

可用的堆内内存的大小需要按照下面的方式计算:

​ 可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction

​ 可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction

​ 其中systemMaxMemory取决于当前JVM堆内内存的大小,最后可用的存储内存或者执行内存要在此基础上与各自的memoryFraction参数和safetyFraction参数相乘得出。

堆外内存

​ 堆外内存的空间分配较为简单,存储内存,执行内存的大小同样是固定的。

1)Storage内存:由spark.memory.storageFraction控制(默认为0.5,占堆外可用内存的50%)

2)Execution内存:(默认为0.5,占堆外可用内存的50%)

总结

​ 静态内存管理机制实现起来较为简单,但如果开发人员不熟悉Spark的存储机制,或没有根据具体的数据规模和计算任务做相应的配置,很容易造成“一半海水,一半火焰”的局面,即存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移除旧的内容以存储新的内容。因此,出现了新的内存管理机制:统一内存管理。但出于兼容旧版本应用程序的目的,Spark仍然保留了它的实现。

资源参数调优

spark-submit \ 
 --master yarn-cluster \ 
 --num-executors 100 \ 
 --executor-memory 6G \ 
 --executor-cores 4 \ 
 --driver-memory 1G \ 
 --conf spark.default.parallelism=1000 \ 
 --conf spark.storage.memoryFraction=0.5 \ 
 --conf spark.shuffle.memoryFraction=0.3

统一内存管理

堆外内存

1)Storage内存:由spark.memory.storageFraction控制(默认为0.5,占堆外可用内存的50%)

2)Execution内存:(默认为0.5,占堆外可用内存的50%)

动态占用机制:与静态内存管理一样,当双方空间都被占满后,若有新增内容双方都需要将其存储到磁盘。但是若己方空间不足对方空间空余则可占用对方空间。

​ Storage占用对方的内存可被淘汰。

​ Execution占用对方的内存不可被淘汰,只能等待释放。

总结

​ 凭借统一内存管理机制,Spark在一定程度上提高了对内和堆外内存资源的利用率,降低了开发者维护Spark内存的难度,但并不意味着开发者可以高枕无忧。如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾惠州,降低任务执行时的性能,因为缓存的RDD数据通常都是长期驻留内存的,所以要想充分发挥Spark的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!