从数据库系统到Spark SQL (三)

628 阅读11分钟

一个完整的MySQL查询如下:

但Spark SQL多少有些不同。因为Spark是出于大数据批处理的设计而诞生的。所以仅仅负责参与数据的计算。存储可以对接HDFS,Hive等数据库。接下来的文章,仅针对计算而展开

解析阶段

逻辑执行计划阶段会将用户的SQL语句转换成树型数据结构(逻辑算子树),SQL语句中蕴含着逻辑映射到逻辑算子树的不同节点

enmm 经过查询资料之后,发现有一篇文献中的章节描述的不错,所以这里放出原文链接以及部分相关思考Spark SQL Relational Data Processing in Spark

为了实现Spark SQL,设计了一个新的可扩展优化器Catalyst,基于Scala的函数式编程构建。Catalyst的可扩展设计有两个目的:

  • 在Spark SQL中更加容易地添加新的优化技术和特性,尤其是在大数据中要解决遇到的各种问题(比如,半结构化数据和高级分析)。
  • 让外部开发者能够扩展优化器,例如通过加入数据源的特定规则,能够将过滤或聚合下推到外部存储系统中,或者支持新的数据类型。Catalyst对RBO和CBO都支持。

虽然,可扩展优化器在过去已经被提出,但一般要求比较复杂的DSL来指定规则,和一个优化器编译器来将规则翻译为可执行代码。这将导致一个很重的学习曲线和维护负担。相反,Catalyst使用Scala函数式编程的标准特性,比如模式匹配,让开发人员使用完整的编程语言,同时使规则易于指定。函数式语言的设计部分是为了构建编译器,所以发现Scala非常适合这个任务。然而,Catalyst是第一个基于这种语言构建的生产级质量的查询优化器。

在核心代码中,Catalyst包含了一个通用的库,用于表示trees,和应用rules来看你管理trees。在这个框架之上,针对关系查询处理,已经建了许多的库(比如expressions、逻辑查询计划),以及都许多规则用于处理查询执行的不同阶段,包括分析、逻辑优化、物理计划以及codegen来将部分查询编译为Java字节码。针对最后一个,我们使用了另外一个Scala特性quasiquotes,使得运行时从可组合的表达式生成代码更加容易。最后,Catalyst提供了许多扩展点,包括外部数据与啊以及用户自定义类型。

1 Trees

在Catalyst中最重要的数据类型就是由多个node对象构成的tree。每个node有一个类型、0或多个子节点。新的node类型通过Scala定义,作为一个TreeNode的子类。这些对象是不可变的,可以通过函数式转换来控制,这个在后续章节讨论。

正如下面的例子,假设针对一个简单的表达式语言,有三个node类:

  • Literal(value: Int): 一个常量值;
  • Attribute(name: String): 一个输入row的属性,例如,”x”;
  • Add(left: TreeNode, right: TreeNode): 两个表达式求和。 这些类用来构建tree,例如,表达式x+(1+2),如图2所示,将通过如下Scala代码表示:
Add(Attribute(x), Add(Literal(1), Literal(2)))

2 Rules

Trees是可以通过Rules来控制的,规则一中可以将一个tree转化为另一个tree的functions。虽然一个规则在输入的tree上(假定tree就是一个Scala对象)可以执行任意代码,但大部分方式是通过一个模式匹配functions集合,来检索和使用特定的结构来替换子树。

模式匹配是许多函数式语言的一个特性,可以从关系代数数据类型的内嵌结构中抽取相关的信息。在Catalyst中,trees提供了一个transform的方法,可以在tree的所有节点上递归应用模式匹配函数,将每个符合模式的节点tranform成一个结果。例如,可以实现一个规则,折叠常量的Add操作,如下:

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

将这个规则,应用在x+(1+2)的树上,在图2中,合并成一个新的tree为x+3。case关键词是Scala中标准模式匹配的语法,可以用来匹配一个对象的类型,以及根据给定的名称来抽取值(如c1和c2)。

这个传递给transform的模式匹配表达式是一个partial function(偏序函数),意味着它只需要匹配所有可能的输入树的子集。Catalyst将测试给定规则适用于树的哪些部分,自动跳过并降序到不匹配的子树中。这个能力使得规则只需要应用在给定优化的树,没有匹配的部分不用考虑。因此,rules不需要被修改为算子的新类型,加入到系统中。

在同样的transform调用中,rules(以及Scala的模式匹配)能够匹配多个模式,使得它可以同时实现多个transformations:

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

实际上,rules也许需要执行多次才能完全transform一个tree。Catalyst将rules分组为batches,执行每个batch直到达到一个固定点,也就是在应用这些规则之后,直到tree停止改变。将规则运行到固定点意味着每个规则都可以是简单和自包含的,但最终仍然会对树产生更大的全局影响。在上述示例中,重复应用规则将会常量折叠一个更大的trees,比如(x+0)+(3+3)。在另一个例子中,第一个batch也会分析一个表达式,给所有attributes分配类型,而第二个batch也许会使用这些类型进行常量折叠。在每个batch处理之后,开发人员还可以对new tree运行健全性检查(例如,查看所有属性是否都被分配了类型),通常也是通过递归匹配来编写的。

最后,rule的条件和它们的内部包含任意Scala代码。这使得Catalyst比起DSL在优化器方面更加强大,同时保持了简单规则的简洁性。

在我们的经验中,在可变trees上的函数式transformations使得整个优化器更加容易推理和调试。还可以做到在优化器中实现并行化,虽然我们还没有使用到它。

3 在SparkSQL中使用Catalyst

我们在4个阶段使用Catalyst的通用tree transformation框架,如图所示:

  • 分析逻辑计划来解析引用;
  • 逻辑计划优化;
  • 物理计划;
  • 代码生成,将查询部分逻辑编译为Java字节码。(ps:比如全阶段代码生成wholeCodegen)
  • 在物理计划阶段,Catalyst也会生成多个计划,然后基于代价进行比较,其他所有阶段都是基于规则的。每个阶段使用不同类型的tree nodes;Catalyst包含了表达式、数据类型、逻辑和物理算子的。node库。

3.1 分析

Spark SQL从一个SQLParser返回的AST或者使用API创建DataFrame对象开始关系计算。在这两个场景中,这个关系包含了unresovled的attribute引用或者关系:例如,在SQL查询SELECT col FROM sales中,col的类型或者列名是否有效都是未知的直到我们找到表sales。如果我们不知道它的类型或者还没有匹配到一个输入的表(或者别名),那么attribute就被视为unresovled。Spark SQL使用Catalyst的rule和一个Catalog对象,在所有数据源上跟踪这个表来resolve这些attributes。首先,构建一个带有未绑定的attributes和数据类型的unresolved逻辑计划,然后按照如下流程应用rules:

从catalog中,通过name查找relations; 将命名了的attributes映射为输入提供给指定算子的children; 决定哪些attributes引用同一个值,给它们一个unique ID(之后可以优化表达式,比如col = col); 通过表达式,传播和强制类型:例如,我们不知道1 + col的类型,直到resolved了col和可能将子表达式转为兼容的类型。 总之,分析器的rule大概有1000行代码。

3.2 逻辑优化

逻辑优化阶段,将应用标准的基于规则的优化操作在逻辑计划上。这些包含了常量折叠、谓词下推、投影剪枝、null传播、布尔表达式简化以及其他rules。通常,我们发现针对各种场景,我们添加一些规则是非常简单的。例如,当我们在Spark SQL中,添加一个固定精度的DECIMAL类型时,我们想优化聚合操作,如在小精度的DECIMAL上的sums和averages操作;大概要12行代码来写一个规则,在SUM和AVG表达式中寻找decimals,之后把它们cast为一个unscaled 64位long,做完聚合计算后,再把结果转换回去。改规则的一个简单的版本,仅仅用于优化SUM表达式,如下再表示一下:

object DecimalAggregates extends Rule[LogicalPlan] {
    //Long中十进制最大位数
    val MAX_LONG_DIGITS = 18
    def apply(plan: LogicalPlan): LogicalPlan = {
        plan transformAllExpressions {
            case Sum(e@DecimalType.Expreesion(prec, scale))
               if prec + 10 <= MAX_LONG_DIGITS => 
            		MakeDecimal(Sum(LongValue(e)), prec + 10, scale)
        }
    }
}

另外一个例子,一个12行代码的规则,将带有一个简单的正则表达式的LIKE表达式优化为一个String.startWith或者String.contains的调用。在rule中可以自由使用任意Scala代码,来实现这些优化,它超越了模式匹配的子树结构,易于表达。

总之,逻辑优化的rule有800行代码。

4.3.3 物理计划

在物理计划阶段,Spark SQL使用逻辑计划,生成一个或多个物理计划,使用能够匹配Spark执行引擎的物理算子。使用代价模型来选择一个计划。当前,基于代价的优化器仅仅用于选择JOIN算法:针对一个较小的relations,Spark SQL会使用broadcast join,使用Spark中点对点广播的能力。然而,该框架支持更广泛地使用基于代价的优化,因为可以使用规则可以递归地估算整个tree的代价。在未来,我们打算实现更加丰富的基于代价的优化。

物理planner也可以执行基于规则的物理优化,比如将投影或过滤操作pipelining到Spark的map操作中。而且,可以将逻辑计划中的操作下推到支持谓词或投影下推的数据源中。

总之,物理计划的rule大概有500行代码。

4.3.4 代码生成

查询优化的最后一个阶段,涉及到生成Java字节码,运行在每个机器上。因为,Spark SQL经常在内存的Datasets上执行操作,处理是受限于CPU的,因此我们想支持codegen来加速执行。尽管如此,codegen引擎通常很难构建,实质上相当于一个编译器。Catalyst基于Scala语言的特殊特性,quasiquotes,使得codegen变得容更加简单。Quasiqutoes允许在Scala语言中以编程方式构造抽象语法树(AST),然后可以在运行时将其提供给Scala编译器以生成字节码。我们使用Catalyst将SQL中tree表达式转换为Scala代码的AST,来计算这个表达式,之后编译和运行生成的代码。

正如章节4.2中介绍的例子,Add、Attribute以及Literal的tree node,可以用来编写一个表达式(x+y)+1。如果没有codegen,这个表达式将针对每行数据,向下遍历tree中的Add、Attribute和Literal节点。这将会引入大量的分析和虚函数的调用,降低执行的性能。通过codegen,我们能够编写一个函数,将特定的expression tree翻译为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开头的字符串就是quasiquotes,可以通过Scala Compiler解析的,来表示内部代码的AST。Quasiquotes可以使用变量或者其他AST‘s的片段,通过$来表示。例如,Literal(1)转为Scala的AST为1,而Attribute(“x”)转为row.get(“x”)。最后,类似Add(Literal(1), Attribute(“x”))的tree,转为Scala代码的AST为1+row.get(“x”)。

Quasiquotes在编译时进行类型检测,以确保仅有正确的ASTs或者literal被替换,这个比起字符串拼接更有使用意义,它们将直接生成Scala AST,而不是在运行执行Scala解析器。而且,它们是高度可组装的,因为针对每个node的codegen规则并不需要知道它的孩子nodes是如何构建返回的。最后,生成的code会进一步通过Scala编译器来优化,以防Catalyst错过表达式级优化。