前言
笔者近日在做一个基于 Calcite 的自定义 SQL 解析框架,需要了解 Calcite,但由于现在网上Calcite 资料极少,有价值的更是寥寥无几,因此只能自己 debug 源码。Calcite 是一个比较复杂的框架,因此本篇内容集中在当中的一个小但重要的阶段:SQL 优化。
限于篇幅,本篇只谈 HepPlanner 的优化流程,有任何问题欢迎评论交流。
HepPlanner
HepPlanner 是一个启发式的优化器,基于非常简单的思想:即遍历匹配 RelOptRule 和 RelNode ,匹配成功则进行优化。HepPlanner 的优化流程大体可以分为两步:
- 初始化 HepPlanner;
调用 HepPlanner 的 findBestExp() 执行真正的优化流程,而优化流程也是一个非常复杂的逻辑,可以细分为以下三步:
- 遍历:启动一个双层的 for 循环去遍历 RelOptRule 和 RelNode ;
- 匹配:在遍历过程中对 RelOptRule 和 RelNode 尝试进行匹配;
- 优化:当匹配成功后执行 RelOptRule 的 onMatch 进行优化。
初始化
初始化 HepPlanner 有两步非常重要:注入优化规则(RelOptRule)以及注入需优化的 RelNode root。
注入优化规则(RelOptRule):HepPlanner 会将注入的 RelOptRule 包装成 HepInstruction,具体的类型为 HepInstruction.RuleInstance,在 HepPlanner 中,HepInstruction 才是执行单位。
注入需优化的 RelNode root:RelNode 是关系代数表达式的一种抽象结构,因此与关系表达式相似是一种类似树的结构(组合模式的应用),HepPlanner 会根据这个特点将其转换成一个有向无环图 DirectedGraph,图上的每个节点都是一个 RelNode,但这个 RelNode 不是原来的 RelNode,而是 RelNode 的另一个实现类 HepRelVertex,HepRelVertex 有一个成员变量 currentRel 指向它包装的 RelNode。
遍历
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 开始重新生成。
匹配
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;
}
具体关于规则和关系表达式的匹配有两步:
- 对 RelOptRule 里的 RelOptRuleOperand 尝试进行匹配;
- 执行 RelOptRule 的 matches 方法。
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;
// TODO trait
// ...
}
很明显这是 RelOptRuleOperand 也是一个树结构,根据不同的 Rule 它可能有子 operand 和 父 operand,需要递归进行匹配,childPolicy 决定了对子 operand 的匹配逻辑,在上面 HepPlanner 的 matchOprands 方法中就会针对 childPolicy 不同的值采取不同的匹配校验方式。
operand 执行匹配的具体方法是 matches,默认一共有三步校验:
public boolean matches(RelNode rel) {
if (!clazz.isInstance(rel)) {
return false;
}
if ((trait != null) && !rel.getTraitSet().contains(trait)) {
return false;
}
return predicate.test(rel);
}
首先是 clazz ,RelNode 必须是 clazz 或其子类生成的对象。
其次是 trait,该 RelNode 的特征中必须包含该 RelOptRuleOperand 的特征。
最后是 predicate,每个 RelOptRuleOperand 都有一个断言,RelNode 需通过该断言的校验。
RelOptRule
RelOptRule 是优化规则,通过其 onMatch 方法我们能将一个关系表达式(RelNode)优化成另一个关系表达式。
RelOptRule 中有一个 RelOptRuleOperand 类型的成员 operand,保存了整个 RelOptRuleOperand 树的 root,在 matchOperands 方法中可以看出,RelOptRule 能否与某个特定的 RelNode 匹配成功一部分由 RelOptRuleOperand 决定。当通过 RelOptRuleOperand 校验后,HepPlanner 会调用 RelOptRule 的 matches 方法进行第二次校验,matches 方法的默认实现是返回 true,但用户可以在实现自定义优化规则时添加自定义判断条件。
优化
优化流程也可以再细分为三步:
- 应用 RelOptRule 的 onMatch 方法;
- 调用 applyTransformationResults 将优化后的 RelNode 添加到图中,替换原先的 RelNode;
- 使用 Mark-Sweep 算法清除过时的 RelNode 及相关数据。
onMatch
以 FilterIntoJoinRule 优化 SQL select * from product p join order o on p.id=o.id where p.id > 1
为例,FilterIntoJoinRule 的作用是通过谓词下推技术优化 filter 和 join 语句的顺序。
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 的逻辑较为复杂,这里只讲述关键流程:
计算出 Filters,分为 4 部分:
- aboveFilters:join 之上的 filters;
- joinFilters:join 的 on filters;
- leftFilters:join 左表的 filters;
- rightFilters:join 右表的 filters;
- 调用 RelOptUtil.classifyFilters 尝试将 aboveFilters 下推到 leftFilters 或 rightFilters 中(也可能两者兼有),在这一步会通过创建两个 bitmap,以计算偏移量的方式来判定应该将 aboveFilters 下推到哪里;
- 应用跟 2 相似的逻辑尝试将 aboveFilters 下推到 joinFilters;
- 构建新的 RelNode,从 leftFilters 和 rightFilters 向上构建,再新建一个 Join RelNode;
- 将新建的 RelNode 放入 HepRuleCall,留待后续处理。
以上面的例子为例,尝试用 FilterIntoJoinRule 对该关系表达式进行优化,在遍历到 LogicFilter 时,满足匹配条件并触发了优化,得到了右图优化后的 RelNode 树,黄色部分表示优化过程中复用的 RelNode,而红色则是在优化过程中新生成的 RelNode。
applyTransformationResults
继续上述的例子,新生成的 RelNode root 为 LogicJoin,applyTransformationResults 所做的事即是将 LogicJoin 写进原先的 RelNode 树中(即替换掉 LogicFilter),并添加到 HepPlanner 的 graph 中(方便后续对 RelNode 进一步优化)。
这部分较清晰简单,是典型的关于树/图数据结构的处理。
- 首先建立 LogicProject → LogicJoin 的连接,这需要修改 LogicProject 的 input 指向 LogicJoin,并添加 LogicProject → LogicJoin 的边。
- 删除 LogicProject → LogicFilter 的边。
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);
// ...
}
这里的清除是一个简单的 Mark-Sweep 算法的实现,通过宽度优先获取所有可达的节点,并清除剩余不可达的节点。
目前从 Calcite 源码来看,在做 mark-sweep 时没有 clean edges。