结合官网以及两篇高质量博客学习Spark性能调优,摘要几点加深理解
原文:
1、Spark开发调优有以下十大原则:
<1>尽量避免创建重复的RDD
<2>尽可能复用同一个RDD
<3>对多次使用的RDD进行持久化
以上3条原则基本讲述一个问题,就是对多次使用的RDD进行持久化,防止重复创建,并且尽可能多次使用
案例代码
// 如果要对一个RDD进行持久化,只要对这个RDD调用cache()和persist()即可。
// 正确的做法。
// cache()方法表示:使用非序列化的方式将RDD中的数据全部尝试持久化到内存中。
// 此时再对rdd1执行两次算子操作时,只有在第一次执行map算子时,才会将这个rdd1从源头处计算一次。
// 第二次执行reduce算子时,就会直接从内存中提取数据进行计算,不会重复计算一个rdd。
val rdd1 = sc.textFile("hdfs://192.168.0.1:9000/hello.txt").cache()
rdd1.map(...)
rdd1.reduce(...)
// persist()方法表示:手动选择持久化级别,并使用指定的方式进行持久化。
// 比如说,StorageLevel.MEMORY_AND_DISK_SER表示,内存充足时优先持久化到内存中,内存不充足时持久化到磁盘文件中。
// 而且其中的_SER后缀表示,使用序列化的方式来保存RDD数据,此时RDD中的每个partition都会序列化成一个大的字节数组,然后再持久化到内存或磁盘中。
// 序列化的方式可以减少持久化的数据对内存/磁盘的占用量,进而避免内存被持久化数据占用过多,从而发生频繁GC。
val rdd1 = sc.textFile("hdfs://192.168.0.1:9000/hello.txt").persist(StorageLevel.MEMORY_AND_DISK_SER)
rdd1.map(...)
rdd1.reduce(...)
对于拉取数据到driver时,sc.textFile("/a.txt").collect,有大小限制,当拉取数据超过限制时,会对拉取的数据进行截取,造成数据丢失,因此需要注意!!!!!!!
持久化选择策略:
默认情况下,性能最高的当然是MEMORY_ONLY,但前提是你的内存必须足够足够大,可以绰绰有余地存放下整个RDD的所有数据。
如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。
如果纯内存的级别都无法使用,那么建议使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略。
通常不建议使用DISK_ONLY和后缀为_2的级别:因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有RDD。
<4>尽量避免使用shuffle类算子
shuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。
因此在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。这样的话,没有shuffle操作或者仅有较少shuffle操作的Spark作业,可以大大减少性能开销。
Broadcast与map进行join代码示例(类似于hive的优先使用mapjoin,将小表加载到内存,此处使用广播变量,将小表加载到内存)
// 传统的join操作会导致shuffle操作。
// 因为两个RDD中,相同的key都需要通过网络拉取到一个节点上,由一个task进行join操作。
val rdd3 = rdd1.join(rdd2)
// Broadcast+map的join操作,不会导致shuffle操作。
// 使用Broadcast将一个数据量较小的RDD作为广播变量。
val rdd2Data = rdd2.collect()
val rdd2DataBroadcast = sc.broadcast(rdd2Data)
// 在rdd1.map算子中,可以从rdd2DataBroadcast中,获取rdd2的所有数据。
// 然后进行遍历,如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的,那么就判定可以进行join。
// 此时就可以根据自己需要的方式,将rdd1当前数据与rdd2中可以连接的数据,拼接在一起(String或Tuple)。
val rdd3 = rdd1.map(rdd2DataBroadcast...)
// 注意,以上操作,建议仅仅在rdd2的数据量比较少(比如几百M,或者一两G)的情况下使用。
// 因为每个Executor的内存中,都会驻留一份rdd2的全量数据。
小程序代码:
package test1
import org.apache.spark.{SparkConf, SparkContext}
object boradcastTest {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName("broast").setMaster("local")
val sc = new SparkContext(conf)
val RDD1 = sc.makeRDD(Array((1,"a"),(2,"b"),(3,"c")))
val RDD2 = sc.makeRDD(Array((1,98),(2,88),(3,77)))
//将rdd2作为广播变量
val rdd2broadcast = sc.broadcast(RDD2.collect())
//拼接rdd1和rdd2
RDD1.map(a=>(a._1,a._2,rdd2broadcast.value.toMap.get(a._1)))
.foreach(a=>print(a+"\t"))
}
}
结果:(1,a,Some(98)) (2,b,Some(88)) (3,c,Some(77))
<5>使用map-side预聚合的shuffle操作
所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。
<6>使用高性能的算子
a 使用reduceByKey/aggregateByKey替代groupByKey(原则5)
b 使用mapPartitions替代普通map
mapPartitions一次处理一个partition的数据,效率提高,但是注意一个partition数据过大会造成内存溢出
使用场景:对于频繁连接数据库获取连接,可以优先考虑使用
c 使用foreachPartitions替代foreach
与mapPartitions类似,一次遍历一个partition
d 使用filter之后进行coalesce操作
通常对一个RDD执行filter算子过滤掉RDD中较多数据后(比如30%以上的数据),建议使用coalesce算子,手动减少RDD的partition数量,将RDD中的数据压缩到更少的partition中去。因为filter之后,RDD的每个partition中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个task处理的partition中的数据量并不是很多,有一点资源浪费,而且此时处理的task越多,可能速度反而越慢。因此用coalesce减少partition数量,将RDD中的数据压缩到更少的partition之后,只要使用更少的task即可处理完所有的partition。
e 使用repartitionAndSortWithinPartitions替代repartition与sort类操作
该算子可以一边进行重分区的shuffle操作,一边进行排序。shuffle与sort两个操作同时进行,比先shuffle再sort来说,性能可能是要高的。
<7>广播大变量
有时在开发过程中,会遇到需要在算子函数中使用外部变量的场景(尤其是大变量,比如100M以上的大集合),那么此时就应该使用Spark的广播(Broadcast)功能来提升性能。
在算子函数中使用到外部变量时,默认情况下,Spark会将该变量复制多个副本,通过网络传输到task中,此时每个task都有一个变量副本。如果变量本身比较大的话(比如100M,甚至1G),那么大量的变量副本在网络中传输的性能开销,以及在各个节点的Executor中占用过多内存导致的频繁GC,都会极大地影响性能。
因此对于上述情况,如果使用的外部变量比较大,建议使用Spark的广播功能,对该变量进行广播。广播后的变量,会保证每个Executor的内存中,只驻留一份变量副本,而Executor中的task执行时共享该Executor中的那份变量副本。这样的话,可以大大减少变量副本的数量,从而减少网络传输的性能开销,并减少对Executor内存的占用开销,降低GC的频率。
具体代码见原则<4>
<8>使用Kryo优化序列化性能
- 在算子函数中使用到外部变量时,该变量会被序列化后进行网络传输
- 将自定义的类型作为RDD的泛型类型时(比如JavaRDD,Student是自定义类型),所有自定义类型对象,都会进行序列化。因此这种情况下,也要求自定义的类必须实现Serializable接口。
- 使用可序列化的持久化策略时(比如MEMORY_ONLY_SER),Spark会将RDD中的每个partition都序列化成一个大的字节数组。
对于这三种出现序列化的地方,我们都可以通过使用Kryo序列化类库,来优化序列化和反序列化的性能。Spark默认使用的是Java的序列化机制,也就是ObjectOutputStream/ObjectInputStream API来进行序列化和反序列化。但是Spark同时支持使用Kryo序列化库,Kryo序列化类库的性能比Java序列化类库的性能要高很多。官方介绍,Kryo序列化机制比Java序列化机制,性能高10倍左右。Spark之所以默认没有使用Kryo作为序列化类库,是因为Kryo要求最好要注册所有需要进行序列化的自定义类型,因此对于开发者来说,这种方式比较麻烦。
代码:
// 创建SparkConf对象。
val conf = new SparkConf().setMaster(...).setAppName(...)
// 设置序列化器为KryoSerializer。
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
// 注册要序列化的自定义类型。
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
<9>优化数据结构
Java中,有三种类型比较耗费内存:
- 对象,每个Java对象都有对象头、引用等额外的信息,因此比较占用内存空间。
- 字符串,每个字符串内部都有一个字符数组以及长度等额外信息。
- 集合类型,比如HashMap、LinkedList等,因为集合类型内部通常会使用一些内部类来封装集合元素,比如Map.Entry。
因此Spark官方建议,在Spark编码实现中,特别是对于算子函数中的代码,尽量不要使用上述三种数据结构,尽量使用字符串替代对象,使用原始类型(比如Int、Long)替代字符串,使用数组替代集合类型,这样尽可能地减少内存占用,从而降低GC频率,提升性能。
在机器性能允许的情况下,需要考虑代码的可维护性
<10>数据本地化
如果数据和运行在其上的代码在一起,那么计算速度会很快。但是,如果代码和数据是分开的,就必须有一个进行迁移。通常,由于代码大小比数据小得多,因此将序列化代码从一个地方运送到另一个地方比迁移数据块更快。Spark根据这个数据本地性的原则构建调度表。
数据的当前位置有几个级别的地点。从最近到最远的顺序:
PROCESS_LOCAL
数据与运行代码在同一个JVM中。这是可能的最佳地点NODE_LOCAL
数据在同一个节点上。示例可能位于同一节点上的HDFS中,也可能位于同一节点上的另一个执行器中。这比PROCESS_LOCAL
数据必须在进程之间传输要慢一点NO_PREF
数据可以从任何地方快速访问,并且没有地区偏好RACK_LOCAL
数据位于同一台服务器上。数据位于同一机架上的不同服务器上,因此需要通过网络进行发送,通常通过一台交换机进行发送ANY
数据位于网络上的其他位置,而不在同一个机架中
Spark会在最好的地点级别安排所有任务。在任何空闲执行程序中没有未处理的数据的情况下,Spark会切换到较低的本地级别。有两种选择:a)等待一个繁忙的CPU释放,以便在同一台服务器上的数据上启动一项任务,或者b)立即在远离需要移动数据的地方开始一项新任务。
Spark通常所做的就是等待繁忙CPU释放的希望。一旦该超时到期,它就开始将数据从远处移动到空闲的CPU。每个级别之间回退的等待超时可以单独配置或全部一起配置为一个参数;
spark.locality.wait 整体配置,默认3S
spark.locality.wait.process 优先选择等待,建议60s
spark.locality.wait.node 建议30s
spark.locality.wait.rack 建议20S
官网建议的其他调优方案:
GC调整