calcite

目录

1.简介

2.核心架构

2.1 四个阶段 

2.2 四大组件

3.SQL Parser

3.1 SqlNode

3.2 JavaCC

4. Catalog 

5. SQL Validator

5.2 校验 namespace

5.3 计算得到 rowType

5.4 总结

5.5 校验 source

5.6 校验 source 和 target 是否兼容

6. Query optimizer

6.2 RelNode 优化

6.3 优化器(HepPlanner)

6.3.1 初始化

6.3.2 遍历

6.3.3 匹配

6.3.4 优化

 7. 参考资料


1.简介

Calcite 是什么?如果用一句话形容 Calcite,Calcite 是一个用于优化异构数据源的查询处理的基础框架

最近十几年来,出现了很多专门的数据处理引擎。例如列式存储 (HBase)、流处理引擎 (Flink)、文档搜索引擎 (Elasticsearch) 等等。这些引擎在各自针对的领域都有独特的优势,在现有复杂的业务场景下,我们很难只采用当中的某一个而舍弃其他的数据引擎。当引擎发展到一定成熟阶段,为了减少用户的学习成本,大多引擎都会考虑引入 SQL 支持,但如何避免重复造轮子又成了一个大问题。基于这个背景,Calcite 横空出世,它提供了标准的 SQL 语言、多种查询优化和连接各种数据源的能力,将数据存储以及数据管理的能力留给引擎自身实现。同时 Calcite 有着良好的可插拔的架构设计,我们可以只使用其中一部分功能构建自己的 SQL 引擎,而无需将整个引擎依托在 Calcite 上。因此 Calcite 成为了现在许多大数据框架 SQL 引擎的最佳方案。我们计算引擎组也基于 Calcite 实现了一个自用的 SQL 校验层,当用户提交 Flink SQL 作业时需要先进过一层语义校验,通过后再利用校验得到的元数据构建模板任务提交给 Flink 引擎执行。

注:目前 Calcite 的官方最新版本是 v1.27,Flink 1.12 使用的是 Calcite v1.26,本文的内容基于 Calcite 1.20 编写,但所有核心内容均不受版本影响。

2.核心架构

下图的 Calcite 架构图来源于论文 Apache Calcite: A Foundational Framework for Optimized Query Processing Over Heterogeneous Data Sources。

calcite_第1张图片

中间的方框总结了 Calcite 的核心结构,首先 Calcite 通过 SQL Parser 和 Validator 将一个 SQL 查询解析得到一个抽象语法树 (AST, Abstract Syntax Tree),由于 Calcite 不包含存储层,因此它提供了另一种定义 table schema 和 view 的机制—— Catalog 作为元数据的存储空间(另外 Calcite 提供了 Adaptor 机制连接外部的存储引擎获取元数据,这部分内容不在本文范围内)。之后,Calcite 通过优化器生成对应的关系表达式树,根据特定的规则进行优化。优化器是 Calcite 最为重要的一部分逻辑,它包含了三个组件:Rule、MetadataProvider(Catalog)、Planner engine,这些组件在文章后续都会有具体的讲解。

通过架构图我们可以看出,Calcite 最大的特点(优势)是它将 SQL 的处理、校验和优化等逻辑单独剥离出来,省略了一些关键组件,例如,数据存储,处理数据的算法以及用于存储元数据的存储库。其次 Calcite 做得最出色的地方则是它的可插拔机制,每个大数据框架都可以选择 Calcite 的整体或部分模块建立自己的 SQL 处理引擎,如 Hive 自己实现了 SQL 解析,只使用了 Calcite 的优化功能,Storm 以及 Flink 则是完全基于 Calcite 建立了 SQL 引擎,具体如下表所示:

calcite_第2张图片

2.1 四个阶段 

Calcite 框架的运行主要分四个阶段

  1. Parse:使用 JavaCC 生成的解析器进行词法、语法分析,得到 AST;

  2. Validate:结合元数据进行校验;

  3. Optimize:将 AST 转化为逻辑执行计划(tree of relational expression),并根据特定的规则(heuristic 或 cost-baesd)进行优化;

  4. Execute:将逻辑执行计划 转化成引擎特有的执行逻辑,比如 Flink 的 DataStream。

2.2 四大组件

围绕着这个运行流程,Apache Calcite 最核心的框架可以拆分为四个组件

  1. SQL Parser:将符合语法规则的 SQL 转化成 AST(Sql text → SqlNode),Calcite 提供了默认的 parser,但也可以基于 JavaCC 生成自定义的 parser;

  2. Catalog:定义记录了 SQL 的 metadata 和 namespace,方便后续的访问和校验;

  3. SQL Validator:结合 Catalog 提供的元数据校验 AST,具体的实现都在 SqlValidatorImpl 中;

  4. Query Optimizer:这块概念较多,首先需要将 AST 转化成逻辑执行计划(即 SqlNode → RelNode),其次使用 Rules 优化逻辑执行计

3.SQL Parser

上文提到,SQL Parser 的作用是将 SQL 文本切割成一个个 token 并生成 AST,每个 token 在 Calcite 中由 SqlNode 表示(即代表 AST 的一个个结点),SqlNode 也可以通过 unparse 方法重新生成 SQL 文本。为了方便说明,我们引入一个 SQL 文本,通过观察它在 Calcite 中的变化来摸清 Calcite 的原理,后续的校验、优化我们也会根据具体场景引入不同的 SQL 文本进行分析。

INSERT INTO sink_table SELECT s.id, name, age FROM source_table s JOIN dim_table d ON s.id=d.id WHERE s.id>1;

3.1 SqlNode

calcite_第3张图片

INSERT 被 Parser 解析后会转化成一个 SqlInsert,而 SELECT 则转化成 SqlSelect,以上述的 SQL 文本为例,解析后会得到如下结构:

calcite_第4张图片

下面根据该图讲解 SqlNode 中一些较常见的核心结构。

3.1.1 SqlInsert

首先这是个动作为 INSERT 的 DDL 语句,因此整个 AST root 是一个 SqlInsert,SqlInsert 中有个有许多成员变量分别记录了这个 INSERT 语句的不同组成部分:

  • targetTable:记录要插入的表,即 sink_table,在 AST 中表示为 SqlIdentifier

  • source:标识了数据源,该 INSERT 语句的数据源是一个 SELECT 子句,在 AST 中表示为 SqlSelect;

  • columnList:要插入的列,由于该 Insert 语句未显式指定所以是 null,会在校验阶段动态计算得到。

3.1.2 SqlSelect

SqlSelect 是该 INSERT 语句的数据源部分被 Parser 解析生成的部分,它的核心结构如下:

  • selectList:指 SELECT 关键字后紧跟的查询的列,是一个 SqlNodeList,在该例中由于显式指定了列且无任何函数调用,因此 SqlNodeList 中是三个 SqlIdentifier;

  • from:指 SELECT 语句的数据源,该例中的数据源是表 source_table 和 dim_table 的连接,因此这里是一个 SqlJoin;

  • where:指 WHERE 子句,是一个关于条件判断的函数调用,SqlBasicCall,它的操作符是一个二元运算符 >,被解析为 SqlBinaryOperator,两个操作数分别是 s.id(SqlIdentifier) 和 1(SqlNumberLiteral)。

3.1.3 SqlJoin

SqlJoin 是该 SqlSelect 语句的 JOIN 部分被 Parser 解析生成的部分:

  • left:代表 JOIN 的左表,由于我们用了别名,因此这里是一个 SqlBasicCall,它的操作符是 AS,被解析为 SqlAsOperator,两个操作数分别是 source_table(SqlIdentifier) 和 s(SqlIdentifier);

  • joinType:代表连接的类型,所有支持解析的 JOIN 类型都定义在 org.apache.calcite.sql.JoinType 中,joinType 被解析为 SqlLiteral,它的值即是 JoinType.INNER;

  • right:代表 JOIN 的右表,由于我们用了别名,因此这里是一个 SqlBasicCall,它的操作符是 AS,被解析为 SqlAsOperator,两个操作数分别是 dim_table(SqlIdentifier) 和 d(SqlIdentifier);

  • conditionType:代表 ON 关键字,是一个 SqlLiteral;

  • condition:与 3.1.2 的 where 相似,是一个关于条件判断的函数调用,SqlBasicCall,它的操作符是一个二元运算符 =,被解析为 SqlBinaryOperator,两个操作数分别是 s.id(SqlIdentifier) 和 d.id(SqlNumberLiteral)。

3.1.4 SqlIdentifier

SqlIdentifier 翻译为标识符,标识 SQL 语句中所有的表名、字段名、视图名(* 也会识别为一个 SqlIdentifier),基本所有与 SQL 相关的解析校验,最后解析都到 SqlIdentifier 这一层结束,因此也可以认为 SqlIdentifier 是 SqlNode 中最基本的结构单元。SqlIdentifier 有一个字符串列表 names 存储实际的值,用列表示因为考虑到全限定名,如 s.id,在 names 会占用两个元素格子,names[0] 存 s,names[1] 存 id。

3.1.5 SqlBasicCall

SqlBasicCall 包含所有的函数调用或运算,如 AS、CAST 等关键字和一些运算符,它有两个核心成员变量:operator 和 operands,分别记录这次函数调用/运算的操作符和操作数,operator 通过 SqlKind 标识其类型。

3.2 JavaCC

Calcite 没有自己造词法、语法分析的轮子,而是采用了主流框架 JavaCC,并结合了 Freemarker 模板引擎来生成 LL(k)parser,JavaCC(Java Compiler Compiler)是一个用Java语言写的一个Java语法分析生成器,它所产生的文件都是纯Java代码文件。用户只要按照 JavaCC 的语法规范编写 JavaCC 的源文件,然后使用 JavaCC 插件进行 codegen,就能够生成基于Java语言的某种特定语言的分析器。

Freemarker 是一个模板渲染引擎,通过它建立内置模板,结合自定义的拓展语法可以快速生成我们想要的语法描述文件。

在 Calcite 中,Parser.jj 是 Calcite 内置的模板文件,.ftl 为自定义拓展模板,config.fmpp 用于声明数据模型,首先 Calcite 通过 fmpp-maven-plugin 插件生成最终的 Parser.jj 文件,再利用 javacc-maven-plugin 插件生成对应的 Java 实现代码,具体的流程图如下:

calcite_第5张图片

4. Catalog 

Catalog 保存着整个 SQL 的元数据和命名空间,元数据的校验都需要通过 Catalog 组件进行,Catalog 中最关键的几个结构如下:

接口/类

备注

Schema

表和函数的命名空间,是一个多层结构(树结构),Schema 接口虽然存储了元数据,但它本身只提供了查询解析的接口,用户一般需要实现该接口来自定义元数据的注册逻辑。

SchemaPlus

Schema 的拓展接口,它提供了额外的方法,能够显式添加表数据。设计者希望用户使用 SchemaPlus 注册元数据,但不要自己对 SchemaPlus 做新的实现,而是直接使用 calcite 提供的实现类。

CalciteSchema

包装用户自定义的 Schema。

Table

最基础的元数据,通常通过 Schema 的 getTable 得到。

RelDataType

表示一个标量表达式或一个关系表达式返回结果(行)的类型。

RelDataTypeField

代表某列字段的结构。

RelDataTypeSystem

提供关于类型的一些限制信息,如精度、长度等。

RelDataTypeFactory

抽象工厂模式,定义了各种方法以实例化 SQL、Java、集合类型,创建这些类型都实现了 RelDataType 接口。

这些结构大致可以分为三类:

  • 元数据管理模式和命名空间;

  • 表元数据信息;

  • 类型系统。

Calcite 的 Catalog 结构复杂,但我们可以从这个角度来理解 Catalog,它是 Calcite 在不同粒度上对元数据所做的不同级别的抽象。首先最细粒度的是 RelDataTypeField,代表某个字段的名字和类型信息,多个 RelDataTypeField 组成了一个 RelDataType,表示某行或某个标量表达式的结果的类型信息。再之后是一个完整表的元数据信息,即 Table。最后我们需要把这些元数据组织存储起来进行管理,于是就有了 Schema。

5. SQL Validator

Calcite 提供的 Validator 流程极为复杂,但概括下来主要做了这么一件事,对每个 SqlNode 结合元数据校验其语义是否正确,这些语义包括:

  • 验证表名是否存在;

  • select 的列在对应表中是否存在,且该匹配到的列名是否唯一,比如 join 多表,两个表有相同名字的字段,如果此时 select 的列不指定表名就会报错;

  • 如果是 insert,需要插入列和数据源进行校验,如列数、类型、权限等;

calcite_第6张图片

Calcite 提供的 validator 和前面提到的 Catalog 关系紧密,Calcite 定义了一个 CatalogReader 用于在校验过程中访问元数据 (Table schema),并对元数据做了运行时的一些封装,最核心的两部分是 SqlValidatorNamespace 和 SqlValidatorScope。

  • SqlValidatorNamespace:描述了 SQL 查询返回的关系,一个 SQL 查询可以拆分为多个部分,查询的列组合,表名等等,当中每个部分都有一个对应的 SqlValidatorNamespace。

  • SqlValidatorScope:可以认为是校验流程中每个 SqlNode 的工作上下文,当校验表达式时,通过 SqlValidatorScope 的 resolve 方法进行解析,如果成功的话会返回对应的 SqlValidatorNamespace 描述结果类型。

在此基础上,Calcite 提供了 SqlValidator 接口,该接口提供了所有与校验相关的核心逻辑,并提供了内置的默认实现类 SqlValidatorImpl 定义如下:

public class SqlValidatorImpl implements SqlValidatorWithHints {
	// ...
  
  final SqlValidatorCatalogReader catalogReader;
  
  /**
   * Maps {@link SqlNode query node} objects to the {@link SqlValidatorScope}
   * scope created from them.
   */
  protected final Map scopes =
      new IdentityHashMap<>();

  /**
   * Maps a {@link SqlSelect} node to the scope used by its WHERE and HAVING
   * clauses.
   */
  private final Map whereScopes =
      new IdentityHashMap<>();

  /**
   * Maps a {@link SqlSelect} node to the scope used by its GROUP BY clause.
   */
  private final Map groupByScopes =
      new IdentityHashMap<>();

  /**
   * Maps a {@link SqlSelect} node to the scope used by its SELECT and HAVING
   * clauses.
   */
  private final Map selectScopes =
      new IdentityHashMap<>();

  /**
   * Maps a {@link SqlSelect} node to the scope used by its ORDER BY clause.
   */
  private final Map orderScopes =
      new IdentityHashMap<>();

  /**
   * Maps a {@link SqlSelect} node that is the argument to a CURSOR
   * constructor to the scope of the result of that select node
   */
  private final Map cursorScopes =
      new IdentityHashMap<>();

  /**
   * The name-resolution scope of a LATERAL TABLE clause.
   */
  private TableScope tableScope = null;

  /**
   * Maps a {@link SqlNode node} to the
   * {@link SqlValidatorNamespace namespace} which describes what columns they
   * contain.
   */
  protected final Map namespaces =
      new IdentityHashMap<>();
  
  // ...
}

 可以看到 SqlValidatorImpl 当中有许多 scopes 映射 (SqlNode -> SqlValidatorScope) 和 namespaces (SqlNode -> SqlValidatorNamespace),校验其实就是在一个个 SqlValidatorScope 中校验 SqlValidatorNamespace 的过程,另外 SqlValidatorImpl 有一个成员 catalogReader,也就是上面说到的 SqlValidatorCatalogReader,为 SqlValidatorImpl 提供了访问元数据的入口。(注:为了简便,后续本文均以 scope 指代 SqlValidatorScope,使用 namespace 指代 SqlValidatorNamespace

5.1 入口函数

在第 3 章对 SqlNode 的介绍中,我们认识到 SqlNode 是一个嵌套的树结构,因此很自然的我们可以想到用处理树数据结构的一些思路或算法来处理整个 SqlNode 树,Calcite 正是基于这个思路,通过递归的方式遍历到每一个 SqlNode,并对每一个 SqlNode 都校验元数据。

校验流程极为繁琐,为了能够聚焦最核心的逻辑,我们下面代码块中的 SQL 为例该 SQL 语法简单但同样是一个完整的 ETL 流程,且该 SQL 覆盖了 INSERT 和 SELECT 这两个最核心常用的 DML 语句

INSERT INTO sink_table SELECT id FROM source_table WHERE id > -1

SQL 校验的整体入口是 SqlValidatorImpl 的 validate(SqlNode topNode) 方法。


public SqlNode validate(SqlNode topNode) {
  SqlValidatorScope scope = new EmptyScope(this);
  scope = new CatalogScope(scope, ImmutableList.of("CATALOG"));
  final SqlNode topNode2 = validateScopedExpression(topNode, scope);
  final RelDataType type = getValidatedNodeType(topNode2);
  Util.discard(type);
  return topNode2;
}

首先,SqlValidatorImpl 会创建 CatalogScope 作为最外层的工作上下文,用于后续校验,这个 scope 也是后面一些 namespace 的 parentScope,创建 scope 完毕后,Calcite 校验进入到 validateScopedExpression。(EmptyScope 的存在是为了更方便处理判空问题,并提供了一些核心的解析逻辑,它就类似一个 root scope


private SqlNode validateScopedExpression(
    SqlNode topNode,
    SqlValidatorScope scope) {
	// 1. 规范 SqlNode
  SqlNode outermostNode = performUnconditionalRewrites(topNode, false);

	// ...
	// 2. 注册 namespace 和 scope 信息
  if (outermostNode.isA(SqlKind.TOP_LEVEL)) {
    registerQuery(scope, null, outermostNode, outermostNode, null, false);
  }

	// 3. 进行校验
  outermostNode.validate(this, scope);
  
	// ...
  return outermostNode;
}

第一步的 performUnconditionalRewrites 是为了对我们的 SqlNode 进行规范,以简化后续校验处理,规范的内容包括不限于以下几点:

  • 如果一个 SELECT 子句带有 ORDER BY 关键字,SQL Parser 会将整个 SELECT 子句解析为 SqlOrderBy,在这一步会将 SqlOrderBy 转成 SqlSelect;

  • 给 SqlDelete 和 SqlUpdate 设置 sourceSelect(这是一个 SqlSelect),后续对这两类进行校验的时候就会对它们的 sourceSelect 进行校验(即 validateSelect);

  • ……

第二步的 registerQuery,会创建该 SqlNode 对应的 namespace 和 scope,以及将 namespace 注入到对应的 scope 中,以本例 SQL 进行调试可得到信息如下:

calcite_第7张图片

  1. 第一部分是 scopes 映射,这里包含了 SQL 文本每部分对应的名称解析空间,一般以 SELECT 为一个完整的空间,像 where、groupBy 等子句的 scope 也是其所属的 SELECT 子句的 SelectScope;

  2. 第二部分是关于 namespace 注入到 scope,SelectScope 继承了 ListScope,有一个成员变量 children,这里面会存 SELECT 的数据源对应的 namespace,如该例存储的是 source_table 对应的 IdentifierNamespace;

  3. 最后一部分是 namespaces 映射,INSERT、SELECT 或某个具体表和视图,都会有相对应的 namespace,用来表示他们执行结果的数据关系。

第三步校验调用 SqlNode 的 validate 方法,以前面的例子会走 SqlInsert 的 validate,然后调用 SqlValidatorImpl 的 validateInsert。


public void validateInsert(SqlInsert insert) {
// 1. 校验 namespace
  final SqlValidatorNamespace targetNamespace = getNamespace(insert);
  validateNamespace(targetNamespace, unknownType);

// ...
// 计算/校验 insert 插入列,insert 语句插入有两种形式
// `insert sink_table values(...):不指定插入列,默认为全部列
// `insert sink_table(idx) :指定插入列
final RelDataType targetRowType = createTargetRowType(table, insert.getTargetColumnList(), false);

// 2. 校验 source

  final SqlNode source = insert.getSource();
  if (source instanceof SqlSelect) {
    final SqlSelect sqlSelect = (SqlSelect) source;
    validateSelect(sqlSelect, targetRowType);
  } else {
    final SqlValidatorScope scope = scopes.get(source);
    validateQuery(source, scope, targetRowType);
  }

// ...
// 3. 校验 source 和 sink 是否兼容
  checkFieldCount(insert.getTargetTable(), table, source,
      logicalSourceRowType, logicalTargetRowType);

  checkTypeAssignment(logicalSourceRowType, logicalTargetRowType, insert);

  checkConstraint(table, source, logicalTargetRowType);

  validateAccess(insert.getTargetTable(), table, SqlAccessEnum.INSERT);
}

validateInsert 的核心逻辑有三块:

  1. 校验 namespace;

  2. 校验 SqlInsert 的 source,在该例里 source 是一个 SqlSelect,因此会走到 validateSelect;

  3. 校验 source(数据来源) 和 target(目标表)是否兼容。

5.2 校验 namespace

Calcite 在校验 namespace 的元数据时采用了模板方法设计模式,在 AbstractNamespace 的 validate 方法中定义了主校验流程,真正的校验逻辑(validateImpl)则交给每个具体的 namespace 各自实现。

AbstarctNamespace 中有一个成员变量 rowType,校验 namespace 其实就是解析得到 rowType 的值赋给对应的 namespace。

calcite_第8张图片

5.2.1 SqlValidatorImpl.validateNamespace

校验 namespace 的入口是 validateNamespace,做的是如下代码显示,即校验 namespace ,并建立 SqlNode → RelDataType 的映射关系放到 nodeToTypeMap 中。。


protected void validateNamespace(final SqlValidatorNamespace namespace,
      RelDataType targetRowType) {
// 1. 模板方法校验 namespace
  namespace.validate(targetRowType);
  if (namespace.getNode() != null) {
// 2. 建立 SqlNode -> RelDataType 的映射关系
    setValidatedNodeType(namespace.getNode(), namespace.getType());
  }
}

5.2.2 AbstractNamespace.validate

namespace 的校验主流程由 validate 方法定义


public final void validate(RelDataType targetRowType) {
  switch (status) {
	// 1. 第一次进入该方法时,status 都是 UNVALIDATED
  case UNVALIDATED:
    try {
			// 2. 标记 status 为正在处理,避免重复处理
      status = SqlValidatorImpl.Status.IN_PROGRESS;
      Preconditions.checkArgument(rowType == null,
          "Namespace.rowType must be null before validate has been called");
			// 3. 调用各自实现的 validateImpl
      RelDataType type = validateImpl(targetRowType);
      Preconditions.checkArgument(type != null,
          "validateImpl() returned null");
			// 4. 记录解析得到的结果类型
      setType(type);
    } finally {
			// 5. 标记 status 已完成
      status = SqlValidatorImpl.Status.VALID;
    }
    break;
  case IN_PROGRESS:
    throw new AssertionError("Cycle detected during type-checking");
  case VALID:
    break;
  default:
    throw Util.unexpected(status);
  }
}

除去 status 的标记更新,validate 核心逻辑分为两步:

  1. 调用各自 namespace 实现的 validateImpl 方法获取对应的 type(RelDataType);

  2. 将解析得到的 type 赋值给 namespace 的 rowType。

以上述例子为例,SqlInsert 会首先开始校验其对应的 InsertNamespace,InsertNamespace 没有实现自己的 validateImpl 方法,但它继承了 IdentifierNamespace,会直接调用 IdentifierNamespace 的 validateImpl。因此 InsertNamespace 的校验即是解析 target table(这是一个 Identifier) 的 rowType。

5.2.3 validateImpl(IdentifierNamespace)

IdentifierNamespace 有个成员 resolvedNamespace(也是一个 SqlValidatorNamespace),该 IdentifierNamespace 对应的 SqlNode 指向一个表时,resolvedNamespace 就是一个 TableNamespace,存有真正的类型信息。


public RelDataType validateImpl(RelDataType targetRowType) {
  // 1. 解析该 identifier 对应的 namespace,通常为 TableNamespace
	resolvedNamespace = Objects.requireNonNull(resolveImpl(id));

	// ...
	// 2. 获取 rowType,第一次执行时需要计算
	RelDataType rowType = resolvedNamespace.getRowType();

	// ...
	return rowType;
}

IdentifierNamespace 会先调用 resolveImpl,拿到对应的 TableNamespace,再调用 resolvedNamespace.getRowType() 得到 rowType。

5.2.3.1 IdentifierNamespace.resolveImpl

在 resolveImpl 中,IdentifierNamespace 会创建一个 SqlValidatorScope.ResolvedImpl 用于存放解析得到的 TableNamespace。


private SqlValidatorNamespace resolveImpl(SqlIdentifier id) {
  // ...
  parentScope.resolveTable(names, nameMatcher,
      SqlValidatorScope.Path.EMPTY, resolved);

  // ...
  return resolve.namespace;
}

这里的 parentScope 其实就是最开始创建的 CatalogScope,几乎是一个空实现,经过层层调用最后会调到 EmptyScope 的 resolve_ 方法。

5.2.3.2 EmptyScope.resolve_

在这里会调用 parentScope 中的 CalciteSchema(parentScope 中存有 SqlValidatorImpl,SqlValidatorImpl 中有 CalciteCatalogReader,CalciteCatalogReader 中有 CalciteSchema)的 getTable 拿到 TableEntryImpl 对象(定义如下),


public static class TableEntryImpl extends TableEntry {
  private final Table table;

  // ...
  public Table getTable() {
    return table;
  }
}

整个调用链如图所示

calcite_第9张图片

拿到 TableEntryImpl 对象,通过 TableEntryImpl.getTable() 就可以拿到我们注册进去的 Table 数据。


public static class TableEntryImpl extends TableEntry {
  private final Table table;

  // ...

  public Table getTable() {
    return table;
  }
}

在拿到 Table 后,会尝试将其转为一个 SqlValidatorTable(实际类型为 RelOptTableImpl),并注册到 TableNamespace 中。

最重要的是第3步,这里走了 Table 接口的 getRowType() 方法获取 rowType,如果我们有自定义的表实现了 Table 接口,也可以通过重写 getRowType() 自定义返回的行类型。拿到 rowType 后,将 rowType 赋给 table2,并基于 table2 创建了 TableNamespace。

直到此时 resolveImpl 执行完毕,我们得拿到了 TableNamespace,它保存有一个 table 变量,和继承自 AbstractNamespace 的类型为 RelDataType 的 rowType。rowType 记录这个 namespace 对应的行数据类型,而此时 TableNamespace 刚创建,rowType 为 null。

需要注意的是,此时 rowType 仅赋值给了 TableNamespace 中的 table,TableNamespace 保存的 rowType 仍是 null(这可能是一个优化点,可以减少后续额外对 TableNamespace 做校验的步骤,但也可能是考虑到 TableNamespace 刚创建,status 仍为 UNVALIDATED)

5.3 计算得到 rowType

因此当 IdentifierNamesapce 的 validateNamespace 函数进行到第二步:RelDataType rowType = resolvedNamespace.getRowType();,由于 rowType 为空,又会触发 TableNamespace 的校验。


public RelDataType getRowType() {
  if (rowType == null) {
    validator.validateNamespace(this, validator.unknownType);
    Preconditions.checkArgument(rowType != null, "validate must set rowType");
  }
  return rowType;
}

校验 TableNamespace 结束后回到 AbstractNamespace.validate (此时该 AbstractNamespace 的真实类型为 IdentifierNamespace),我们拿到了 IdentifierNamespace 对应的行类型信息(即 resolvedNamespace 的 rowType),通过 setType 注入到 IdentifierNamespace 中,此时就完成了 IdentifierNamespace 的校验。

5.3.1 validateImpl(TableNamespace)

TableNamespace 的 validateImpl 实现如下:


protected RelDataType validateImpl(RelDataType targetRowType) {
  if (extendedFields.isEmpty()) {
    return table.getRowType();
  }
  final RelDataTypeFactory.Builder builder =
      validator.getTypeFactory().builder();
  builder.addAll(table.getRowType().getFieldList());
  builder.addAll(extendedFields);
  return builder.build();
}

前文提到,经过 EmptyScope.resolve_ 方法,TableNamespace 中绑定的 table 已经注入了 rowType 信息,因此这里可以直接获取到并返回。

回到 AbstractNamespace.validate (此时该 AbstractNamespace 的真实类型为 TableNamespace),我们拿到了 TableNamespace 对应的行类型信息(即它的 TableNamespace 的 rowType),通过 setType 注入到 TableNamespace 中,完成TableNamespace 的校验。

5.4 总结

下图是 IdentifierNamespace 校验的完整流程。

 calcite_第10张图片

5.5 校验 source

校验 source 就是一个校验 select 的过程,延续上面的例子,校验的 SqlNode(实际类型为SqlSelect)对应的语句为 select id as idx from source_table where id > -1,SqlSelect 有自己绑定的 SelectNamespace,当然也有校验 namespace 的部分(给绑定的 SelectNamespace 注入 rowType 属性)。

校验 select 的入口函数为 validateSelect,下图是整个 validateSelect 的工作流程。

calcite_第11张图片

可以看到 validateSelect 对 select 语句的每个组成部分都做了 validate,当中最重要的是 validateSelectList,这个方法校验 select 的列是否存在(能从数据源表中检索到),以及为这部分查出来的列建立对应的类型信息(RelDataType),也即 SelectNamespace 的 rowType

举个例子,针对 SELECT id FROM source_table WHERE id > -1,validateSelectList 则需要校验 id 这个列是否在 source_table 中存在。

5.6 校验 source 和 target 是否兼容

这部分校验具体检查四块内容:

  1. checkFieldCount:检查插入数据列的个数是否合法,检查非空;

  2. checkTypeAssignment:检查来源列和插入表对应列的类型是否兼容;

  3. checkConstraint:约束检查;

  4. validateAccess:权限校验,默认情况下都能通过。

6. Query optimizer

Query Optimizer 是最为庞杂的一个组件,涉及到的概念多,首先,Query Optimizer 需要将 SqlNode 转成 RelNode(SqlToRelConverter),并使用一系列关系代数的优化规则(RelOptRule)对其进行优化,最后将其转化成对应引擎可执行的物理计划。

SqlNode 是从 sql 语法角度解析出来的一个个节点,而 RelNode 则是一个关系表达式的抽象结构,从关系代数这一角度去表示其逻辑结构,并用于之后的优化过程中决定如何执行查询。当 SqlNode 第一次被转化成 RelNode 时,由一系列逻辑节点(LogicalProject、LogicalJoin 等)组成,后续优化器会将这些逻辑节点转化成物理节点,根据不同的计算存储引擎有不同的实现,如 JdbcJoin、SparkJoin 等。下表是一个关于 SQL 、关系代数以及 Calcite 结构的映射关系:

SQL

关系代数

Calcite

select

投影(Project)

LogicalProject + RexInputRef + RexLiteral + ...

where

选择(Select)

LogicFilter + RexCall

union

并(Union)

LogicalUnion

无 on 的 inner join

笛卡尔积(Cartesian-product)

LogicJoin

有 on 的 inner join

自然连接(Natural join)

LogicJoin + RexCall

as

重命名(rename)

RexInputRef

...

注:Calcite 列只列举了较常见的情况,而非和前面两列严格的映射标准。

一个简单的 SQL 例子经过 query optimizer 处理后得到的结果如下:

calcite_第12张图片

6.2 RelNode 优化

类/接口

备注

RelOptNode

代表的是能被 planner 操作的 expression node

RelNode

Relational algebra,RelNode,是一个关系表达式的抽象结构,继承了 RelOptNode 接口,SqlNode 是从 sql 语法角度解析出来的一个个节点,而 RelNode 则是从关系代数这一角度去表示其逻辑结构,并用于之后的优化过程中决定如何执行查询。当 SqlNode 第一次被转化成 RelNode 时,由一系列逻辑节点(LogicalProject、LogicalJoin 等)组成,后续优化器会将这些逻辑节点转化成物理节点,根据不同的计算存储引擎有不同的实现,如 JdbcJoin、SparkJoin 等。

RexNode

Row expressions,代表一个行表达式,表示在单行上需要执行的操作,通常包含在 RelNode 中。比如 Project 类有个成员 List exps,代表投影的字段(从 SQL 上来说即查询的字段),Filter 类有个成员 RexNode condition,代表具体的过滤条件,类似还可以充当 Join condition,Sort fields 等

RelTrait

Trait,表示 RelNode 的部分物理特征,该部分特征不会改变执行结果。三种主要的物理特征为

Convention:单类数据源的调用约定,每个关系表达式必须在同一类数据源上运行;

RelCollation:该关系表达式定义的数据排序;

RelDistribution:数据分布特点。

Convention

是一种 Trait,代表单类数据源。

Converter

一个 RelNode 通过实现该接口来表明其可以将某个 Trait 的值转变成另一个。

RelOptRule

Rules,用于优化查询计划,rules 可以分为两类,

converters:继承 ConverterRule,在不改变语义的基础上将某种 Convention 转成另一种; transformers:匹配查询计划并进行优化。

RelOptPlanner

有两种 planner, HepPlanner:启发式优化器,对 RelNode 每个节点与注册好的 rule 遍历进行匹配,如果成功匹配到 rule 则进行优化; VolcanoPlanner:基于成本的优化器,每次迭代选择代价最小的方案进行优化。

RelOptCluster

planner 运行时的上下文环境

在将 SqlNode 转为 RelNode 后,我们就可以通过关系代数的一些规则对 RelNode 进行优化,这些“规则”在 Calcite 表现为 RelOptRule。

RelNode 有一些物理特征,这部分特征就由 RelTrait 来表示,其中最重要的一个是 Convention(Calling Convention),可以理解是一个特定数据引擎协议或数据源约定,同数据引擎的 RelNode 可以直接相互连接,而非同引擎的则需要通过 Converter 进行转换(通过 ConverterRule 匹配)。

比较常见的优化规则如:

  • 剪除不用的 fields;

  • 合并 projections;

  • 将子查询转为 join;

  • 对 joins 重排序;

  • 下推 projections;

  • 下推 filters;

6.3 优化器(HepPlanner)

Calcite 的优化器主要有两个:HepPlanner 和 VolcanoPlanner。HepPlanner 是一个启发式的优化器,基于非常简单的思想:即遍历匹配 RelOptRule 和 RelNode ,匹配成功则进行优化。VolcanoPlanner 则是一个基于成本计算的优化器,相对更加复杂,限于篇幅,我们只介绍 HepPlanner。HepPlanner 的优化流程大体可以分为两步:

  1. 初始化 HepPlanner;

  2. 调用 HepPlanner 的 findBestExp() 执行真正的优化流程,而优化流程也是一个非常复杂的逻辑,可以细分为以下三步:

    1. 遍历:启动一个双层的 for 循环去遍历 RelOptRule 和 RelNode ;

    2. 匹配:在遍历过程中对 RelOptRule 和 RelNode 尝试进行匹配;

    3. 优化:当匹配成功后执行 RelOptRule 的 onMatch 进行优化。

6.3.1 初始化

初始化 HepPlanner 有两步非常重要:注入优化规则(RelOptRule)以及注入需优化的 RelNode root

注入优化规则(RelOptRule):HepPlanner 会将注入的 RelOptRule 包装成 HepInstruction,具体的类型为 HepInstruction.RuleInstance,在 HepPlanner 中,HepInstruction 才是执行单位。使用 HepInstruction 而不直接使用 RelOptRule 的原因在于,Instruction 还可能是其他东西,比如一个 rule 集合,可能是一些注入属性的方法。

注入需优化的 RelNode root:RelNode 是关系代数表达式的一种抽象结构,因此与关系表达式相似是一种类似树的结构(组合模式的应用),HepPlanner 会根据这个特点将其转换成一个有向无环图 DirectedGraph,图上的每个节点都是一个 RelNode,但这个 RelNode 不是原来的 RelNode,而是 RelNode 的另一个实现类 HepRelVertex,HepRelVertex 有一个成员变量 currentRel 指向它包装的 RelNode

6.3.2 遍历

HepPlanner 通过 findBestExp 进入到优化流程,其关键步骤有以下几步。

首先,HepPlanner 会进入第一层 for 循环,遍历所有 instructions(前面提到 RelOptRule 也被包装成 HepInstruction 存在 instructions 中),对每个 RelOptRule 尝试匹配所有的 RelNode(即 HepRelVertex)。

在 HepPlanner 中,RelNode 已经被转成一个有向无环图 DirectedGraph,因此 HepPlanner 会先根据 DirectedGraph 生成一个 RelNode 集合的迭代器,


private Iterator getGraphIterator(HepRelVertex start) {
    collectGarbage();
    switch (currentProgram.matchOrder) {
    case ARBITRARY:
    case DEPTH_FIRST:
      return DepthFirstIterator.of(graph, start).iterator();
    case TOP_DOWN:
      assert start == root;
      return TopologicalOrderIterator.of(graph).iterator();
    case BOTTOM_UP:
    default:
      assert start == root;
      final List list = new ArrayList<>();
      for (HepRelVertex vertex : TopologicalOrderIterator.of(graph)) {
        list.add(vertex);
      }
      Collections.reverse(list);
      return list.iterator();
    }
}

可以看到,迭代器的生成方式由一个类型为 HepMatchOrder 的参数 matchOrder 指定,该参数指定了四种生成规则:

  • ARBITRARY

  • DEPTH_FIRST

  • TOP_DOWN

  • BOTTOM_UP(default)

ARBITRARY 和 DEPTH_FIRST 使用的都是深度优先算法生成规则集合,而 TOP_DOWN 和 BOTTOM_UP 则是基于拓扑排序,后者由于是自底向上,所以会有一个 reverse list 的过程

得到 RelNode 集合后,HepPlanner 开始进入第二层 for 循环,尝试匹配 RelOptRule 和 RelNode,

注:在优化过程每次修改了原先的 graph,需要重新调用 getGraphIterator 方法生成迭代器。如果 matchOrder 是 ARBITRARY || DEPTH_FIRST,则直接从优化新生成的 RelNode 开始遍历建立迭代器,否则则继续从 root 开始重新生成。

6.3.3 匹配

HepPlanner 在 applyRule 中实现了关于规则匹配和优化的逻辑。


private HepRelVertex applyRule(
    RelOptRule rule,
    HepRelVertex vertex,
    boolean forceConversions) {
	// 一些特殊情况处理,如 ConverterRule 的处理逻辑

  // 1. 进行 operand 匹配
  boolean match =
        matchOperands(
            rule.getOperand(),
            vertex.getCurrentRel(),
            bindings,
            nodeChildren);

  if (!match) {
    return null;
  }

	HepRuleCall call =
        new HepRuleCall(
            this,
            rule.getOperand(),
            bindings.toArray(new RelNode[0]),
            nodeChildren,
            parents);

  // 2. 进行 rule 匹配
  if (!rule.matches(call)) {
    return null;
  }

  // 3. 优化
  fireRule(call);

  // 4. 使用优化后新的 RelNode 替换原先老的 RelNode
  if (!call.getResults().isEmpty()) {
    return applyTransformationResults(
      vertex,
      call,
      parentTrait);
  }

  return null;
}

具体关于规则和关系表达式的匹配有两步:

  1. 对 RelOptRule 里的 RelOptRuleOperand 尝试进行匹配;

  2. 执行 RelOptRule 的 matches 方法。

6.3.3.1 RelOptRuleOperand

RelOptRuleOperand 则是用于判断某个 RelOptRule 能否与某个特定的关系表达式(RelNode)匹配成功。RelOptOperand 的结构如下:


public class RelOptRuleOperand {
  //~ Instance fields --------------------------------------------------------

  private RelOptRuleOperand parent;
  private final ImmutableList children;
  public final RelOptRuleOperandChildPolicy childPolicy;

  private RelOptRule rule;
  private final Predicate predicate;
  private final Class clazz;
  private final RelTrait trait;
  
  // ...
}

很明显这是 RelOptRuleOperand 也是一个树结构,根据不同的 Rule 它可能有子 operand 和 父 operand,需要递归进行匹配,childPolicy 决定了对子 operand 的匹配逻辑,在上面 HepPlanner 的 matchOprands 方法中就会针对 childPolicy 不同的值采取不同的匹配校验方式。

operand 执行匹配的具体方法是 matches,默认一共有三步校验:


public void onMatch(RelOptRuleCall call) {
  Filter filter = call.rel(0);
  Join join = call.rel(1);
  perform(call, filter, join);
}

首先是 clazz ,RelNode 必须是 clazz 或其子类生成的对象。

其次是 trait,该 RelNode 的特征中必须包含该 RelOptRuleOperand 的特征。

最后是 predicate,每个 RelOptRuleOperand 都有一个断言,RelNode 需通过该断言的校验。

6.3.3.2 RelOptRule

RelOptRule 是优化规则,通过其 onMatch 方法我们能将一个关系表达式(RelNode)优化成另一个关系表达式。

RelOptRule 中有一个 RelOptRuleOperand 类型的成员 operand,保存了整个 RelOptRuleOperand 树的 root,在 matchOperands 方法中可以看出,RelOptRule 能否与某个特定的 RelNode 匹配成功一部分由 RelOptRuleOperand 决定。当通过 RelOptRuleOperand 校验后,HepPlanner 会调用 RelOptRule 的 matches 方法进行第二次校验,matches 方法的默认实现是返回 true,但用户可以在实现自定义优化规则时添加自定义判断条件

6.3.4 优化

优化流程也可以再细分为三步:

  1. 应用 RelOptRule 的 onMatch 方法;

  2. 调用 applyTransformationResults 将优化后的 RelNode 添加到图中,替换原先的 RelNode;

  3. 使用 Mark-Sweep 算法清除过时的 RelNode 及相关数据。

6.3.4.1 onMatch

为了方便说明,在该节我们选取了 SQL 文本如下:

SELECT * FROM product p JOIN order o ON p.id=o.id WHERE p.id > 1 

这是一个明显的 Filter 和 Join 相结合的 SQL,我们可以通过谓词下推技术优化 Filter 和 Join 语句的顺序(优化前后对比图如下),针对该场景 Calcite 也内置了相对应的规则:FilterIntoJoinRule。

calcite_第13张图片

FilterIntoJoinRule 的 onMatch 方法逻辑非常清晰,取出了 Filter 和 Join,调用了 perform 方法尝试做谓词下推。


public void onMatch(RelOptRuleCall call) {
  Filter filter = call.rel(0);
  Join join = call.rel(1);
  perform(call, filter, join);
}

perform 的逻辑较为复杂,这里只讲述关键流程:

  1. 计算出 Filters,分为 4 部分:

    • aboveFilters:join 之上的 filters;

    • joinFilters:join 的 on filters;

    • leftFilters:join 左表的 filters;

    • rightFilters:join 右表的 filters;

  2. 调用 RelOptUtil.classifyFilters 尝试将 aboveFilters 下推到 leftFilters 或 rightFilters 中(也可能两者兼有),在这一步会通过创建两个 bitmap,以计算偏移量的方式来判定应该将 aboveFilters 下推到哪里;

  3. 应用跟 2 相似的逻辑尝试将 aboveFilters 下推到 joinFilters;

  4. 构建新的 RelNode,从 leftFilters 和 rightFilters 向上构建,再新建一个 Join RelNode;

  5. 将新建的 RelNode 放入 HepRuleCall,留待后续处理。

以上面的例子为例,尝试用 FilterIntoJoinRule 对该关系表达式进行优化,在遍历到 LogicFilter 时,满足匹配条件并触发了优化,得到了右图优化后的 RelNode 树,黄色部分表示优化过程中复用的 RelNode,而红色则是在优化过程中新生成的 RelNode。

calcite_第14张图片

6.3.4.2 applyTransformationResults

继续上述的例子,新生成的 RelNode root 为 LogicJoin,applyTransformationResults 所做的事即是将 LogicJoin 写进原先的 RelNode 树中(即替换掉 LogicFilter),并添加到 HepPlanner 的 graph 中(方便后续对 RelNode 进一步优化)。

这部分较清晰简单,是典型的关于树/图数据结构的处理。

  1. 首先建立 LogicProject → LogicJoin 的连接,这需要修改 LogicProject 的 input 指向 LogicJoin,并添加 LogicProject → LogicJoin 的边。

  2. 删除 LogicProject → LogicFilter 的边。

calcite_第15张图片

6.3.4.3 collectGarbage

在 applyTransformationResults 中,我们已经建立了新的 RelNode 树,但优化前的一些 RelNode 虽然和 RelNode 树断开了连接,但仍存在于 HepPlanner 的 graph 中,这一步所做的就是清理这些无用的 RelNode。


private void collectGarbage() {
  // ...

// mark
  final Set rootSet = new HashSet<>();
  if (graph.vertexSet().contains(root)) {
    BreadthFirstIterator.reachable(rootSet, graph, root);
  }

// ...
  final Set sweepSet = new HashSet<>();
  for (HepRelVertex vertex : graph.vertexSet()) {
    if (!rootSet.contains(vertex)) {
      sweepSet.add(vertex);
      RelNode rel = vertex.getCurrentRel();
      notifyDiscard(rel);
    }
  }

  assert !sweepSet.isEmpty();
// sweep
  graph.removeAllVertices(sweepSet);

  // ...
}

 7. 参考资料

  1. Apache Calcite: A Foundational Framework for Optimized Query Processing Over Heterogeneous Data Sources: https://arxiv.org/abs/1802.10233

  2. Calcite 概念说明:Calcite 若干概念说明 - 知乎

  3. Apache Calcite 处理流程详解:Apache Calcite 处理流程详解(一) | Matt's Blog

    Apache Calcite中的基本概念 - 知乎

  4. RelNode 和 RexNode:http://mail-archives.apache.org/mod_mbox/calcite-dev/201608.mbox/

  5. Introduction to Apache Calcite:https://www.slideshare.net/JordanHalterman/introduction-to-apache-calcite?qid=fec98de5-2f12-400e-99eb-65ea101947a0&v=&b=&from_search=4

你可能感兴趣的:(java,开发语言)