在实际工作中,性能调优是必不可少的,虽然业务千种百样,实际落地的解决方案可能也不尽相同,但归根结底,调优的最终目的是使得内存、CPU、IO均衡而没有瓶颈。
基本上,思路都是结合实际业务、数据量从硬件出发,考虑如何充分利用CPU、内存、IO。
除了对业务的理解之外,对于Spark本身的机制也要深入理解,这样才能通过各种调整,充分发挥Spark的优势,达成调优的目的。
下面以一个案例尝试总结常用的Spark调优思路和实践。
案例数据来源极客时间Spark 性能调优实战,数据地址百度网盘,提取码 ajs6
。
数据结构如下所示:
下载数据,导入到HDFS;
代码:
val spark = SparkSession
.builder
.appName("SkewTestApp")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.adaptive.enabled", "true")
.config("spark.sql.debug.maxToStringFields", "100")
.getOrCreate
val rootPath: String = "hdfs://HD1/data"
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
val hdfs_path_apply = s"${rootPath}/apply"
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky = s"${rootPath}/lucky"
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
结果:
因为每个批次的数据还包含了反映申请者历史申请次数的重复数据,所以首先要在批次内对重复数据去重:
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
import org.apache.spark.sql.functions._
val result = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
println(s"result.count:${result.count}")
result.write.format("csv").save("geektime/result/")
spark-submit --master yarn --num-executors 1 --executor-cores 4 --executor-memory 20g --deploy-mode cluster --class com.app.test.GeekTimeProgramTest2 gt-1.0-SNAPSHOT.jar
耗时: 4min31s
从SparkUI可以看到,Spark的任务并行度很高:72
但我们给的资源有限,只有4个并行度,显然,在执行的过程中会有大量的任务排队,所以,第一个思路,增加资源,扩大并行度:
spark-submit --master yarn --num-executors 2 --executor-cores 10 --executor-memory 20g --class com.app.test.CreateSkewDataExampleNoBroadcast2 cnter-1.0-SNAPSHOT.jar
将集群并行度扩大到 2 * 10 = 20
后,耗时变成了 **耗时: 1min31s**
,耗时降低了2/3。
总结:数据并行度高但集群资源并行度低时,增加集群并行度是简单而且有效的方式。
因为每个批次的数据还包含了反映申请者历史申请次数的重复数据,所以首先要在批次内对重复数据去重:
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
val result = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
result.write.format("csv").save("geektime/result/")
val result02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1))
.alias("y_axis"))
.orderBy("x_axis")
result02.write.format("csv").save("geektime/result02/")
并行度:20
spark-submit --master yarn --num-executors 2 --executor-cores 10 --executor-memory 20g --deploy-mode cluster --class com.app.test.GeekTimeProgramTest2 gt-1.0-SNAPSHOT.jar
执行耗时:1mins, 36sec
我们发现applyDistinctDF被使用了两次,在得到applyDistinctDF的过程中使用了distinct,这是一个耗时的算子,如果把applyDistinctDF缓存即调用cache方法,减少一次applyDistinctDF的计算。
applyDistinctDF.cache()
applyDistinctDF.count()
val result = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
result.write.format("csv").save("geektime/result/")
val result02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1))
.alias("y_axis"))
.orderBy("x_axis")
result02.write.format("csv").save("geektime/result02/")
执行耗时:1mins, 23sec
优化后,性能提升不明显,应该是我们使用的数据集还不够大。
在求得Result2的过程中,使用了join,在另一篇文章中总结过spark的多种join方式,其中BroadcastJoin是在性能调优中值得考虑的一种优化手段,要使用BroadcastJoin有如下方式:
.config("spark.sql.autoBroadcastJoinThreshold","2073741824")
val spark = SparkSession
.builder
.appName("SkewTestApp")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.adaptive.enabled", "true")
.config("spark.sql.debug.maxToStringFields", "100")
.config("spark.sql.autoBroadcastJoinThreshold","2073741824")
.getOrCreate
val result = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
result.write.format("csv").save("geektime/result/")
val result02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1))
.alias("y_axis"))
.orderBy("x_axis")
result02.write.format("csv").save("geektime/result02/")
执行耗时:1mins, 16sec
优化后,性能提升不明显,应该是我们使用的数据集还不够大。
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
import org.apache.spark.sql.functions._
val lucky_molecule_2018 = luckyDogsDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1))
.alias("denominator"))
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")
result04.write.format("csv").save("/data/res/geektime/result4/")
执行耗时:47sec
DPP(Dynamic Partition Pruning,动态分区剪裁)是 Spark3的新特性,简单说就是根据维表的过滤条件对事实表进行分区过滤,类似于列裁剪,用来降低磁盘IO。
满足下列三个条件,Spark即会进行DPP优化:
所以,在维表即lucky表添加过滤条件:
.filter(col("batchNum").like("2018%"))
再加上hdfs上存储的事实表数据apply是以batchNum分区的,而lucky表在过滤后可以满足广播的条件。
所以,满足了动态DPP的条件。
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
import org.apache.spark.sql.functions._
val lucky_molecule_2018 = luckyDogsDF
.filter(col("batchNum").like("2018%"))
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1))
.alias("denominator"))
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")
result04.write.format("csv").save("/data/res/geektime/result4/")
执行耗时:26sec
耗时减小了50%,性能得到了可观的提升。
结合业务,减小要扫描的数据范围,从而减小磁盘IO和网络IO以及计算量,能够大幅提升作业性能。其中DPP能够自动的执行这些过程,但要满足3个条件。