深入理解 Spark SQL 的 Catalyst Optimizer

1,253 阅读7分钟

前言

Spark SQL 的核心是Catalyst 优化器,它以一种全新的方式利用高级语言的特性(例如:Scala 的模式匹配和Quasiquotes)构建一个可扩展的查询优化器。

Spark SQL架构图

image.png Catalyst 核心是表示树操作树的规则。在框架的顶层,构建了专门用于关系型查询处理的库(表达式,逻辑查询计划)以及处理查询执行的不同阶段的几组规则(分析,逻辑优化,物理计划和将部分查询编译为 Java 字节码的代码生成)。对于后者,我们使用了另一个 Scala 特性Quasiquotes,它使得在运行时从组合表达式生成代码机器变得非常简单。最后,Catalyst 提供了若干公共的扩展点,包括扩展数据源和用户自定义类型。

Catalyst 的核心

Catalyst 中的主要数据类型是由节点对象组成的树。每个节点有一个节点类型零个或多个子节点。新的节点类型在 Scala 中定义为 TreeNode 类的子类。这些对象是不可变的,可以使用函数转换进行操作

举个简单的例子,假设我们有以下三个节点类型,可以用更简化的表达式表示为:

  • Literal(value: Int):代表常量
  • Attribute(name: String):代表输入一行数据的一个属性,例如:“x”
  • Add(left: TreeNode, right: TreeNode):对两个表达式加和

这些类构建成树;例如,表达式 x+(1+2),可以在 Scala 代码中表示为:

image.png

规则

规则用于对树进行操作,是一个将一棵树转换为另外一棵树的方法。最常见的方式是使用一组模式匹配函数,找到并替换特定结构的子树。模式匹配是许多函数式编程语言的特性,允许从代数数据类型的嵌套结构中进行值提取。在Catalyst 中,树提供的转换方法可以递归地应用模式匹配函数到树的所有节点。例如,我们可以实现一个常量合并操作的规则:

tree.transform {
  case Add(Literal(c1), Literal(c2)) => Literal(c1+c2)
}

这条规则将树 x+(1+2)转换为一颗新树:x+3。实践中,规则需要执行多次才能完全转换一棵树。Catalyst 将规则分成批次,执行各个批次直到树不再更新为止。每条规则可以非常简单且是自包含的,但最终仍会在树上产生比较大的全局效果。在上面的例子中,就重复地应用规则不断折叠一棵大树。

另一个例子:第一个批次分析一个表达式并将类型赋给所有属性,而第二个批次可能使用这些类型进行不断折叠。每个批次之后,开发者还可以在生成的新树上运行健全性检查(例如,查看所有的属性都指定了类型),通常这也同样通过递归匹配来编写。

在 Spark SQL 中的使用

我们在四个阶段使用了 Catalyst 通用树转换操作框架

  1. 分析:语法树和元数据  (Catalog)  绑定,得到Resolved Logical Plan(Analyzed Logical Plan)
  2. 逻辑优化:使用RBO(逻辑优化规则)和CBO(成本优化规则)进行优化,得到新的语法树
  3. 物理计划:将Logical Plan 转换为多个Physical Plans ,使用Cost Model 选择最佳的Physical Plans
  4. 代码生成:编译部分查询为Java 字节码

分析

Spark SQL 以一个需要计算的关系开始,其来自SQL Parser返回的抽象语法树*(AST)*或来自使用 API 构造的 DataFrame 对象。在两种情况下,关系可能包含未解析的属性引用或关系,例如:在 SQL 查询 SELECT col FROM sales,col 的类型,甚至是否是一个合法的列名,在我们查询表 sales 之前都是未知的。如果我们不知道其类型或者没有匹配到输入表(或别名),那么这个属性就未被解析。Spark SQL 使用 Catalyst 规则和一个 Catalyst 对象去追踪所有数据源的表来解析这些属性。从未绑定的属性和数据类型构建一个未解析的逻辑计划,然后应用规则执行下面的步骤:

  1. 通过名字从 Catalog 中查找关系。
  2. 映射命名属性,如 col,到输入的给定操作符子项。
  3. 检查哪些属性引用了相同的值给它们一个相同的 ID(之后允许针对 col = col 这样的表达式进行优化)。
  4. 通过表达式传递和强制类型:举个例子,我们无法知道 1 + col 的返回类型,直到解析 col 并可能将其子表达式转换为兼容类型。

Analyzer.scala

逻辑优化

逻辑优化阶段对逻辑计划应用标准的基于规则的优化。这些规则包括:常数折叠(constant folding)、谓词下推(predicate pushdown)、投影剪枝(projection pruning)、空传播(null propagation)、布尔表达式简化(Boolean expression simplification)和其他规则。

Optimizer.scala

物理计划

Spark SQL 将一个逻辑计划使用匹配 Spark 执行引擎的物理操作符生成一个或多个的物理计划,然后应用成本模型选择其中一个。

基于成本的优化器只用于选择连接算法:对于已知的很小的关系,Spark SQL 使用 broadcast join(点对点的广播工具)。

物理计划同样执行基于规则的物理优化,如在一个 Spark 的 map 操作执行流水线投影*(Piplining Projection)*或过滤。除此之外,还可以从逻辑计划将操作推到支持谓词或投影下推的数据源。

SparkStrategies.scala.

代码生成

最后阶段包括是生成在机器上运行的 Java 字节码。因为 Spark SQL 经常对内存中的数据集进行操作,而处理是受 cpu 约束的,所以我们希望支持代码生成以加快执行速度。代码生成引擎相当于一个编译器,Catalyst 依赖于 Scala 语言的一个特殊功能——准引号,以简化代码生成。Quasiquotes 允许用 Scala 语言编程构造 AST ,然后在运行时将其提供给 Scala 编译器生成字节码。我们使用 Catalyst 将一个表示 SQL 表达式的树转换为一个表示 Scala 代码的 AST 来计算该表达式,然后编译并运行生成的代码。

以上面的 Add、 Attribute 和 Literal 树节点为例,它允许我们编写如  (x + y) + 1 之类的表达式。如果没有代码生成,则必须通过遍历 Add、 Attribute 和 Literal 节点的树来解释每一行数据中的这类表达式,这将引入大量分支和虚函数调用,从而降低执行速度。通过代码生成,我们可以编写一个函数将特定的表达式树转换为 Scala AST,如下所示:

def compile(node: Node): AST = node match {
  // 常量
  case Literal(value) => q"$value"
	// 属性
  case Attribute(name) => q"row.get($name)"
	// 操作符 ”+“
  case Add(left, right) => q"${compile(left)} + ${compile(right)}"
}
复制代码

以 q 开头的字符串是准引号,尽管看起来像字符串,但在编译时被 Scala 编译器解析,并表示其中代码的 AST

Quasiquotes 可以将变量或其他 AST 拼接到这些变量中,并使用  $  符号表示。例如,Literal (1)  将成为1的 Scala AST,而 *Attribute (“x”)*将成为 row.get (“x”) 。最后,像 Add (Literal (1) ,Attribute (“x”)  这样的树会成为 Scala 表达式的 AST1+row.get (“x”)

Quasiquotes 会在编译时进行类型检查以确保只有合适的 ASTs 或者字面量能够被替换,这比字符串连接更有用,而且是直接生成 Scala AST 树而不是在运行时运行 Scala 解析器。此外,由于每个节点代码的生成规则不需要知晓其子节点是如何构建的,因此它们是高度可组合的。最后,如果 Catalyst 缺少表达式级别的优化,Scala 编译器会对代码进行进一步的优化。
CodeGenerator.scala