Spark调优经验
编程部分
环境创建
IDE中可以通过如下方式创建Spark的上下文,其中master
指定了上下文环境,一版在开发或是测试时,指定为local
或者local[*]
即可,这两种方法都是以本地运行Spark程序,前者代表单线程运行,后者代表多线程运行,如果想要指定具体的线程数量,可以指定为local[4]
,如果不进行指定,则默认等于机器核数(比如8核CPU那就是8线程)。
在本地运行较大文件时,尽可能不使用local[*]
,而是使用显式地指定核数,因为如果将所有线程都用于worker计算,可能会导致内存不足的错误。
val spark = SparkSession.builder()
.master("local[*]")
.getOrCreate()
如果完成了算子开发及打包后,想要提交到集群运行,那么在代码里就不需要指定master
了。比如使用cluster模式提交任务到spark集群,但是代码中指定了master("local[*]")
,这时只会在集群的driver中使用local模式运行任务,而不会用到集群资源。所以打包时只需要以下方式创建即可:
val spark = SparkSession.builder()
.getOrCreate()
重复计算
Spark是以RDD或Dataframe来构成算子流程的,而通常它是不会进行缓存的,以下面为例:
val df = spark
.read
.format("csv")
.load(file_path)
columns.map(c=>
expand(df.select(c._1, time_col, time_col_as_long)
)
expand
方法中有action操作会触发计算,当对每一列调用expand方法时,虽然参数里都指定是df,但实际上在触发计算操作时这个df才会真正地被加载数据,也就是说如果column有10列,那么就会出现10次读文件操作,这样会显著降低运行效率。
为了避免重复计算,最好地方法就是把需要经常用到的RDD或Dataframe进行缓存,缓存方式有2种,一种是cache
,另一种是persist
。cache
就是将RDD或Dataframe中的数据缓存到内存里,相当于persist(StorageLevel.MEMORY_ONLY)
,如果数据量过大,这种方式显然会导致内存不足。所以对于大数据集,可以使用persist(StorageLevel.MEMORY_AND_DISK)
或persist(StorageLevel.MEMORY_AND_DISK_SER)
进行缓存,也就是当内存不够的时候将数据缓存到磁盘上,后者会读数据进行序列化,可以进一步减小存储对象大小,但是会影响读写效率。
分区
Spark默认是通过根据HDFS文件进行分区的(通常是128MB分为一个partition),如果数据文件是在本地一个csv中,那一开始读进来就是只有一个partition。我们知道在当运行spark时,Scheduler会根据partition来将stage划分为多个task,每个task由一个core来执行,所以如果只有一个partition,那么即时在之前声明了多线程执行,但因为只分配了一个task,最终也会导致实际运行时只有一个core执行。
带来的问题就是哪怕前面声明了使用多线程,但由于数据本身只在一个分区,且如果没有额外的划分操作(如reduceByKey
),那么在运行时也只会使用单核单线程计算。所以可以使用repartition
来强制重新分区。
并行计算
Spark的精髓就在于它可以支持并行计算,所以在代码里最好能够将一个大流程划分为多个可以并行执行、互不相交的小流程,比如多使用map
、foreach
操作等。
Broadcast
如果有一个较小的rdd或dataframe在许多task中都会用到,可以使用broadcast
操作将其广播到各个executor上,相当于每台机器都有这样一个数据备份,避免重复计算和数据传输。
窗口函数
针对窗口函数,比如lag
,是需要通过over
指定window的,在window里通常会以某种方式来将数据分区,比如reduceByKey
,每个区对应一个窗口,从而实现计算并行化。
但在某些场景下数据是没法分区的,比如要对全量数据计算两两相邻数据的变化量,在运行时也会提示“所有数据被划分到一个区,会带来严重的性能损耗”。关于这种情况我暂时也没有找到特别好的解决方法,有一点可以注意的是当对全量数据执行窗口函数的时候,如果没法对行分区,那尽可能只使用尽可能少的列。
df.withColumn(variation_name, col(col_name) - lag(col_name, 1).over(fake_window))
df.select(col_name)
.withColumn(variation_name, col(col_name) - lag(col_name, 1).over(fake_window))
根据测试情况,下面一种执行方法会比上面的执行方法效率提高不少。
Shuffle
除了数据读取,Spark另一个性能消耗重点是Shuffle操作,Shuffle操作通常会在reduce
、group
、join
等语句被触发,执行Shuffle时数据会在整个spark节点间传输。不过不同的Shuffle算子带来的时间消耗是不同的,这里给出的建议是尽可能少地去使用group
和join
这两种操作(除非有广播RDD,这种情况可以使用join
),可以用reduce
去替换group
。
提交部分
提交模式
如果不是在IDE开发或测试的情况下,更推荐用spark-submit去提交job,执行spark-submit要求本地也安装了spark。
spark-submit有很多种提交模式,除了前面提到的local模式,还有集群模式、yarn模式等,这里只考虑集群模式,也就是把job提交到spark standalone集群。
提交到集群有2中方式,分别是client模式和cluster模式。
- client模式:它代表在本地启动driver,进行任务分配,由cluster启动worker,进行具体的任务执行,然后任务执行结果会返回给driver进行整合及返回。因为这种方式会涉及到大量的本地-集群间通讯,所以效率比较低下。它使用7077端口,提交方式如下:
spark-submit --master spark://ip:7077
- cluster模式:它代表driver和worker都在集群节点上创建,本地把job提交后就和之后的执行没有任何关系了。在集群上,driver会随机选择一个节点创建,也就是说有一个节点会既有driver,也有worker,因此在分配内存和核数时也需要注意这一点(比如executor内存为30GB,分配driver内存6GB,那么worker最大也只能分配24GB。这种方式的好处是节点间通信都在集群内部,效率会高一些,但使用这种方式,本地提交后是无法直接获得程序输出的,只可以通过文件等形式间接读取结果。它使用6066端口,提交方式如下
spark-submit --master spark://ip:6066 --deploy-mode cluster
提交参数
spark支持各种提交参数配置,这里只例举我使用到的一部分:
- –num-executors:节点数,可以在集群8080端口的GUI的Alive Workers看到;
- –executor-cores: 每个节点的核数,集群8080端口的GUI的Cores in use会显示总核数,除以节点数就是每个节点的核数,节点数*每个节点的核数就是分配给该job用于计算的总核数,即最大并行度;
- –executor-memory:每个节点用于计算的内存大小,集群8080端口的GUI的Memory in use会显示总内存大小,除以节点数就是每个节点的内存大小;
- –driver-memory:driver内存大小;
- –conf spark.default.parallelism=?:默认并行度,通常设置为与总核数相等即可;
- –class:项目启动类,在使用cluster模式提交时这一项似乎是必要的(哪怕在打包时已经指定启动类)。
另外spark 2.2.0版本后支持自动调整spark.storage.memoryFraction和spark.shuffle.memoryFraction,所以这两项是不需要在提交参数里显式设置的。