在开发过程中需要时刻注意开发调优的一些基本原则,并将这些原则根据具体的业务以及实际的应用场景,灵活地运用到自己的spark作业开发中。
1. 避免创建重复的RDD
对于同一份数据,只应该创建一个RDD,不能创建多个RDD来代表同一份数据。
错误示例 :对于一份数据执行多次算子操作时,使用多个RDD
// 需要从HDFS上两次加载hello.txt文件的内容,并创建两个单独的RDD
val rdd1 = sc.textFile("hdfs://xydwns/user/hive/warehouse/xy_temp.db/hello.txt")
rdd1.map(...)
val rdd2 = sc.textFile("hdfs://xydwns/user/hive/warehouse/xy_temp.db/hello.txt")
rdd2.reduce(...)
正确示例:对于一份数据执行多次算子操作时,只使用一个RDD。
// 正确用法:对于一份数据执行多次算子操作时,只使用一个RDD。
val rdd1 = sc.textFile("hdfs://xydwns/user/hive/warehouse/xy_temp.db/hello.txt")
rdd1.map(...)
rdd1.reduce(...)
由于rdd1被执行了两次算子操作,第二次执行reduce操作的时候,还会再次从源头处重新计算一次rdd1的数据,因此还是会有重复计算的性能开销,对多次使用的RDD进行持久化”,才能保证一个RDD被多次使用时只被计算一次。
2. 尽可能复用同一个RDD
除了要避免在开发过程中对一份完全相同的数据创建多个RDD之外,在对不同的数据执行算子操作时还要尽可能地复用一个RDD。
错误做法
// 对rdd1执行了一个map操作,而rdd2中的数据仅仅是rdd1中的value值而已,是rdd1的子集
JavaPairRDD<Long, String> rdd1 = ...
JavaRDD<String> rdd2 = rdd1.map(...)// 分别对rdd1和rdd2执行了不同的算子操作。
rdd1.reduceByKey(...)
rdd2.map(...)
正确做法
// 正确的做法
JavaPairRDD<Long, String> rdd1 = ...
rdd1.reduceByKey(...)
rdd1.map(tuple._2...)
对rdd1我们还是执行了两次算子操作,rdd1实际上还是会被计算两次,对多次使用的RDD进行持久化”进行使用,才能保证一个RDD被多次使用时只被计算一次
3. 对多次使用的RDD进行持久化
针对上面1和2正确做法中还存在的问题,再做进一步优化
正确示例1
// cache()方法表示:使用非序列化的方式将RDD中的数据全部尝试持久化到内存中。
val rdd1 = sc.textFile("hdfs://xydwns/user/hive/warehouse/xy_temp.db/hello.txt").cache()
rdd1.map(...)
rdd1.reduce(...)
正确示例2
// persist()方法表示:手动选择持久化级别,并使用指定的方式进行持久化。
// 序列化的方式可以减少持久化的数据对内存/磁盘的占用量,进而避免内存被持久化数据占用过多,从而发生频繁GC。
val rdd1 = sc.textFile("hdfs://192.168.0.1:9000/hello.txt").persist(StorageLevel.MEMORY_AND_DISK_SER)
rdd1.map(...)
rdd1.reduce(...)
4. 尽量避免使用shuffle类算子
Spark作业运行过程中,最消耗性能的地方就是shuffle过程。shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作,比如reduceByKey、join、distinct、repartition等算子,都会触发shuffle操作,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作,磁盘IO和网络数据传输也是shuffle性能较差的主要原因。
在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。
假设rdd1数据量很大,rdd2数据量比较小(比如几百M)
错误示例
// 因为两个RDD中,相同的key都需要通过网络拉取到一个节点上,由一个task进行join操作。
val rdd3 = rdd1.join(rdd2)
正确示例
// broadcast+map的join操作,不会导致shuffle操作。
val rdd2Data = rdd2.collect()
val rdd2DataBroadcast = sc.broadcast(rdd2Data)
val rdd3 = rdd1.map(rdd2DataBroadcast...)
5. 使用map-side预聚合的shuffle操作
有时候因业务需要,不得不使用shuffle操作,无法用broadcast+map来替代,那么尽量使用可以map-side预聚合的算子。
所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。
比如,通常情况下建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。
6. 使用高性能的算子
- reduceByKey/aggregateByKey替代groupByKey
- mapPartitions替代普通map
- foreachPartitions替代foreach
- filter之后进行coalesce操作
- repartitionAndSortWithinPartitions替代repartition与sort
7. 广播大变量
有时在开发过程中,会遇到需要在算子函数中使用外部变量的场景(尤其是大变量,比如100M以上的大集合),那么此时就应该使用Spark的广播(Broadcast)功能来提升性能。
在算子函数中使用到外部变量时,默认情况下,Spark会将该变量复制多个副本,通过网络传输到task中,此时每个task都有一个变量副本。如果变量本身比较大的话(比如100M,甚至1G),那么大量的变量副本在网络中传输的性能开销,以及在各个节点的Executor中占用过多内存导致的频繁GC,都会极大地影响性能。
因此对于上述情况,如果使用的外部变量比较大,建议使用Spark的广播功能,对该变量进行广播。广播后的变量,会保证每个Executor的内存中,只驻留一份变量副本,而Executor中的task执行时共享该Executor中的那份变量副本。这样的话,可以大大减少变量副本的数量,从而减少网络传输的性能开销,并减少对Executor内存的占用开销,降低GC的频率。
假设list1变量几百M甚至1-2G
错误示例
// 此时没有做任何特殊操作,每个task都会有一份list1的副本。
val list1 = ...
rdd1.map(list1...)
正确示例
// 以下代码将list1封装成了Broadcast类型的广播变量
val list1 = ...
val list1Broadcast = sc.broadcast(list1)
rdd1.map(list1Broadcast...)
8. 使用Kryo优化序列化性能
在Spark中,主要有三个地方涉及到了序列化:
- 在算子函数中使用到外部变量时,该变量会被序列化后进行网络传输
- 将自定义的类型作为RDD的泛型类型时(比如JavaRDD,Student是自定义类型),所有自定义类型对象,都会进行序列化
- 使用可序列化的持久化策略时(比如MEMORY_ONLY_SER),Spark会将RDD中的每个partition都序列化成一个大的字节数组
Spark支持的序列化机制
- Java的序列化机制:是spark默认使用的序列化机制;性能比较差
- Kryo序列化机制:比Java序列化机制,性能高10倍左右;但要求最好要注册所有需要进行序列化的自定义类型,比较麻烦
Kryo序列化示例
// 创建SparkConf对象
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")// 设置序列化器为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官方建议,特别是对于算子函数中的代码,尽量不要使用上述三种数据结构,尽量使用字符串替代对象,使用原始类型(比如Int、Long)替代字符串,使用数组替代集合类型,这样尽可能地减少内存占用,从而降低GC频率,提升性能。这个建议仅供参考,开发过程中还是要结合实际情况。
参考