使用 Apache Calcite 的框架非常多,包括 Hive,Flink,Kylin,Elasticsearch。为的就是使用 SQL 来查询数据或者设计流程,提升框架的易用性。Calcite 有下面几个特点。
- 通过 SQL 的方式统一数据查询
- 支持不同数据源的在同一个 SQL 执行
- 对执行计划进行优化,提升执行性能
我们一步步结合 Calcite 的概念分析它的实现方式。
首先 SQL 是对二维表的查询,所以底层数据无论如何存储结构如何,也必须要先抽象成二维表。抽象成二维表是不同数据源要做的事情,Calcite 提供几种适配器编写方式,新数据源只要按照这些适配器的方式编写即可。这部分放到第二部分来讲。我们先用最简单的 List<List<Object>> 来表示二维表数据,用 RowType = List<Pair<String,Type>> 来表示列的定义,Pair 里面 String 是列名,Type 是列类型。
Calcite 从 SQL 到最终的数据提取方法经历了下面几个阶段,我们先讲前后两部分,再讲中间的。
SQL -> SqlNode -> RelNode -> RelNode -> Enumerator
语法树构建 ( SQL -> SqlNode )
要执行 SQL 首先要解析 SQL,分析 SQL 的语法和语义。在 Calcite 里面使用 JavaCC 来解析 SQL。JavaCC 是一种基于 Java 的词法分析器和语法分析器生成器,常用于构建解析器和编译器。使用 JavaCC 可以校验 SQL,并把 SQL 变成一个语法树。例如一个简单的 select 语句会变成下面结构化的数据。
js
SELECT col_a1, col_b1 FROM testa LEFT JOIN testb ON col_a2 = col_b2 WHERE col_a3 = 10

这里面解析出来的节点的共同父类是 SqlNode,子类非常多。

这样就把一个 SQL 转换成 SqlNode。
迭代器构建 (Enumerator)
这部分考虑的是如何通过构造程序来筛选出数据。SQL 本质上是对数据处理过程的声明,可以通过组合迭代器的方式来实现数据处理。
C# 里面有一个框架叫 LINQ,可以用通过类似 SQL 进行查询。像 Mybatis-Plus 里面的流式查询,Flink 里面的 Table Api。Mybatis-Plus 里面的查询是通过代码的方式生成 SQL。而 Calcite 则相反,通过解析 SQL 得到最终要执行的方法。Calcite 底层用的是 java 的 LINQ,称为 linq4j。所以要搞清楚 Calcite 是怎么计算的,就要搞清楚 linq4j 的逻辑是怎样的。

linq4j 的设计特点是使用装饰者模式,对上一个节点的值再进行封装。例如上面的 programers.where().select() 会变成 select(where(programers)),而不管怎么包装,返回的 Enumerable。Enumerable 继承 Iterable,所以可以看成是一个数组。

Enumerable 还继承了 ExtendedEnumberable,这个是实现装饰模式的关键类。实现了诸如 where,groupBy,join,select 等等方法。因此可以实现 Enumerable.where().select()。例如下面的 SQL 对应 Enumerable 的函数调用。
- select x from y where z
- y.where(z).select(x)

下面是 Enumerable 一些常见的方法
java
<TKey, TElement> Enumerable<Grouping<TKey, TElement>> groupBy(
Function1<TSource, TKey> keySelector,
Function1<TSource, TElement> elementSelector, EqualityComparer<TKey> comparer);
<TInner, TKey, TResult> Enumerable<TResult> hashJoin(Enumerable<TInner> inner,
Function1<TSource, TKey> outerKeySelector,
Function1<TInner, TKey> innerKeySelector,
Function2<TSource, TInner, TResult> resultSelector,
EqualityComparer<TKey> comparer);
<TInner, TKey, TResult> Enumerable<TResult> asofJoin(
Enumerable<TInner> inner,
Function1<TSource, TKey> outerKeySelector,
Function1<TInner, TKey> innerKeySelector,
Function2<TSource, @Nullable TInner, TResult> resultSelector,
Predicate2<TSource, TInner> matchComparator,
Comparator<TInner> timestampComparator,
boolean generateNullsOnRight);
<TResult> Enumerable<TResult> select(
Function2<TSource, Integer, TResult> selector);
Enumerable<TSource> union(Enumerable<TSource> source1);
Enumerable<TSource> where(Predicate2<TSource, Integer> predicate);
Enumerable<TSource> distinct();
下面通过 where 方法分析一下 Enumerable 的实现方式

where 逻辑是创建一个新的 Enumerable,而 Enumerable 的核心方法是创建一个枚举类 Enumerator。Enumerator 是由 EnumerableDefaults.where 来实现。Enumerable 是负责把 Enumerator 组合起来,EnumerableDefaults.where 只是 where 的一种实现方式,如果有用到更复杂的数据结构可以自己实现一个 where 功能的 enumerator。例如数据源本来就提供了筛选数据的功能,那可以直接基于数据源的筛选功能编写一个 Enumerator。
Enumereator 有 current,moveNext,reset,close 这几个方法,就是一个典型的迭代器。这个迭代器会循环调用 predicate,直到满足出现一个满足输出条件的结果。因此如果从使用方的角度,不关心被过滤了多少个值,除非数据源没数据了,否则必须有一个结果返回。

经过装饰器的包装,最后形成嵌套式的 Enumerator,当 debug 到源数据获取值的时候,堆栈经过了 where 和 select 的 Enumerator。

看上去只要从 SqlNode 转换成 Enumerator 就可以实现一个 SQL 的查询了,但 Calcite 的作用远不是执行一下 SQL 那么简单,它还提供了执行计划优化的功能。
从语法树到迭代器
从语法树到迭代器中间有很重要的中间产物,这就是 RelNode。从 SQL -> Enumerator,和RelNode 有关的有 3 个步骤。
- SqlNode -> RelNode
- RelNode -> RelNode
- RelNode -> Enumerator
SqlNode -> RelNode (SqlToRelConverter)
这个逻辑比较简单,例如有这么一个 SQL :
js
SELECT col_a1, col_b1 FROM testa LEFT JOIN testb ON col_a2 = col_b2 WHERE col_a3 = 10
从 SqlNode 转成 RelNode 如下图。

- LogicalProject 是数据投影算子,就是列选择,select 的实现
- LogicalFilter 是过滤数据算子,就是 where 的实现,RexCall 是 LogicalFilter 的一个参数,代表过滤条件
- TableScan 是对二维数据进行全局扫描
这个流程的 RelNode 都是 Logical 开头的。每个 RelNode 称为一个算子。最底层是原始二维表的全表扫描,再逐步往上进行处理,有点像火山一样。通过这种方式便将整个数据流转变为自顶向下执行、数据自底向上拉取。该计算模型称为火山模型。
SqlNode 和 RelNode 的节点并不是一一对应的。例如 select,where 在 SqlNode 里面是同级的,但在 RelNode 里面是包含关系,在上图里面就是先对数据进行过滤,再对数据进行数据投影 (就是列选择,select 的实现),一个嵌套的 RelNode 来处理 select 和 where。

LogicalRelNode 和 Enumerator 的结构已经非常相似了,一对一转成 Enumerator 就可以完整执行 SQL 了.
RelNode -> RelNode (VolcanoPlanner)
LogicalRelNode 只是最简单的物理执行计划,这样的执行效率很低,还可以进行更多的优化。
例如下面的 SQL ,执行计划是先进行 LogicalJoin,在进行 LogicalFilter (col_a3=10)。虽然执行上面没问题,但性能会差一些,如果能先在数据源进行 Filter 再 Join 就能减少数据处理量。特别是 testa 和 testb 是不同的数据源,且 testa 数据源本身就有对 filter 进行优化,那把 filter 下移到数据源去执行性能会更好。
js
SELECT col_a1,col_b1 FROM testa LEFT JOIN testb ON col_a2 = col_b2 WHERE col_a3 = 10

这种修改算子执行顺序,对最终结果不产生影响,称为关系代数转换。
上面只是其中一种规则,官方提供的基础规则可以在这里看到 org.apache.calcite.plan.RelOptUtil#registerDefaultRules,每一个规则就是一个 Rule 类。有部分规则是在执行的时候分析数据源类型,根据数据源类型去添加的。例如如果数据源是 mysql,那当然尽可能把和这个数据源相关的算子全部下推到数据源,然后在数据库一起执行,会比先从数据库查出来,再在内存里面进行处理要更好一些.
这里的规则不一定都是和查询性能相关的,还和具体的执行等有关。
性能相关的规则
下面是前面提到的 Filter 下推的规则

再列举一个,有个规则是去掉没用的 left join,"left join product as p " 并没有 select,所以去掉这个 join 对结果没影响。

执行相关的规则
LogicalRelNode 是不能真正转换成 Enumerator 的,它需要先转换成 BindableRel 的子类或者 EnumerableRel 的子类。这两种类型都是 RelNode,再由它来构造 Enumerator。下面 RelNode -> Enumerator 才对 EnumerableRel 做介绍。
深度搜索构造多个物理计划
RelNode 是一棵树,规则是对 RelNode 的处理。每一个规则都有自己的匹配规则。
例如过滤下推的优化,匹配逻辑就是 Join 类型的 RelNode,且不限制 Join 的输入类型。

又例如下面移除无用 Join 的规则是 Join 的 input 是 Project,这样就可以根据 Join 的查询字段和 left 或 right 的查询字段进行对比,如果没 select ,则可以去掉整个 Join

应用某个规则可能会对树的结构产生变化,这样可能又会导致原本不生效的规则生效了。
假如有3个规则
- B + C -> E
- E + D -> F
- C + D -> G

因此需要根据深度优先算法,对每一次使用规则之后的 RelNode 树,需要再遍历所有规则。这样虽然最终肯定能找到最优的执行计划,但优化的时间会变得比较长。为了提升性能,还可以采用剪枝的方式,当遍历的代价超过当前的最小代价,则不用再进行遍历。
根据代价选择最优的物理计划
怎么判断物理计划是好是坏,Calcite 对每一个 RelNode 都有 computeSelfCost 方法来给 RelNode 的设计者来指定代价。返回的对象是 RelOptCost,里面包含了 行数,cpu, IO,默认是只根据 行数 来判断。
行数一般只是一个大概值,大部分场景没法精确获取规则应用后的行数。规则的设计者可以根据规则的优化程度对子节点的行数乘上一个系数。最底层的 RelNode 就是 TableScan 了,所以我们在实现数据源适配器的时候最好对表的行数有一个估算值,后面的规则才能更好的利用行数去计算代码。
RelNode -> Enumerator (VolcanoPlanner)
BindableRel 和 EnumerableRel 两种实现方式的差异在于 EnumerableRel 是根据 Enumerator 的组合逻辑生成一个新的类,提升执行性能,避免了反射和嵌套。而 BinableRel 则不需要创建新类,通过把火山模型变成方法的嵌套调用实现,调用方式和前面提到的 Enumerator 的组合类似。BindableRel 和 EnumerableRel 还可以相互嵌套。
Calcite 默认使用 EnumerableRel,但控制的不好可能会导致创建过多的类反而降低性能。
例如下面的 SQL 会生成一个新类 Baz,Filter 和 Project 的逻辑都在一个 Enumerator 里面实现。
js
SELECT col_a1, col_a2 FROM testa WHERE col_a3 = 10
java
public org.apache.calcite.linq4j.Enumerable bind(final org.apache.calcite.DataContext root) {
final org.apache.calcite.linq4j.Enumerable _inputEnumerable = ((org.apache.calcite.adapter.csv.CsvTranslatableTable) root.getRootSchema().getSubSchema("hr").getTable("testa")).project(root, new int[] { 0,1,2 });
return new org.apache.calcite.linq4j.AbstractEnumerable() {
public org.apache.calcite.linq4j.Enumerator enumerator() {
return new org.apache.calcite.linq4j.Enumerator() {
public final org.apache.calcite.linq4j.Enumerator inputEnumerator = _inputEnumerable.enumerator();
public void reset() {
inputEnumerator.reset();
}
public boolean moveNext() {
while (inputEnumerator.moveNext()) {
final Object[] current = (Object[]) inputEnumerator.current();
final String input_value = current[2] == null ? null : current[2].toString();
// 10 作为一个常量来进行值匹配
if ((input_value == null ? 0 : org.apache.calcite.runtime.SqlFunctions.toInt(input_value)) == 10) {
return true;
}
}
return false;
}
public void close() {
inputEnumerator.close();
}
public Object current() {
// 直接进行数据投影,不再需要加一个 Project 的 Enumerator
final Object[] current = (Object[]) inputEnumerator.current();
final Object input_value = current[0];
final Object input_value0 = current[1];
return new Object[] {
input_value,
input_value0
};
}
};
}
};
}
总结
Calcite 从 SQL -> SqlNode -> RelNode -> RelNode -> Enumerator,一步步把 SQL 变成迭代器来抽取数据。RelNode -> RelNode 是 Calcite 的重点所在,因为提供了多样的规则使得多数据源的查询性能不再是一个无法解决的问题。但它的复杂程度也使得要真正写好一个数据源适配器变得很有难度。虽然写一个通用的数据源适配器很困难,但如果只是针对部分 SQL 模板去做优化则简单很多。
笔者在工作上写了基于 API 的数据源,因为这个数据源只是负责 left join,所以虽然数据源的数据量很大,不可能全部都加载到内存进行计算。但如果只是基于 left join 去写优化规则还是很简单的.
希望文章对有需要了解 Calcite 的同学有点帮助。