You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

27 KiB

31 | 性能调优:手把手带你提升应用的执行性能

你好,我是吴磊。

在上一讲我们一起完成了小汽车摇号趋势分析的应用开发解决了5个案例。今天这一讲我们逐一对这5个案例做性能调优一起把专栏中学过的知识和技巧应用到实战中去。

由于趋势分析应用中的案例较多,为了方便对比每一个案例调优前后的性能效果,我们先来对齐并统一性能对比测试的方法论。

首先我们的性能对比测试是以案例为粒度的也就是常说的Case By Case。然后在每一个案例中我们都有对比基准Baseline。**对比基准的含义是,在不采取任何调优方法的情况下,直接把代码交付执行得到的运行时间。**之后,对于每一个案例,我们会采取一种或多种调优方法做性能优化,每一种调优方法都有与之对应的运行时间。最终,我们将不同调优方法的运行时间与对比基准做横向比较,来观察调优前后的性能差异,并分析性能提升/下降的背后原因。

话不多说,我们直接开始今天的课程吧!

运行环境

既然调优效果主要由执行时间来体现,那在开始调优之前,我们有必要先来交代一下性能测试采用的硬件资源和配置项设置。硬件资源如下表所示。

为了避免因为实验本身而等待太长的时间,我使用了比较强悍的机器资源。实际上,为了跑通应用,完成性能对比测试,你使用笔记本也可以。而且为了给后续调优留出足够空间,除了必需的运行资源设置以外,其他配置项全部保留了默认值,具体的资源配置如下表所示。

另外由于调优方法中涉及AQE和DPP这些Spark 3.0新特性因此我建议你使用3.0及以上的Spark版本来部署运行环境我这边采用的版本号是Spark 3.1.1。

接下来我们就Case By Case地去回顾代码实现分别分析5个案例的优化空间、可能的调优方法、方法的效果以及它们与对比基准的性能差异。

案例1的性能调优人数统计

首先我们先来回顾案例1。案例1的意图是统计摇号总人次、中签者人数以及去掉倍率影响之后的摇号总人次代码如下所示。

val rootPath: String = _
 
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
val hdfs_path_apply = s"${rootPath}/apply"
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s"${rootPath}/lucky"
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
luckyDogsDF.count
 
// 申请者数据(去掉倍率的影响)
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
applyDistinctDF.count

从上面的代码实现中我们不难发现短短的几行代码共有3个Actions也就是3个不同数据集上的count操作这3个Actions会触发3个Spark Jobs。其中前2个Jobs都是读取数据源之后立即计数没什么优化空间。第3个Job是在applyNumbersDF之上做去重然后再统计计数。结合上一讲对于不同案例的讲解我们知道applyNumbersDF、luckyDogsDF和applyDistinctDF这3个数据集在后续的案例中会被反复引用。

因为上述3个数据集的引用次数过于频繁所以我们甚至都不用去计算“运行成本占比”就可以判定利用Cache一定有利于提升执行性能。

使用Cache的一般性原则
如果RDD/DataFrame/Dataset在应用中的引用次数为1那么坚决不使用Cache
如果引用次数大于1且运行成本占比超过30%应当考虑启用Cache

因此对于第3个Job我们可以利用Cache机制来提升执行性能。调优方法很简单我们只需在applyNumbersDF.count之前添加一行代码applyNumbersDF.cache。

由于这个案例中性能对比测试的关注点是第3个Job那为了方便横向对比我们先把不相干的Jobs和代码去掉整理之后的对比基准和调优代码如下表所示。

然后我们把这两份代码分别打包、部署和执行并记录applyDistinctDF.count作业的执行时间来完成性能对比测试我把执行结果记录到了下面的表格中。

从中我们可以看到相较于对比基准调优之后的执行性能提升了20%。坦白地说这样的提升是我们意料之中的。毕竟前者消耗的是磁盘I/O而调优之后计数作业直接从内存获取数据。

案例2的性能调优摇号次数分布

接下来我们再来分析案例2。案例2分为两个场景第一个场景用于统计申请者摇号批次数量的分布情况第二个场景也是类似不过它的出发点是中签者主要用来解答“中签者通常需要摇多少次号才能中签”这类问题。

场景1参与摇号的申请者

我们先来回顾一下场景1的代码实现。仔细研读代码我们不难发现场景1是典型的单表Shuffle而且是两次Shuffle。第一次Shuffle操作是以数据列“carNum”为基准做分组计数第二次Shuffle是按照“x_axis”列再次做分组计数。

val result02_01 = 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")
 
result02_01.write.format("csv").save("_")

因此场景1的计算实际上就是2次Word Count而已只不过第一次的Word是“carNum”而第二次的Word是“x_axis”。那么对于这样的“Word Count”我们都有哪些调优思路呢

在配置项调优那一讲我们专门介绍了Shuffle调优的一些常规方法比如调整读写缓冲区大小、By Pass排序操作等等。除此之外我们的脑子里一定要有一根弦Shuffle的本质是数据的重新分发凡是有Shuffle操作的地方都需要关注数据分布。所以对于过小的数据分片我们要有意识地对其进行合并。再者在案例1中我们提到applyNumbersDF、luckyDogsDF和applyDistinctDF在后续的案例中会被反复引用因此给applyDistinctDF加Cache也是一件顺理成章的事情

调优的思路这么多那为了演示每一种调优方法的提升效果我会从常规操作、数据分区合并、加Cache这3个方向出发分别对场景1进行性能调优。不过需要说明的是咱们这么做的目的一来是为了开阔调优思路二来是为了复习之前学习过的调优技巧。

当然了,在实际工作中,我们一般没有时间和精力像现在这样,一个方法、一个方法去尝试。那么,效率最高的做法应该是遵循我们一直强调的调优方法论,也就是先去应对木桶的短板、消除瓶颈,优先解决主要矛盾,然后在时间、精力允许的情况下,再去应对次短的木板

那么问题来了你认为上述3种调优思路分别应对的是哪些“木板”这些“木板”中哪一块是最短的你又会优先采用哪种调优技巧接下来我们就带着这些问题依次对场景1做调优。

思路1Shuffle常规优化

刚刚咱们提到Shuffle的常规优化有两类一类是By Pass排序操作一类是调整读写缓冲区。而By Pass排序有两个前提条件一是计算逻辑不涉及聚合或排序二是Reduce阶段的并行度要小于参数spark.shuffle.sort.bypassMergeThreshold的设置值。显然场景1不符合要求计算逻辑既包含聚合也包含排序。所以我们就只有调整读写缓冲区这一条路可走了。

实际上读写缓冲区的调优也是有前提的因为这部分内存消耗会占用Execution Memory内存区域所以提高缓冲区大小的前提是Execution Memory内存比较充裕。由于咱们使用的硬件资源比较强劲而且小汽车摇号数据整体体量偏小因此咱们还是有一些“资本”对读写缓冲区做调优的。具体来说我们需要调整如下两个配置项

  • spark.shuffle.file.bufferMap阶段写入缓冲区大小
  • spark.reducer.maxSizeInFlightReduce阶段读缓冲区大小

由于读写缓冲区都是以Task为粒度进行设置的因此调整这两个参数的时我们要小心一点一般来说50%往往是个不错的开始,对比基准与优化设置如下表所示。

两组对比实验的运行时间,我记录到了下面的表格中。从中我们不难发现,上述两个参数的调整,对于作业端到端执行性能的影响不大。不过,这种参数调了半天,执行效率并没有显著提升的场景,肯定让你似曾相识。这个时候,最好的办法就是我们继续借助“木桶短板”“瓶颈”以及“调优方法论”,去尝试其他的调优思路。

思路2数据分区合并

接着我们再来说第二个思路数据分区合并。首先咱们先来一起分析一下场景1到底存不存在数据分片过小的问题。为了方便分析我们再来回顾一遍代码。因为场景1的计算基于数据集applyDistinctDF所以要回答刚刚的问题我们需要结合数据集applyDistinctDF的存储大小以及Shuffle计算过后Reduce阶段的并行度一起来看。

val result02_01 = 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")
 
result02_01.write.format("csv").save("_")

并行度由配置项spark.sql.shuffle.partitions决定其默认大小为200也就是200个数据分区。而对于数据集存储大小的估算我们需要用到下面的函数。

def sizeNew(func: => DataFrame, spark: => SparkSession): String = {
 
  val result = func
 
  val lp = result.queryExecution.logical
 
  val size = spark.sessionState.executePlan(lp).optimizedPlan.stats.sizeInBytes
 
  "Estimated size: " + size/1024 + "KB"
 
}

给定DataFramesizeNew函数可以返回该数据集在内存中的精确大小。把applyDistinctDF作为实参调用sizeNew函数返回的估算尺寸为2.6 GB。将数据集尺寸除以并行度我们就能得到Reduce阶段每个数据分片的存储大小也就是13 MB也就是2.6 GB / 200。通常来说数据分片的尺寸大小在200 MB左右为宜13 MB的分片尺寸显然过小。

在调度系统那一讲第5讲我们说过如果需要处理的数据分片过小相较于数据处理Task调度开销将变得异常显著而这样会导致CPU利用降低执行性能变差。因此为了提升CPU利用率进而提升整体的执行效率我们需要对过小的数据分片做合并。这个时候AQE的自动分区合并特性就可以帮我们做这件事情。

不过要想充分利用AQE的自动分区合并特性我们还需要对相关的配置项进行调整。这里你直接看场景1是怎么设置这些配置项的就可以了。

一旦开启AQE机制自动分区合并特性会自动生效。表格中的配置项有两个需要我们特别注意一个是最小分区数minPartitionNum另一个是合并之后的目标尺寸advisoryPartitionSizeInBytes。

我们先来看最小分区数也就是minPartitionNum。minPartitionNum的含义指的是分区合并之后的分区数量不能低于这个参数设置的数值。由于我们计算集群配置的Executors个数为6为了保证每个CPU都不闲着、有活儿干我们不妨把minPartitionNum也设置为6。

接下来是分区合并的目标尺寸我们刚刚说过分区大小的经验值在200 MB左右因此我们不妨把advisoryPartitionSizeInBytes设置为200 MB。不过为了对比不同分区大小对于执行性能的影响我们可以多做几组实验。

配置项调整前后的几组实验效果对比如下可以看到调优后的运行时间有所缩短这说明分区合并对于提升CPU利用率和作业的整体执行性能是有帮助的。仔细观察下表我们至少有3点洞察。

  • 并行度过高、数据分片过小CPU调度开销会变大执行性能也变差。
  • 分片粒度划分在200 MB左右时执行性能往往是最优的。
  • 并行度过低、数据分片过大CPU数据处理开销也会过大执行性能会锐减。

思路3加Cache

最后一个思路是加Cache这个调优技巧使用起来非常简单我们在案例1已经做过演示因此这里直接给出优化代码和运行结果。

可以看到利用Cache机制做优化作业执行性能提升得非常显著。

到此为止我们尝试了3种调优方法来对场景1做性能优化分别是Shuffle读写缓冲区调整、数据分区合并以及加Cache。第1种方法针对的是Shuffle过程中磁盘与网络的请求次数第2种方法的优化目标是提升Reduce阶段的CPU利用率第3种方法针对的是数据集在磁盘中的重复扫描与重复计算。

实际上根本不需要做定量分析仅从定性我们就能看出数据集的重复扫描与计算的开销最大。因此在实际工作中对于类似的“多选题”我们自然要优先选择能够消除瓶颈的第3种方法。

场景2幸运的中签者

完成了场景1单表Shuffle的优化之后接下来我们再来看看场景2场景2的业务目标是获取中签者的摇号次数分布。我们先来回顾场景2的代码实现场景2的计算涉及一次数据关联两次分组、聚合以及最终的排序操作。不难发现除了关联计算外其他计算步骤与场景1如出一辙。因此对于场景2的优化我们专注在第一步的数据关联后续优化沿用场景1的调优方法即可。

val result02_02 = 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_02.write.format("csv").save("_")

参与关联的两张表分别是applyDistinctDF和luckyDogsDF其中applyDistinctDF是去重之后的摇号数据luckyDogsDF包含的是中签者的申请编号与批次号。applyDistinctDF包含1.35条数据记录而luckyDogsDF仅仅包含115万条数据记录。很显然二者之间的数据关联属于数仓中常见的“大表Join小表”。

遇到“大表Join小表”的计算场景我们最先应该想到的调优技巧一定是广播变量。毕竟我们一直都在不遗余力地强调Broadcast Joins的优势与收益。在这里我再强调一次你一定要掌握使用广播变量优化数据关联的调优技巧。毫不夸张地说广播变量是“性价比”最高的调优技巧且没有之一。

要利用广播变量来优化applyDistinctDF与luckyDogsDF的关联计算我们需要做两件事情。第一件估算luckyDogsDF数据表在内存中的存储大小。第二件设置广播阈值配置项spark.sql.autoBroadcastJoinThreshold。

对于分布式数据集的尺寸预估我们还是使用sizeNew函数把luckyDogsDF作为实参调用sizeNew函数返回的估算尺寸为18.5MB。有了这个参考值我们就可以设置广播阈值了。要把applyDistinctDF与luckyDogsDF的关联计算转化为Broadcast Join只要让广播阈值大于18.5MB就可以我们不妨把这个参数设置为20MB。

我把配置项调整前后的实验结果记录到了如下表格显然相比默认的Shuffle Sort Merge Join实现机制Broadcast Join的执行性能更胜一筹。

案例3的性能调优中签率的变化趋势

案例3的业务目标是洞察中签率的变化趋势我们先来回顾代码。要计算中签率我们需要分两步走。第一步按照摇号批次也就是batchNum分别对applyDistinctDF和luckyDogsDF分组然后分别对分组内的申请者和中签者做统计计数。第二步通过数据关联将两类计数做除法最终得到每个批次的中签率。

// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("denominator"))

// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))

val result03 = apply_denominator
.join(lucky_molecule, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")
 
result03.write.format("csv").save("_")

由于2011年到2019年总共有72个摇号批次因此第一步计算得到结果集也就是apply_denominator和lucky_molecule各自有72条数据记录。显然两个如此之小的数据集做关联不存在什么调优空间。

因此对于案例3来说调优的关键在于第一步涉及的两个单表Shuffle。关于单表Shuffle的调优思路与技巧我们在案例2的场景1做过详细的分析与讲解因此applyDistinctDF和luckyDogsDF两张表的Shuffle优化就留给你作为课后练习了。

案例4的性能调优中签率局部洞察

与案例3不同案例4只关注2018年的中签率变化趋势我们先来回顾案例4的代码实现。

// 筛选出2018年的中签数据并按照批次统计中签人数
val lucky_molecule_2018 = luckyDogsDF
.filter(col("batchNum").like("2018%"))
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))

// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
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("_")

从代码实现来看案例4相比案例3唯一的改动就是在luckyDogsDF做统计计数之前增加了摇号批次的过滤条件也就是filter(col("batchNum").like("2018%"))。你可能会说“案例4的改动可以说是微乎其微它的调优空间和调优方法应该和案例3没啥区别”。还真不是还记得Spark 3.0推出的DPP新特性吗添加在luckyDogsDF表上的这个不起眼的过滤谓词恰恰让DPP有了用武之地。

在DPP那一讲我们介绍过开启DPP的3个前提条件

  • 事实表必须是分区表且分区字段可以是多个必须包含Join Key
  • DPP仅支持等值Joins不支持大于、小于这种不等值关联关系
  • 维表过滤之后的数据集必须要小于广播阈值因此你要注意调整配置项spark.sql.autoBroadcastJoinThreshold

那么这3个前提条件是怎么影响案例4的性能调优的呢

首先在上一讲我们介绍过摇号数据的目录结构apply和lucky目录下的数据都按照batchNum列做了分区存储。因此案例4中参与关联的数据表applyDistinctDF和luckyDogsDF都是分区表且分区键batchNum刚好是二者做关联计算的Join Key。其次案例4中的关联计算显然是等值Join。

最后我们只要保证lucky_molecule_2018结果集小于广播阈值就可以触发DPP机制。2018年只有6次摇号也就是说分组计数得到的lucky_molecule_2018只有6条数据记录这么小的“数据集”完全可以放进广播变量。

如此一来案例4满足了DPP所有的前提条件利用DPP机制我们就可以减少applyDistinctDF的数据扫描量从而在整体上提升作业的执行性能。

DPP的核心作用在于降低事实表applyDistinctDF的磁盘扫描量因此案例4的调优办法非常简单只要把最初加在applyDistinctDF之上的Cache去掉即可如上表右侧所示。同时为了公平起见对比基准不应该仅仅是让DPP失效的测试用例而应该是applyDistinctDF加Cache的测试用例。与此同时我们直接对比DPP的磁盘读取效率与Cache的内存读取效率也能加深对DPP机制的认知与理解。

把上述两个测试用例交付执行运行结果如下。可以看到相较对比基准在DPP机制的作用下案例4端到端的执行性能有着将近5倍的提升。由此可见数据集加Cache之后的内存读取远不如DPP机制下的磁盘读取更高效。

案例5的性能调优倍率分析

案例5也包含两个场景场景1的业务目标是计算不同倍率下的中签人数场景2与场景1相比稍有不同它的目的是计算不同倍率下的中签比例。

尽管两个场景的计算逻辑有区别但是调优思路与方法是一致的。因此在案例5中我们只需要对场景1的性能优化进行探讨、分析与对比我们先来回顾一下场景1的代码实现。

val result05_01 = applyNumbersDF
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
 
result05_01.write.format("csv").save("_")

仔细研读代码之后我们发现场景1的计算分为如下几个环节

  • 大表与小表的关联计算,且小表带过滤条件
  • 按batchNum列做统计计数
  • 按carNum列取最大值
  • 按multiplier列做统计计数

在这4个环节当中关联计算涉及的数据扫描量和数据处理量最大。因此这一环节是案例5执行效率的关键所在。另外除了关联计算环节其他3个环节都属于单表Shuffle优化的范畴这3个环节的优化可以参考案例2场景1的调优思路与技巧咱们也不多说了。因此对于案例5的性能优化我们重点关注第一个环节也就是applyNumbersDF与luckyDogsDF的关联计算。

仔细观察第一个环节的关联计算我们发现关联条件中的Join Key是carNum而carNum并不是applyNumbersDF与luckyDogsDF两张表的分区键因此在这个关联查询中我们没有办法利用DPP机制去做优化。

不过applyNumbersDF与luckyDogsDF的内关联是典型的“大表Join小表”对于这种场景我们至少有两种方法可以将低效的SMJ转化为高效的BHJ。

第一种办法是计算原始数据集luckyDogsDF的内存存储大小确保其小于广播阈值从而利用Spark SQL的静态优化机制将SMJ转化为BHJ。第二种方法是确保过滤后的luckyDogsDF小于广播阈值这样我们就可以利用Spark SQL的AQE机制来动态地将SMJ转化为BHJ。

接下来我们分别使用这两种方法来做优化比较它们之间以及它们与对比基准之间的性能差异。在案例2场景2中我们计算过luckyDogsDF在内存中的存储大小是18.5MB因此通过适当调节spark.sql.autoBroadcastJoinThreshold我们就可以灵活地在两种调优方法之间进行切换。

将3种测试用例付诸执行在执行效率方面SMJ毫无悬念是最差的而AQE的动态优化介于SMJ与Spark SQL的静态转化之间。毕竟AQE的Join策略调整是一种“亡羊补牢、犹未为晚”的优化机制在把SMJ调整为BHJ之前参与Join的两张表的Shuffle计算已经执行过半。因此它的执行效率一定比Spark SQL的静态优化更差。尽管如此AQE动态调整过后的BHJ还是比默认的SMJ要高效得多而这也体现了AQE优化机制的价值所在。

小结

今天这一讲我们结合以前学过的知识点与调优技巧以小汽车摇号为例Case By Case地做性能优化。涉及的优化技巧有Shuffle读写缓冲区调整、加Cache、预估数据集存储大小、Spark SQL静态优化、AQE动态优化自动分区合并与Join策略调整以及DPP机制。为了方便你对比我把它们总结在了一张脑图里。不过我更希望你能自己总结一下这样才能记得更好。

最后我想说,很遗憾我们没能在这个实战里,把专栏中所有的调优技巧付诸实践,这主要是因为小汽车摇号应用相对比较简单,很难覆盖所有的计算与优化场景。对于那些未能付诸实战的调优技巧,只能靠你在平时的工作中去实践了。

不过,专栏的留言区和咱们的读者群,会一直为你敞开,尽管我不能做到立即回复,但我可以承诺的是,对于你的留言,我只会迟到、绝不缺席!

每日一练

  1. 你能参考案例2场景1完成案例3中applyDistinctDF和luckyDogsDF两张表的单表Shuffle优化吗
  2. 你能参考案例5场景1综合运用AQE、Broadcast Join等调优技巧对案例5场景2做性能优化吗

期待在留言区看到你的优化结果,也欢迎你随时提问,我们下一讲见!