learn-tech/专栏/Spark性能调优实战/21Catalyst逻辑计划:你的SQL语句是怎么被优化的?(上).md
2024-10-16 06:37:41 +08:00

200 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
21 Catalyst逻辑计划你的SQL语句是怎么被优化的
你好,我是吴磊。
上一讲我们说Spark SQL已经取代Spark Core成为了新一代的内核优化引擎所有Spark子框架都能共享Spark SQL带来的性能红利所以在Spark历次发布的新版本中Spark SQL占比最大。因此Spark SQL的优化过程是我们必须要掌握的。
Spark SQL端到端的完整优化流程主要包括两个阶段Catalyst优化器和Tungsten。其中Catalyst优化器又包含逻辑优化和物理优化两个阶段。为了把开发者的查询优化到极致整个优化过程的运作机制设计得都很精密因此我会用三讲的时间带你详细探讨。
下图就是这个过程的完整图示,你可以先通过它对优化流程有一个整体的认知。然后随着我的讲解,逐渐去夯实其中的关键环节、重要步骤和核心知识点,在深入局部优化细节的同时,把握全局优化流程,做到既见树木、也见森林。
今天这一讲我们先来说说Catalyst优化器逻辑优化阶段的工作原理。
案例小Q变身记
我们先来看一个例子例子来自电子商务场景业务需求很简单给定交易事实表transactions和用户维度表users统计不同用户的交易额数据源以Parquet的格式存储在分布式文件系统。因此我们要先用Parquet API读取源文件。
val userFile: String = _
val usersDf = spark.read.parquet(userFile)
usersDf.printSchema
/**
root
|-- userId: integer (nullable = true)
|-- name: string (nullable = true)
|-- age: integer (nullable = true)
|-- gender: string (nullable = true)
|-- email: string (nullable = true)
*/
val users = usersDf
.select("name", "age", "userId")
.filter($"age" < 30)
.filter($"gender".isin("M"))
val txFile: String = _
val txDf = spark.read.parquet(txFile)
txDf.printSchema
/**
root
|-- itemId: integer (nullable = true)
|-- userId: integer (nullable = true)
|-- price: float (nullable = true)
|-- quantity: integer (nullable = true)
*/
val result = txDF.select("price", "volume", "userId")
.join(users, Seq("userId"), "inner")
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))
result.write.parquet("_")
代码示例如上图所示为了实现业务逻辑我们对过滤之后的用户表与交易表做内关联然后再按照用户分组去计算交易额不难发现这个计算逻辑实际上就是星型数仓中典型的关联查询为了叙述方便我们给这个关联查询起个名字小Q小Q的计算需要两个输入源一个是交易表另一个是过滤之后的用户表今天这一讲我们就去追随小Q看看它在Catalyst的逻辑优化阶段都会发生哪些变化
Catalyst逻辑优化阶段分为两个环节逻辑计划解析和逻辑计划优化在逻辑计划解析中Catalyst把Unresolved Logical Plan转换为Analyzed Logical Plan在逻辑计划优化中Catalyst基于一些既定的启发式规则Heuristics Based RulesAnalyzed Logical Plan转换为Optimized Logical Plan
因为Unresolved Logical Plan是Catalyst优化的起点所以在进入Catalyst优化器之前小Q先是改头换面从代码中的查询语句摇身变成了Unresolved Logical Plan
逻辑计划解析
小Q成功进入Catalyst优化器之后就要开始执行逻辑计划解析也就是要从Unresolved Logical Plan转换为Analyzed Logical Plan那么具体该怎么做呢
小Q启程那张图我们不难发现Unresolved Logical Plan携带的信息相当有限它只包含查询语句从DSL语法变换成AST语法树的信息需要说明的是不论是逻辑计划还是物理计划执行的次序都是自下向上因此图中逻辑计划的计算顺序是从全表扫描到按性别过滤每个步骤的含义都是准备做什么
例如在计划的最底层Relation节点告诉Catalyst你需要扫描一张表这张表有4个字段分别是ABCD文件格式是Parquet但这些信息对于小Q的优化还远远不够我们还需要知道这张表的Schema是啥字段的类型都是什么字段名是否真实存在数据表中的字段名与计划中的字段名是一致的吗
因此在逻辑计划解析环节Catalyst就是要结合DataFrame的Schema信息来确认计划中的表名字段名字段类型与实际数据是否一致完成确认之后Catalyst会生成Analyzed Logical Plan这个时候小Q就会从Unresolved Logical Plan转换成Analyzed Logical Plan
从下图中我们能够看到逻辑计划已经完成了一致性检查并且可以识别两张表的字段类型比如userId的类型是intprice字段的类型是double等等
逻辑计划优化
对于现在的小Q来说如果我们不做任何优化直接把它转换为物理计划也可以但是这种照搬开发者的计算步骤去制定物理计划的方式它的执行效率往往不是最优的
为什么这么说呢在运行时Spark会先全量扫描Parquet格式的用户表然后遴选出userIdnameagegender四个字段接着分别按照年龄和性别对数据进行过滤
对于这样的执行计划来说最开始的全量扫描显然是一种浪费原因主要有两方面一方面查询实际上只涉及4个字段并不需要email这一列数据另一方面字段age和gender上带有过滤条件我们完全可以利用这些过滤条件减少需要扫描的数据量
由此可见对于同样一种计算逻辑实现方式可以有多种按照不同的顺序对算子做排列组合我们就可以演化出不同的实现方式最好的方式是我们遵循能省则省能拖则拖的开发原则去选择所有实现方式中最优的那个
同样在面对这种选择题的时候Catalyst也有一套自己的原则和逻辑因此生成Analyzed Logical Plan之后Catalyst并不会止步于此它会基于一套启发式的规则Analyzed Logical Plan转换为Optimized Logical Plan
那么问题来了Catalyst都有哪些既定的规则和逻辑呢基于这些规则Catalyst又是怎么做转换的呢别着急我们一个一个来解答咱们先来说说Catalyst的优化规则然后再去探讨逻辑计划的转换过程
Catalyst的优化规则
和Catalyst相比咱们总结出的开发原则简直就是小巫见大巫为什么这么说呢在新发布的Spark 3.0版本中Catalyst总共有81条优化规则Rules这81条规则会分成27组Batches其中有些规则会被收纳到多个分组里因此如果不考虑规则的重复性27组算下来总共会有129个优化规则
对于如此多的优化规则我们该怎么学呢实际上如果从优化效果的角度出发这些规则可以归纳到以下3个范畴
谓词下推Predicate Pushdown
列剪裁Column Pruning
常量替换 Constant Folding
首先我们来说说谓词下推谓词下推主要是围绕着查询中的过滤条件做文章其中谓词指代的是像用户表上age < 30这样的过滤条件下推指代的是把这些谓词沿着执行计划向下推到离数据源最近的地方从而在源头就减少数据扫描量换句话说让这些谓词越接近数据源越好
不过在下推之前Catalyst还会先对谓词本身做一些优化比如像OptimizeIn规则它会把gender in M优化成gender = M也就是把谓词in替换成等值谓词。再比如CombineFilters规则它会把“age < 30gender = M”这两个谓词捏合成一个谓词“age != null AND gender != null AND age
完成谓词本身的优化之后Catalyst再用PushDownPredicte优化规则把谓词推到逻辑计划树最下面的数据源上对于ParquetORC这类存储格式结合文件注脚Footer中的统计信息下推的谓词能够大幅减少数据扫描量降低磁盘I/O开销
再来说说列剪裁列剪裁就是扫描数据源的时候只读取那些与查询相关的字段以小Q为例用户表的Schema是userIdnameagegenderemail但是查询中压根就没有出现过email的引用因此Catalyst会使用 ColumnPruning规则把email这一列剪掉经过这一步优化Spark在读取Parquet文件的时候就会跳过email这一列从而节省I/O开销
不难发现谓词下推与列剪裁的优化动机其实和能省则省的原则一样核心思想都是用尽一切办法减少需要扫描和处理的数据量降低后续计算的负载
最后一类优化是常量替换它的逻辑比较简单假设我们在年龄上加的过滤条件是age < 12 + 18Catalyst会使用ConstantFolding规则自动帮我们把条件变成age < 30再比如我们在select语句中掺杂了一些常量表达式Catalyst也会自动地用表达式的结果进行替换
到此为止咱们从功用和效果的角度探讨了Catalyst逻辑优化规则的3大范畴你可能说拢共就做了这么3件事至于兴师动众地制定81条规则吗我们划分这3大范畴主要是为了叙述和理解上的方便实际上对于开发者写出的五花八门千奇百怪的查询语句正是因为Catalyst不断丰富的优化规则才让这些查询都能够享有不错的执行性能如果没有这些优化规则的帮忙小Q的执行性能一定会惨不忍睹
最终被Catalyst优化过后的小Q就从Analyzed Logical Plan转换为Optimized Logical Plan如下图所示我们可以看到谓词下推和列剪裁都体现到了Optimized Logical Plan中
Catalys的优化过程
接下来我继续来回答刚刚提出的第二个问题基于这么多优化规则Catalyst具体是怎么把Analyzed Logical Plan转换成Optimized Logical Plan的呢其实不管是逻辑计划Logical Plan还是物理计划Physical Plan它们都继承自QueryPlan
QueryPlan的父类是TreeNodeTreeNode就是语法树中对于节点的抽象TreeNode有一个名叫children的字段类型是Seq[TreeNode]利用TreeNode类型Catalyst可以很容易地构建一个树结构
除了children字段TreeNode还定义了很多高阶函数其中最值得关注的是一个叫做transformDown的方法transformDown的形参正是Catalyst定义的各种优化规则方法的返回类型还是TreeNode另外transformDown是个递归函数参数的优化规则会先作用Apply于当前节点然后依次作用到children中的子节点直到整棵树的叶子节点
总的来说Analyzed Logical PlanOptimized Logical Plan的转换就是从一个TreeNode生成另一个TreeNode的过程Analyzed Logical Plan的根节点通过调用transformDown方法不停地把各种优化规则作用到整棵树直到把所有27组规则尝试完毕且树结构不再发生变化为止这个时候生成的TreeNode就是Optimized Logical Plan
为了把复杂问题简单化我们使用Expression也就是表达式来解释一下这个过程因为Expression本身也继承自TreeNode所以明白了这个例子TreeNode之间的转换我们也就清楚了
//Expression的转换
import org.apache.spark.sql.catalyst.expressions._
val myExpr: Expression = Multiply(Subtract(Literal(6), Literal(4)), Subtract(Literal(1), Literal(9)))
val transformed: Expression = myExpr transformDown {
case BinaryOperator(l, r) => Add(l, r)
case IntegerLiteral(i) if i > 5 => Literal(1)
case IntegerLiteral(i) if i < 5 => Literal(0)
}
首先我们定义了一个表达式6 - 4*1 - 9然后我们调用这个表达式的transformDown高阶函数。在高阶函数中我们提供了一个用case定义的匿名函数。显然这是一个偏函数Partial Functions你可以把这个匿名函数理解成“自定义的优化规则”。在这个优化规则中我们仅考虑3种情况
对于所有的二元操作符,我们都把它转化成加法操作
对于所有大于5的数字我们都把它变成1
对于所有小于5的数字我们都把它变成0
虽然我们的优化规则没有任何实质性的意义仅仅是一种转换规则而已但是这并不妨碍你去理解Catalyst中TreeNode之间的转换。当我们把这个规则应用到表达式6 - 4*1 - 9之后得到的结果是另外一个表达式1 + 0+0 + 1下面的示意图直观地展示了这个过程。
从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换与示例中表达式的转换过程如出一辙。最主要的区别在于Catalyst的优化规则要复杂、精密得多。
Cache Manager优化
从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换Catalyst除了使用启发式的规则以外还会利用Cache Manager做进一步的优化。
这里的Cache指的就是我们常说的分布式数据缓存。想要对数据进行缓存你可以调用DataFrame的.cache或.persist或是在SQL语句中使用“cache table”关键字。
Cache Manager其实很简单它的主要职责是维护与缓存有关的信息。具体来说Cache Manager维护了一个Mapping映射字典字典的Key是逻辑计划Value是对应的Cache元信息。
当Catalyst尝试对逻辑计划做优化时会先尝试对Cache Manager查找看看当前的逻辑计划或是逻辑计划分支是否已经被记录在Cache Manager的字典里。如果在字典中可以查到当前计划或是分支Catalyst就用InMemoryRelation节点来替换整个计划或是计划的一部分从而充分利用已有的缓存数据做优化。
小结
今天这一讲我们主要探讨了Catalyst优化器的逻辑优化阶段。这个阶段包含两个环节逻辑计划解析和逻辑计划优化。
在逻辑计划解析环节Catalyst结合Schema信息对于仅仅记录语句字符串的Unresolved Logical Plan验证表名、字段名与实际数据的一致性。解析后的执行计划称为Analyzed Logical Plan。
在逻辑计划优化环节Catalyst会同时利用3方面的力量优化Analyzed Logical Plan分别是AQE、Cache Manager和启发式的规则。它们当中Catalyst最倚重的是启发式的规则。
尽管启发式的规则多达81项但我们把它们归纳为3大范畴谓词下推、列剪裁和常量替换。我们要重点掌握谓词下推和列剪裁它们的优化动机和“能省则省”的开发原则一样核心思想都是用尽一切办法减少需要扫描和处理的数据量降低后续计算的负载。
针对所有的优化规则Catalyst优化器会通过调用TreeNode中的transformDown高阶函数分别把它们作用到逻辑计划的每一个节点上直到逻辑计划的结构不再改变为止这个时候生成的逻辑计划就是Optimized Logical Plan。
最后Cache Manager的作用是提供逻辑计划与数据缓存的映射关系当现有逻辑计划或是分支出现在Cache Manager维护的映射字典的时候Catalyst可以充分利用已有的缓存数据来优化。
每日一练
既然Catalyst在逻辑优化阶段有81条优化规则我们还需要遵循“能省则省、能拖则拖”的开发原则吗
你能说说Spark为什么用偏函数而不是普通函数来定义Catalyst的优化规则吗
期待在留言区看到你的思考和答案,我们下一讲见!