前言
Spark SQL 的核心是Catalyst 优化器,它以一种全新的方式利用高级语言的特性(例如:Scala 的模式匹配和Quasiquotes)构建一个可扩展的查询优化器。
Spark SQL架构图
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 代码中表示为:
规则
规则用于对树进行操作,是一个将一棵树转换为另外一棵树的方法。最常见的方式是使用一组模式匹配函数,找到并替换特定结构的子树。模式匹配是许多函数式编程语言的特性,允许从代数数据类型的嵌套结构中进行值提取。在Catalyst 中,树提供的转换方法可以递归地应用模式匹配函数到树的所有节点。例如,我们可以实现一个常量合并操作的规则:
tree.transform {
case Add(Literal(c1), Literal(c2)) => Literal(c1+c2)
}
这条规则将树 x+(1+2)转换为一颗新树:x+3。实践中,规则需要执行多次才能完全转换一棵树。Catalyst 将规则分成批次,执行各个批次直到树不再更新为止。每条规则可以非常简单且是自包含的,但最终仍会在树上产生比较大的全局效果。在上面的例子中,就重复地应用规则不断折叠一棵大树。
另一个例子:第一个批次分析一个表达式并将类型赋给所有属性,而第二个批次可能使用这些类型进行不断折叠。每个批次之后,开发者还可以在生成的新树上运行健全性检查(例如,查看所有的属性都指定了类型),通常这也同样通过递归匹配来编写。
在 Spark SQL 中的使用
我们在四个阶段使用了 Catalyst 通用树转换操作框架
- 分析:语法树和元数据 (Catalog) 绑定,得到Resolved Logical Plan(Analyzed Logical Plan)
- 逻辑优化:使用RBO(逻辑优化规则)和CBO(成本优化规则)进行优化,得到新的语法树
- 物理计划:将Logical Plan 转换为多个Physical Plans ,使用Cost Model 选择最佳的Physical Plans
- 代码生成:编译部分查询为Java 字节码
分析
Spark SQL 以一个需要计算的关系开始,其来自SQL Parser返回的抽象语法树*(AST)*或来自使用 API 构造的 DataFrame 对象。在两种情况下,关系可能包含未解析的属性引用或关系,例如:在 SQL 查询 SELECT col FROM sales,col 的类型,甚至是否是一个合法的列名,在我们查询表 sales 之前都是未知的。如果我们不知道其类型或者没有匹配到输入表(或别名),那么这个属性就未被解析。Spark SQL 使用 Catalyst 规则和一个 Catalyst 对象去追踪所有数据源的表来解析这些属性。从未绑定的属性和数据类型构建一个未解析的逻辑计划,然后应用规则执行下面的步骤:
- 通过名字从 Catalog 中查找关系。
- 映射命名属性,如 col,到输入的给定操作符子项。
- 检查哪些属性引用了相同的值给它们一个相同的 ID(之后允许针对
col = col这样的表达式进行优化)。 - 通过表达式传递和强制类型:举个例子,我们无法知道
1 + col的返回类型,直到解析 col 并可能将其子表达式转换为兼容类型。
逻辑优化
逻辑优化阶段对逻辑计划应用标准的基于规则的优化。这些规则包括:常数折叠(constant folding)、谓词下推(predicate pushdown)、投影剪枝(projection pruning)、空传播(null propagation)、布尔表达式简化(Boolean expression simplification)和其他规则。
物理计划
Spark SQL 将一个逻辑计划使用匹配 Spark 执行引擎的物理操作符生成一个或多个的物理计划,然后应用成本模型选择其中一个。
基于成本的优化器只用于选择连接算法:对于已知的很小的关系,Spark SQL 使用 broadcast join(点对点的广播工具)。
物理计划同样执行基于规则的物理优化,如在一个 Spark 的 map 操作执行流水线投影*(Piplining Projection)*或过滤。除此之外,还可以从逻辑计划将操作推到支持谓词或投影下推的数据源。
代码生成
最后阶段包括是生成在机器上运行的 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 表达式的 AST:1+row.get (“x”) 。
Quasiquotes 会在编译时进行类型检查以确保只有合适的 ASTs 或者字面量能够被替换,这比字符串连接更有用,而且是直接生成 Scala AST 树而不是在运行时运行 Scala 解析器。此外,由于每个节点代码的生成规则不需要知晓其子节点是如何构建的,因此它们是高度可组合的。最后,如果 Catalyst 缺少表达式级别的优化,Scala 编译器会对代码进行进一步的优化。
CodeGenerator.scala。