实际工作中的Spark程序优化

本篇博客要点如下:

一.Spark编程优化

二.参数调优

三参考资料

Spark编程优化
RDD复用
避免创建重复的RDD
该问题主要出现在比较复杂的编程场景中,存在一份数据需要使用多次的情况,由于编程人员疏忽对同一份数据创建了多个RDD

正确使用 : 对一份数据执行多次算子操作,只创建一个RDD
尽可能复用RDD
 对于rdd有重合或者相互包含的情况
 例如 : 一个value型的RDD是一个<key,value>型RDD的真子集, 这个时候,我们可以只使用<key, value>型的RDD
 通过这样的方式尽可能的减少RDD的数量,从而减少算子计算的次数,提高程序执行的性能
RDD持久化
对经常使用的RDD进行持久化
	Spark对于RDD的计算方式默认是这样的 :
	对每一个RDD执行运算操作时,都会从源头处重新计算一遍
	
	对于需要多次复用的RDD,使用这种默认机制处理,无疑会消耗大量的资源,
	这个时候,就需要对这样的RDD进行持久化
	(实际使用Spark进行编程的过程中,使用次数超过两次的RDD都会进行持久化)

spark提供了两种持久化机制

 val dataset = spark.read.parquet(file)
 dataset.cache() // 使用cache机制进行持久化,默认持久化到内存
 dataset.persist(StorageLevel.MEMORY_AND_DISK_SER) // 使用persist指定持久化策略

通过如下方式来释放缓存

dataset.unpersist()
dataset.unpersist(true) //true 指定的是是否阻塞进程,直到所有block都被删除。
选择合适的持久化策略
使用持久化处理数据的过程中,一个必须要开发者考虑的问题就是如何选择合适的持久化策略

常用的持久化策略有以下几种 :

持久化级别含义解释
MEMORY_ONLY将数据保存在内存中,内存不够存放现有数据,数据不会进行持久化. 默认的持久化策略
MEMORY_AND_DISK优先将数据保存在内存中,内存不够,会将数据写入磁盘
MEMORY_ONLY_SER基本同MEMORY_ONLY,会将RDD数据进行序列化,更节约内存
MEMORY_AND_DISK_SER基本含义同MEMORY_AND_DISK,会将RDD数据进行序列化,避免持久化数据占用过多内存导致频繁GC
DISK_ONLY将数据全部写入磁盘文件中
MEMORY_ONLY_2, MEMORY_AND_DISK_2与MEMORY_ONLY,MEMORY_AND_DISK的级别相同,复制一份副本,将副本保存在其它节点上.
OFF_HEAP (experimental)与MEMORY_ONLY_SER类似, 但是将数据存储在堆外内存中,需要开启堆外内存
实际工作中,个人通常使用MEMORY_ONLY(默认), 和 MEMORY_AND_DISK_SER 两种持久化策略

如果集群内存足够支撑数据处理,就选用默认的持久化级别,处理性能最优
如果集群内存不足以支撑数据处理,推荐使用 MEMORY_AND_DISK_SER, 我实际生产中最常用的持久化方式
使用Kryo优化序列化性能
在使用spark编程的过程中,相信大家经常碰到 : 
	Caused by: java.io.NotSerializableException
也就是说我们的类没有序列化
spark 1.* 默认使用的是java的序列化接口 
该序列化接口使用简单,但是速度慢,占用空间大
如果我们想要进一步压榨程序性能,就可以尝试使用Kryo这种序列化方式

本来想介绍一下我们使用Kryo序列化的方式,可是说来惭愧, 之前使用Kryo序列化,仅仅是听说它的性能更优秀
在使用的时候并没有针对此进行过实际的性能测试, 关于Kryo序列化,在网上找了一篇不错的博客,博客链接如下:

利用Kryo序列化库是你提升Spark性能要做的第一件事

使用广播变量

很多情况下,我们会在多个并行操作中使用同一个变量(该变量比较大), Spark默认会为每个操作分别发送
这种场景下,默认的处理是很低效的,这个时候,就可以通过合理的使用广播变量进行优化
广播变量从功能上类似于Hadoop的分布式缓存, 但是它可以跨作业共享

使用广播变量的过程如下:

(1) 通过对一个类型 T 的对象调用 SparkContext.broadcast 创建出一个 Broadcast[T] 对象。任何可序列化的类型都可以这么实现。

(2) 通过 value 属性访问该对象的值(在 Java 中为 value() 方法)。

(3) 变量只会被发到各个节点一次,应作为只读值处理(修改这个值不会影响到别的节点)。

import org.apache.spark.{SparkConf, SparkContext}

/**
  * @author xmr
  * @date 2019/8/15 16:00
  * @description
  */
object BroadcastTest {
  def main(args: Array[String]): Unit = {
    val conf = new SparkConf().setMaster("local").setAppName("broadcast")
    val sc = new SparkContext(conf)
    val list = List("hello world")
    val broadcast = sc.broadcast(list)
    val linesRDD = sc.textFile("./word")
    linesRDD.filter(line => {
      broadcast.value.contains(line)
    }).foreach(println)
    sc.stop()
  }
}
合适的算子选择
尽量避免使用shuffle算子

相信用spark开发的童鞋都有这样的感受, spark程序执行的性能消耗主要在shuffle阶段, 更严重的是,spark程序失败也常常是在shuffle过程中发生的

shuffle过程,主要是将分布在集群中多个节点的同一个key拉取到同一个节点上,进行聚合或join等操作
比如说 : reduceByKey,join等算子,都会触发shuffle操作
shuffle过程中,各个节点上的相同的key都会先写入本地磁盘文件中,然后其它节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key.
而相同key都拉取到同一个节点进行聚合操作时,可能因为某个节点上处理的key过多,导致内存不够存放.
进而溢写到磁盘文件中,因此在shuffle过程中,可能会发生大量磁盘文件读写的IO操作,以及数据的网络传输操作.
这些是shuffle性能较差的主要原因

基于上面所述,在开发过程中,要尽量避免使用reduceByKey,join,distinct,repartition等进行shuffle的算子,尽量使用map类的非shuffle算子.
这样,可以大大减少性能开销。

spark里面,会产生Shuffle操作的算子如下,使用的时候尽可能避免:

去重

def distinct()
def distinct(numPartitions: Int)

聚合

def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)]
def groupBy[K](f: T => K, p: Partitioner):RDD[(K, Iterable[V])]
def groupByKey(partitioner: Partitioner):RDD[(K, Iterable[V])]
def aggregateByKey[U: ClassTag](zeroValue: U, partitioner: Partitioner): RDD[(K, U)]
def aggregateByKey[U: ClassTag](zeroValue: U, numPartitions: Int): RDD[(K, U)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C): RDD[(K, C)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C, numPartitions: Int): RDD[(K, C)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C, partitioner: Partitioner, mapSideCombine: Boolean = true, serializer: Serializer = null): RDD[(K, C)]

排序

def sortByKey(ascending: Boolean = true, numPartitions: Int = self.partitions.length): RDD[(K, V)]
def sortBy[K](f: (T) => K, ascending: Boolean = true, numPartitions: Int = this.partitions.length)(implicit ord: Ordering[K], ctag: ClassTag[K]): RDD[T]

重分区

def coalesce(numPartitions: Int, shuffle: Boolean = false, partitionCoalescer: Option[PartitionCoalescer] = Option.empty)
def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null)

集合或者表操作

def intersection(other: RDD[T]): RDD[T]
def intersection(other: RDD[T], partitioner: Partitioner)(implicit ord: Ordering[T] = null): RDD[T]
def intersection(other: RDD[T], numPartitions: Int): RDD[T]
def subtract(other: RDD[T], numPartitions: Int): RDD[T]
def subtract(other: RDD[T], p: Partitioner)(implicit ord: Ordering[T] = null): RDD[T]
def subtractByKey[W: ClassTag](other: RDD[(K, W)]): RDD[(K, V)]
def subtractByKey[W: ClassTag](other: RDD[(K, W)], numPartitions: Int): RDD[(K, V)]
def subtractByKey[W: ClassTag](other: RDD[(K, W)], p: Partitioner): RDD[(K, V)]
def join[W](other: RDD[(K, W)], partitioner: Partitioner): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)], numPartitions: Int): RDD[(K, (V, W))]
def leftOuterJoin[W](other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]
在Map端进行预聚合

该优化主要针对于groupByKey这个恶心的算子

	之前上线过一个spark应用,总是在执行到某个过程就挂掉,
	通过分析它的DAG图,和运行日志,最后将发生错误的地方定位到了groupByKey这个算子, 后来使用了reduceByKey进行替代,程序才得以正常执行
	
	原因 : groupByKey算子不会进行预聚合,全量的数据在集群的各个节点之间分发和传输,性能很差,而且数据量较大的时候会导致程序运行失败
	
	这种情况下,需要使用reduceByKey或者aggergateByKey算子来替代groupByKey算子,也就是标题提到的在map端进行聚合
	
	这种操作类似于mapReduce程序的combiner过程,即在每个节点对相同的key进行一次聚合操作, 聚合之后,每个节点就只有一条相同的key<其它节点在拉取所有节点上面相同的key是,就会大大减少需要拉取的数据数量,大大减少了磁盘IO以及网络传输开销
选用更高性能的算子

个人工作中常用的主要包含以下几种:

map 与 mapPartitions

1.优缺点

mapPartition的优点:

普通的map执行一个partition中有1.2万条数据。ok,那么function要执行和计算1.2万次。

如果使用MapPartitions操作之后,一个task仅仅会执行一次function,function一次接收所有的partition数据。只要执行一次就可以了,性能比较高。

mapPartition的缺点:

普通的map操作一次function的执行就处理一条数据。那么如果内存不够用的情况下,回收内存就OK,一般来说普通的map操作通常不会导致内存的OOM异常。

但是MapPartitions操作,对于大量数据来说,比如甚至一个partition,200万数据,一次传入一个function以后,那么可能一下子内存不够,可能导致OOM,内存溢出。

2.使用场景

在项目中先要预估一下每个partition中rdd的数量,同时了解内存大小,当分析的数据量不是特别大的时候且不会出现OOM,选择MapPartitions。

foreach 与 foreachPartition

1.优缺点

foreachPartition的优点:

foreachPartition一般是用来将处理好的数据保存到数据库,使用它有三大好处:

    调用一次func函数,一次传入一个partition所有的数据
    一个分区创建一个数据库连接(数据库连接的创建和销毁,都是非常非常消耗性能的)
    只需要向数据库发送一次SQL语句和多组参数即可(多次发送SQL语句,非常消耗性能)。
参数调优

这里介绍到的参数直接来自于最新版本的Spark2.4.3官方文档
因为是最新版本,所以参数名称可能和我们经常使用的名称有所差别,
但若读者有过真正的spark开发经验,相信很容易知道这些参数对应这什么

Spark2.4.3官网文档链接:

Executor参数设置
	Executor的参数,常用的有以下几种:
参数名称默认值含义解释
spark.executor.memory1gAmount of memory to use per executor process, in the same format as JVM memory strings with a size unit suffix (“k”, “m”, “g” or “t”) (e.g. 512m, 2g).
spark.executor.cores1 in YARN mode, all the available cores on the worker in standalone and Mesos coarse-grained modes.The number of cores to use on each executor. In standalone and Mesos coarse-grained modes, for more detail, see this description.
Driver参数设置

Driver参数,常用的包含以下几种 :

参数名称默认值含义解释
spark.driver.maxResultSize1gLimit of total size of serialized results of all partitions for each Spark action (e.g. collect) in bytes. Should be at least 1M, or 0 for unlimited. Jobs will be aborted if the total size is above this limit. Having a high limit may cause out-of-memory errors in driver (depends on spark.driver.memory and memory overhead of objects in JVM). Setting a proper limit can protect the driver from out-of-memory errors.
spark.driver.memory1gAmount of memory to use for the driver process, i.e. where SparkContext is initialized, in the same format as JVM memory strings with a size unit suffix (“k”, “m”, “g” or “t”) (e.g. 512m, 2g). Note: In client mode, this config must not be set through the SparkConf directly in your application, because the driver JVM has already started at that point. Instead, please set this through the --driver-memory command line option or in your default properties file.
spark.driver.memoryOverheaddriverMemory * 0.10, with minimum of 384The amount of off-heap memory to be allocated per driver in cluster mode, in MiB unless otherwise specified. This is memory that accounts for things like VM overheads, interned strings, other native overheads, etc. This tends to grow with the container size (typically 6-10%). This option is currently supported on YARN and Kubernetes.
spark.dynamicAllocation.initialExecutorsspark.dynamicAllocation.minExecutorsInitial number of executors to run if dynamic allocation is enabled If --num-executors (or spark.executor.instances) is set and larger than this value, it will be used as the initial number of executors.

关于dirver和executor的关键参数,我们通常在执行jar包的时候通过命令行进行设置,如下:
是一个生产环境下真实的jar包运行命令 :
集群内存 : 1.2T,集群cores300个左右
使用如下命令 : 基本上能够保证集群的内存资源和core资源被占满

spark-submit --class cn.mastercom.bigdata.main.DoJob --master yarn --queue root.queue1 --executor-memory 8g  --num-executors 200 --executors-cores 2 --driver mermory 4g spark.jar 
并行度设置

这个参数应该是所有参数里面最重要的一个!

举一个惨痛的经历, 刚开始写spark程序的时候,有一个需求是处理日志文件, 每天大约10-20G
集群的资源(可用的队列内存400多G)相对于这点数据量来说,是完全够用的!

但是,我写出来的程序在几乎将集群资源占用满的情况下,跑这么点数据量,居然需要4个小时!!!
是的,你没有听错,我也没有开玩笑

后来发现主要的原因就是这些日志文件在HDFS上面,每个仅有100多KB,居然有接近7W个
显然,这种将大量小文件存放在HDFS文件系统的设计是及其不合理的,
但是,我的程序居然没有设置并行度, 而且 executor-memory这个参数,我居然丧心病狂的设置了8G
于是,我的程序起了将近7W个TASK,现在想来,能4个小时处理完也完全不过分

后面,当然就是设置一个合理的并行度, 减少 executor-memory的大小, 并且在数据落地到HDFS的时候,设置大小为128M
经过此优化之后, 程序执行时间降低到了不到5分钟!

下面介绍下我在实际生产中的并行度设置:

//通过executorNum, executorCoreNum两个参数的设置来计算并行度(因此,这两个参数设置要合理)
String parallelism = calcParallelism(executorNum, executorCoreNum);
private static String calcParallelism(String executorNum, String executorCoreNum){
		//官方原话: In general, we recommend 2-3 tasks per CPU core in your cluster.
		//即 num-partitions = 2(3) * executor-cores * num-executors 
		if(executorCoreNum != null && executorNum != null){
			return String.valueOf(Integer.parseInt(executorCoreNum) * Integer.parseInt(executorNum) * 2);
		}
		return "200"; // 默认值取200(因为我们的数据量通常比较大)
	}

// 设置并行度
	conf.set("spark.default.parallelism", conf.get("spark.default.parallelism", parallelism));

应用该参数的办法 :

 sc.textFile("filePath").coalesce(parallelism ); //创建rdd的时候设置并行度
 // 通常,进行完过滤操作也要重新设置并行度,如果过滤掉的数据非常多的话,并行度可以设置的小一点
其它参数设置

除了上述提到的参数,实际工作中,调优时主要还涉及到下面几个参数:

参数名称默认值含义解释参数调优建议
spark.storage.memoryFraction默认是0.6该参数用于设置RDD持久化数据在Executor内存中能占的比例,。也就是说,默认Executor 60%的内存,可以用来保存持久化的RDD数据。根据你选择的不同的持久化策略,如果内存不够时,可能数据就不会持久化,或者数据会写入磁盘。如果Spark作业中,有较多的RDD持久化操作,该参数的值可以适当提高一些,保证持久化的数据能够容纳在内存中。避免内存不够缓存所有的数据,导致数据只能写入磁盘中,降低了性能。但是如果Spark作业中的shuffle类操作比较多,而持久化操作比较少,那么这个参数的值适当降低一些比较合适。此外,如果发现作业由于频繁的gc导致运行缓慢(通过spark web ui可以观察到作业的gc耗时),意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。
spark.shuffle.memoryFraction默认是0.2该参数用于设置shuffle过程中一个task拉取到上个stage的task的输出后,进行聚合操作时能够使用的Executor内存的比例,,Executor默认只有20%的内存用来进行该操作。shuffle操作在进行聚合时,如果发现使用的内存超出了这个20%的限制,那么多余的数据就会溢写到磁盘文件中去,此时就会极大地降低性能。如果Spark作业中的RDD持久化操作较少,shuffle操作较多时,建议降低持久化操作的内存占比,提高shuffle操作的内存占比比例,避免shuffle过程中数据过多时内存不够用,必须溢写到磁盘上,降低了性能。此外,如果发现作业由于频繁的gc导致运行缓慢,意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。

需要说明的一点是,从来就没有哪个参数是普遍适用的,如果有,那么这个参数就没有存在的意义

参数的调优,需要同学们根据自己的实际情况(包括Spark作业中的shuffle操作数量、RDD持久化操作数量以及spark web ui中显示的作业gc情况,合理地设置。

上面提到的这些参数,只是spark中很少的几种,但通常情况下的调优,这几个参数就足够

参考资料

利用Kryo序列化库是你提升Spark性能要做的第一件事

Spark2.4.3官网文档链接:

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值