上一篇文章我们讲解了 Apache Calcite 架构设计及 SQL 优化器概述,这篇文章我们将接着介绍 Apache Calcite 组件的关键原理。
三、Calcite SQL 解析和元数据验证关键原理解析
3.1 Calcite SQL 解析关键原理
当一条 SQL 语句到达引擎,首先通过的是 SQL 解析器,SQL 解析器将用户输入的 SQL 语句转换为一棵抽象语法树,同时在这个过程里,它还会对SQL进行词法和语法校验,如果输入的 SQL 有词法或语法问题,会在这个阶段收到错误提示。
在 Calcite 中,一棵抽象语法树通常用SqlNode来表示。以下面这条 SQL 为例:
这条SQL在经过 Calcite 解析后,形成的 AST 抽象语法树如下:
图中的每一个节点都是一个 SqlNode,SqlNode作为抽象语法树的顶级抽象,相互关联组成了一颗大的抽象语法树。
Calcite 支持通过两种方式进行 SQL 解析:即基于 Java CC(Java Compiler Compiler)的 SQL 解析以及基于 Anltr 的 SQL 解析。Calcite 默认基于 Java CC 来做 SQL 解析:Calcite 能够基于Parser.jj 文件,使用 Java CC 技术,动态生成 SQL 解析实现类(XXXParserImpl),Calcite 本身内置有一个 Parser.jj 文件,如果我们需要自定义 SQL 语法的时候,我们也可以使用 FMPP 技术,自定义 ftl(freemaker 语言模板)和 config.fmpp 文件的内容,来控制最终需要生成的Parser.jj 的内容,从而实现自定义 SQL 语法。
config.fmpp 主要定义 SQL 语法中的关键字、外部引入的类(如自定义的 SqlNode)、以及一些 SQL 解析方法定义等等,具体的实现一般会在 ftl 文件中。以 Apahce Flink 项目举例,组成其 SQL 解析器模块的主要文件如下:
3.2 Calcite SQL 校验和关系代数转换流程
3.2.1 Calcite SQL 元数据校验
上文我们提到:SQL 解析后会形成一棵 SqlNode 的抽象语法树,下一步我们需要对这棵树进行元数据验证,以校验SQL的合法性。这里需要注意,经过元数据验证之后的SqlNode,其本质还是一棵SqlNode 树,之后依然需要经过 SqlNode 到RelNode关系代数的转换,才会形成一棵由RelNode组成的关系代数节点树。
在 Calcite 元数据验证阶段,其主要验证三个点:
对 SQL 语句中的 Table Schema 进行校验,如 Table 存不存在,Column 存不存在
对 SQL 语句中函数进行校验,如函数是否存在
针对数据类型的校验,如函数中的参数数据类型和函数定义是否匹配
Calcite 中元数据验证源码的核心方法入口为:SqlValidatorImpl.validate(),SqlValidatorImpl的使用,需要配合SqlOperatorTable,SqlValidatorCatalogReader,RelDataTypeFactory三个类使用。SqlValidatorImpl 的初始化构造器的源码如下:
初始化 SqlValidatorImpl 需要传入三类参数:
SqlOperatorTable-生产函数和 SQL Operator 的工厂,为SqlValidatorImpl 提供函数、SQL 操作符(比如 >,=,<)的元数据相关信息
SqlValidatorCatalogReader - 提供 Table Schema 和 Rowcount 等相关信息,其中CatalogReader 封装了从底层的元数据存储中读取 Table Schema 的数据接口
RelDataTypeFactory 和RelDataTypeSystem – 为SqlValidatorImpl 提供所有需要识别的数据类型,以及精度相关的信息。
要验证一个 SQL 中用到的函数,我们可以从自定义实现的SqlOperatorTable中进行查找,一般有两种方法:lookupOperatorOverloads 和getOperatorList ,lookupOperatorOverloads主要逻辑是将某类函数的具体信息查找出来,并添加到输入参数operatorList 集合中。
对于数据类型以及默认的精度等信息,在Calcite中可以从RelDataTypeFactory和RelDataTypeSystem 中获取,一般这两个接口需要自定义扩展实现。虽然 Calcite 中已经有这两个接口的默认实现,但一般我们需要的类型以及精度信息,不会和 Calcite 内置实现完全一样,所以还是需要自己复写这两个接口中的部分方法。
SqlValidatorCatalogReader对上层元数据验证提供了 Table 信息获取接口,同时屏蔽了底层获取具体 Table 元数据的实现细节。根据 SQL 查询语句中 From 后面表的 paths,调用该接口,可获取 Table 的 Schema Table 的 RowCount ,以及PreparingTable等信息。
3.2.2 Calcite 关系代数转换流程
进行元数据校验之后,我们得到的还是一棵SqlNode 抽象语法树,接下来我们需要将其转换为RelNode 关系代数 Tree,后续才能基于关系代数优化理论对其进行优化。
关系代数转化阶段和元数据验证阶段是紧密在一起的,经过元数据验证阶段后,每个SqlNode输出的字段和数据类型信息都被添加至SqlValidatorImpl 中的nodeToTypeMap 集合中,这样从SqlNode转到相应的RelNode时,每个RelNode 的数据类型,也都能够被 bind 上。
Calcite 使用SqlToRelConverter将SqlNode 转换为RelNode ,其入口方法是convertQuery(),之后会调用convertQueryRecursive方法,底层最终调用 convertXXX 方法(如下图所示),递归遍历抽象语法树将SqlNode 转换为RelNode ,至此,SqlNode 转 RelNode 转换完毕。
四、Calcite SQL 执行计划生成与优化
4.1 Calcite RBO 启发式优化
SqlNode转换为RelNode关系代数节点树后,Calcite会对其分别进行 RBO(Rule-Based Optimization)和 CBO (Cost-Based Optimization)优化。RBO 优化一般会结合一些预定且有收益的逻辑优化规则,一般发生在 CBO 优化之前。RBO 改变的是计划的逻辑,而不是物理实现,这里只是对逻辑计划做等价转换。如果需要更改计划的物理实现(Convention),需要结合ConverterRule来进行实现。
下面以 Calcite FilterIntoJoinRule 优化规则进行举例,FilterIntoJoinRule 规则主要尝试将 Join 之上的 Filter 过滤条件,下推到 Join RelNode 下面,这样的好处是能够提前对数据进行过滤,减少进入 Join RelNode 的数据量,从而降低计算成本。下图左边部分是输入的原始计划,右边部分是经过FilterIntoJoinRule 优化后的计划,可以看到,Filter RelNode 已经下推到 Join 之下了。
在Calcite 中,RBO 优化主要使用 HepPlanner 来实现,在HepPlanner 优化之前,会将 SQL Query 计划 Tree 中的每一个 RelNode,先转换为 HepRelVertex 节点,通过 HepPlanner 的 setRoot 方法,从根节点,递归的将所有 RelNode 加入到图中,同时根据每个 RelNode 以及输入信息,构建图的边,在这个过程中,构建 HepRelVertex,再构建出一个 DAG(有向无环图),从设置的根节点的 RelNode,按照预设定遍历顺序,进行 RBO 优化。
那么 Calicte 为什么会将RelNode 转换为HepRelVertex 呢?因为在进行 RBO 的时候,需要记录RelNode转换前与转换后的相关信息,而RelNode本身只能记录其输入信息,无法记录其输出的节点信息,所以这里引入 HepRelVertex 。CalciteHepPlanner节点优化遍历方式有以下四种:
ARBITRARY,DEPTH-FIRST -- 本质上两者底层都是从深度优先遍历和优化,核心区别在于:当一个规则作用于计划时,两者都会从新产生的节点进行遍历,顺序为 DEPTH-FIRST 时,如果之前已经应用过某个规则,那么在之后转换的新的计划 Tree 中,不会再使用该规则,所以它比 ARBITRARY 效率更高。
BOTTOM-UP -- 从叶子节点到根节点,自低向上进行遍历和适用规则优化。
TOP-DOWN -- 从根节点到叶子阶段,自顶向下进行遍历优化。
最终经过 RBO 优化,会产生一棵优化后的逻辑计划的关系代数Tree。当然,RBO 优化也有一定的缺陷性,因为 RBO 优化阶段更倾向于一定有收益的优化规则,往往会导致可运用的优化规则是有限的,其适用场景相对有限。同时 RBO 由于只是根据先验经验来选择适用一定的规则集合,没有结合计划实际适用的计算资源来进行判定,可能在某些场景下,RBO 优化会适得其反。
4.2 Calcite 物化视图改写
在 RBO 优化之后,可以使用物化视图来对查询进行优化。物化视图本质是特定逻辑下的预计算结果。Calcite 中支持两种物化视图改写方式:基于 UnifyRule 的局部匹配替换改写和基于MaterializedViewRule 结构化信息改写。前者可以在 CBO 优化之前单独进行优化,配合可用的物化RelOptMaterialization 集合,使用SubstitutionVisitor 来进行改写。基于MaterializedViewRule结构化信息改写,从本质上来讲,它就是优化规则的一种实现,需要添加到VolcanoPlanner 优化规则集合中,和 CBO 优化一起来进行配合使用。
使用基于UnifyRule 进行局部匹配替换的物化改写时,需要传入三类信息:物化视图的计算逻辑 A、SQL 查询计算逻辑 B、代替物化视图计算逻辑的 C,其原理概括来说,是在 SQL 查询计划中,自顶向下找到和物化视图计算计划中相等的子集顶层的RelNode E,然后从 E 之上,使用UnifyRule,自低向上变换 Query 的计划结构,最终在 SQL 查询计划中,使用计划 C 来替换 B 。
UnifyRule 局部匹配替换改写的实现,整体相对清晰和简洁,而且改写的速度也相对较快,Calcite 内置的 UnifyRule 已经可以支持大部分场景。但其缺点也很明显,其改写范围本质上就是取决于UnifyRule的数量和所覆盖的场景,对于有很多 Join 或者其他非常复杂的 SQL 查询,UnifyRule 可能就不支持,此时需要我们自定义UnifyRule 来Case By Case 的支持,因此UnifyRule 通用性相对较低。
基于MaterializedViewRule 结构化信息改写,会首先将 SQL 查询转换为 SPJG(Join-Select-Project-GroupBy)的标准形式,然后提取查询中的 Join,Projects,Filters,Grouping 和 Aggregations 五种表达式,运用一系列的步骤分别与物化视图对应的表达式进行匹配和改写。由于是将查询转换为标准形式,那么在其之上扩展的逻辑也会相对更加通用,使用范围会更广。但缺点在于其技术实现原理过于底层,需要和 CBO 优化器配合使用,会导致对物化视图改写逻辑跟踪和问题排查难度较大,逻辑相对比较黑盒,上手成本会比较高。同时可能存在对于部分 SQL 逻辑复杂的场景(比如存在 10 + 以上表关联),改写效率会比UnifyRule 低。
4.3 Calcit CBO 基于代价的优化
经过 RBO 优化或者物化视图改写之后的执行计划, 本质上还是一个逻辑执行计划,所以这个阶段需要经过 CBO 优化器,选出一个 Cost 最低的物理计划。既然涉及到 Cost,肯定涉及到 Calcite 的物理转换。在 Calcite 中,该过程通过Convention来进行实现,一般在 CBO 阶段后,我们需要自定义相应的Convention来定义转换逻辑,以下是 Apache Flink 的Convention举例:
实现一个 CBO 优化器,包含以下几块关键内容:
搜索框架(比如 Volcano 搜索框架、Cascades 搜索框架),如何快速从众多计划空间中,找到最优物理计划。
Cost Model,每种 RelNode 自身的 Cost 如何计算,基数如何评估,以及如何结合统计信息模型,如何让计算的 Cost 更加贴近真实计算。
优化规则集合,包括 Transformation Rule(逻辑计划的优化规则)和 Implementation Rule(逻辑 RelNode 转物理 RelNode 优化规则)
物理转换信息,Calcite 里面使用RelTrait 来表示物理特质信息,Calicte 内置有三种物理特质:Convention (一般是物理实现,比如 Flink、Spark)、RelCollation (排序信息)、RelDistribution
一般在流式计算中,主要会用到 RBO 规则进行优化,因为流式系统的处理数据是动态且不确定的,会导致 Cost 评估不准。在 OLAP 分析或者批处理系统中,RBO 和 CBO 会一起结合使用来对 SQL Query 计划优化。
Calcite 中使用 RelOptCost 作为优化器 Cost 的基础接口,默认有 rowCount(数据行数)、CPU(CPU 的消耗)、IO(磁盘 IO 消耗),我们也可以通过实现RelOptCost 接口,自定义我们的 Cost Model,一般 SQL 优化器 Cost 除了 CPU、磁盘 IO 消耗外,还有内存以及网络 IO,最终代价还取决于优化器优化的资源类型。下面以 Flink 中的 Cost 举例:
那么一个RelNode Tree 的 Cost 怎么计算呢,在 Calcite 中,RelNode 接口有computeSelfCost 方法,底层具体实现的RelNode,我们可以复写该方法,来计算自身的 Cost 成本。其默认实现为:逻辑计划(LogicalXX 开头),其 Cost 成本为无限大,同时它的 Convention 为Convention.None。一棵RelNode 关系代数 Tree 的成本,就是这棵关系代数 Tree 中所有RelNode节点的自身 Cost 之和,即每个 RelNode computeSelftCost 值相加。
Calcite 的 Cost 计算以及 Cost 计算相关元数据统一使用RelMetadataQuery 进行封装,BuiltInMetadata 类中定义了每一类元数据的接口,比如 Selectivity(选择率)、RowCount(行数)、DistinctRowCount(去重后的行数)等等,最后通过实现MetadataHandler来自定义元数据的计算逻辑,这个阶段可以结合统计信息服务来使用。
Calcite 使用RelSet和RelSubSet 来存储等价计划以及某一种物理特质下最优的物理计划。在优化前,会将 RelNode Tree 中每一个 RelNode 注册到 VolcanoPlanner 中,都会有一个RelSubset(Convention 为 Convention.None),随着后续结合 ConverterRule(物理转换规则),会更新RelSubset 中 Best 所指的计划,最后结合动态规划,找到最优的物理计划。
五、Calcite SQL 方言转换
Calcite 中 SQL 方言转换最重要的两个类是RelToSqlConverter 和 SqlDialect ,其中RelToSqlConverter 会将RelNode转换为SqlNode,SqlDialect 则是统一了对 SqlNode方言翻译的所有方法,我们可以自定义复现SqlDialect 的方法,来控制方言转换的具体行为,在 Calcite 内部,已经有很多数据引擎的SqlDialect方言转换的实现,但本身实现逻辑较少,要想生产级别的使用,还需要自定义扩展内部的实现逻辑,下图是 Calcite 内置的 SqlDialect 类:
要实现对自定义 SQL 方言的转换,核心是在相应引擎的SqlDialect方言转化类中进行自定义扩展。默认情况下,每个SqlNode都会有unparse 方法,我们可在此方法中定义其转换为相应 SQL 的实现:
下图是 CalciteRelNode转换为 SQL 方言的示例代码:
SQL 方言转换的核心源码调用时序图如下:
六、总结
Apache Calcite 作为一款成熟的开源项目,已经在众多的 Apache 和商业项目中进行使用(下图是使用了Calcite 的项目):
与此同时,大量商业产品也在使用 Calcite,广泛的应用也验证了 Calcite 项目自身的成熟性。使用 Calcite 能够降低开发 SQL Base 的项目复杂度,在 SQL 层,我们无需重复对 SQL 解析、元数据验证、RBO 和 CBO 等底层框架造轮子,大大地提高了开发效率。
钱可以带来快乐,技术也可以!如果你对数据虚拟化、Calcite 原理技术、湖仓平台、SQL 优化器感兴趣,欢迎关注“Aloudata技术团队”公众号。
✎ 本文作者/ 沈浪,Aloudata OLAP 引擎研发专家, 毕业于电子科技大学,曾就职于阿里、有赞、字节的基础设施团队,参与数据仓库、实时计算、数据湖核心研发,现负责Aloudata 湖仓查询引擎 SQL 查询优化器的研发工作。