Apache Calcite 优化器详解(二)
什么是查询优化器
查询优化器是传统数据库的核心模块,也是大数据计算引擎的核心模块,开源大数据引擎如 Impala、Presto、Drill、HAWQ、 Spark、Hive 等都有自己的查询优化器。Calcite 就是从 Hive 的优化器演化而来的。
优化器的作用:将解析器生成的关系代数表达式转换成执行计划,供执行引擎执行,在这个过程中,会应用一些规则优化,以帮助生成更高效的执行计划。
关于 Volcano 模型和 Cascades 模型的内容,建议看下相关的论文,这个是 Calcite 优化器的理论基础,代码只是把这个模型落地实现而已。
基于规则优化(RBO)
基于规则的优化器(Rule-Based Optimizer,RBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会变成另外一个关系表达式,同时原有表达式会被裁剪掉,经过一系列转换后生成最终的执行计划。
RBO 中包含了一套有着严格顺序的优化规则,同样一条 SQL,无论读取的表中数据是怎么样的,最后生成的执行计划都是一样的。同时,在 RBO 中 SQL 写法的不同很有可能影响最终的执行计划,从而影响执行计划的性能。
基于成本优化(CBO)
基于代价的优化器(Cost-Based Optimizer,CBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会生成另外一个关系表达式,同时原有表达式也会保留,经过一系列转换后会生成多个执行计划,然后 CBO 会根据统计信息和代价模型 (Cost Model) 计算每个执行计划的 Cost,从中挑选 Cost 最小的执行计划。
由上可知,CBO 中有两个依赖:统计信息和代价模型。统计信息的准确与否、代价模型的合理与否都会影响 CBO 选择最优计划。 从上述描述可知,CBO 是优于 RBO 的,原因是 RBO 是一种只认规则,对数据不敏感的呆板的优化器,而在实际过程中,数据往往是有变化的,通过 RBO 生成的执行计划很有可能不是最优的。事实上目前各大数据库和大数据计算引擎都倾向于使用 CBO,但是对于流式计算引擎来说,使用 CBO 还是有很大难度的,因为并不能提前预知数据量等信息,这会极大地影响优化效果,CBO 主要还是应用在离线的场景。
优化规则
无论是 RBO,还是 CBO 都包含了一系列优化规则,这些优化规则可以对关系表达式进行等价转换,常见的优化规则包含:
-
谓词下推 Predicate Pushdown
-
常量折叠 Constant Folding
-
列裁剪 Column Pruning
-
其他
在 Calcite 的代码里,有一个测试类(org.apache.calcite.test.RelOptRulesTest
)汇集了对目前内置所有 Rules 的测试 case,这个测试类可以方便我们了解各个 Rule 的作用。在这里有下面一条 SQL,通过这条语句来说明一下上面介绍的这三种规则。
1 2 3 |
select 10 + 30, users.name, users.age from users join jobs on users.id= user.id where users.age > 30 and jobs.id>10 |
谓词下推(Predicate Pushdown)
关于谓词下推,它主要还是从关系型数据库借鉴而来,关系型数据中将谓词下推到外部数据库用以减少数据传输;属于逻辑优化,优化器将谓词过滤下推到数据源,使物理执行跳过无关数据。最常见的例子就是 join 与 filter 操作一起出现时,提前执行 filter 操作以减少处理的数据量,将 filter 操作下推,以上面例子为例,示意图如下(对应 Calcite 中的 FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN
Rule):
Filter操作下推前后的对比
在进行 join 前进行相应的过滤操作,可以极大地减少参加 join 的数据量。
常量折叠(Constant Folding)
常量折叠也是常见的优化策略,这个比较简单、也很好理解,可以看下 编译器优化 – 常量折叠 这篇文章,基本不用动脑筋就能理解,对于我们这里的示例,有一个常量表达式 10 + 30
,如果不进行常量折叠,那么每行数据都需要进行计算,进行常量折叠后的结果如下图所示( 对应 Calcite 中的 ReduceExpressionsRule.PROJECT_INSTANCE
Rule):
常量折叠前后的对比
列裁剪(Column Pruning)
列裁剪也是一个经典的优化规则,在本示例中对于jobs 表来说,并不需要扫描它的所有列值,而只需要列值 id,所以在扫描 jobs 之后需要将其他列进行裁剪,只留下列 id。这个优化带来的好处很明显,大幅度减少了网络 IO、内存数据量的消耗。裁剪前后的示意图如下(不过并没有找到 Calcite 对应的 Rule):
列裁剪前后的对比
Calcite 中的优化器实现
有了前面的基础后,这里来看下 Calcite 中优化器的实现,RelOptPlanner 是 Calcite 中优化器的基类,其子类实现如下图所示:
RelOptPlanner
Calcite 中关于优化器提供了两种实现:
-
HepPlanner:就是前面 RBO 的实现,它是一个启发式的优化器,按照规则进行匹配,直到达到次数限制(match 次数限制)或者遍历一遍后不再出现 rule match 的情况才算完成;
-
VolcanoPlanner:就是前面 CBO 的实现,它会一直迭代 rules,直到找到 cost 最小的 paln。
前面提到过像calcite这类查询优化器最核心的两个问题之一是怎么把优化规则应用到关系代数相关的RelNode Tree上。所以在阅读calicite的代码时就得带着这个问题去看看它的实现过程,然后才能判断它的代码实现得是否优雅。
calcite的每种规则实现类(RelOptRule的子类)都会声明自己应用在哪种RelNode子类上,每个RelNode子类其实都可以看成是一种operator(中文常翻译成算子)。
VolcanoPlanner就是优化器,用的是动态规划算法,在创建VolcanoPlanner的实例后,通过calcite的标准jdbc接口执行sql时,默认会给这个VolcanoPlanner的实例注册将近90条优化规则(还不算常量折叠这种最常见的优化),所以看代码时,知道什么时候注册可用的优化规则是第一步(调用VolcanoPlanner.addRule实现),这一步比较简单。
接下来就是如何筛选规则了,当把语法树转成RelNode Tree后是没有必要把前面注册的90条优化规则都用上的,所以需要有个筛选的过程,因为每种规则是有应用范围的,按RelNode Tree的不同节点类型就可以筛选出实际需要用到的优化规则了。这一步说起来很简单,但在calcite的代码实现里是相当复杂的,也是非常关键的一步,是从调用VolcanoPlanner.setRoot方法开始间接触发的,如果只是静态的看代码不跑起来跟踪调试多半摸不清它的核心流程的。筛选出来的优化规则会封装成VolcanoRuleMatch,然后扔到RuleQueue里,而这个RuleQueue正是接下来执行动态规划算法要用到的核心类。筛选规则这一步的代码实现很晦涩。
第三步才到VolcanoPlanner.findBestExp,本质上就是一个动态规划算法的实现,但是最值得关注的还是怎么用第二步筛选出来的规则对RelNode Tree进行变换,变换后的形式还是一棵RelNode Tree,最常见的是把LogicalXXX开头的RelNode子类换成了EnumerableXXX或BindableXXX,总而言之,看看具体优化规则的实现就对了,都是繁琐的体力活。
一个优化器,理解了上面所说的三步基本上就抓住重点了。
—— 来自【zhh-4096 】的微博
下面详细讲述一下这两种 planner 在 Calcite 内部的具体实现。
HepPlanner
使用 HepPlanner 实现的完整代码见 SqlHepTest。
HepPlanner 中的基本概念
这里先看下 HepPlanner 的一些基本概念,对于后面的理解很有帮助。
HepRelVertex
HepRelVertex 是对 RelNode 进行了简单封装。HepPlanner 中的所有节点都是 HepRelVertex,每个 HepRelVertex 都指向了一个真正的 RelNode 节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// org.apache.calcite.plan.hep.HepRelVertex /** * note:HepRelVertex 将一个 RelNode 封装为一个 DAG 中的 vertex(DAG 代表整个 query expression) */ public class HepRelVertex extends AbstractRelNode { //~ Instance fields -------------------------------------------------------- /** * Wrapped rel currently chosen for implementation of expression. */ private RelNode currentRel; } |
HepInstruction
HepInstruction 是 HepPlanner 对一些内容的封装,具体的子类实现比较多,其中 RuleInstance 是 HepPlanner 中对 Rule 的一个封装,注册的 Rule 最后都会转换为这种形式。
HepInstruction represents one instruction in a HepProgram.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
//org.apache.calcite.plan.hep.HepInstruction /** Instruction that executes a given rule. */ //note: 执行指定 rule 的 Instruction static class RuleInstance extends HepInstruction { /** * Description to look for, or null if rule specified explicitly. */ String ruleDescription; /** * Explicitly specified rule, or rule looked up by planner from * description. * note:设置其 Rule */ RelOptRule rule; void initialize(boolean clearCache) { if (!clearCache) { return; } if (ruleDescription != null) { // Look up anew each run. rule = null; } } void execute(HepPlanner planner) { planner.executeInstruction(this); } } |
HepPlanner 处理流程
下面这个示例是上篇文章(Apache Calcite 处理流程详解(一))的示例,通过这段代码来看下 HepPlanner 的内部实现机制。
1 2 3 4 5 |
HepProgramBuilder builder = new HepProgramBuilder(); builder.addRuleInstance(FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN); //note: 添加 rule HepPlanner hepPlanner = new HepPlanner(builder.build()); hepPlanner.setRoot(relNode); relNode = hepPlanner.findBestExp(); |
上面的代码总共分为三步:
-
初始化 HepProgram 对象;
-
初始化 HepPlanner 对象,并通过
setRoot()
方法将 RelNode 树转换成 HepPlanner 内部使用的 Graph; -
通过
findBestExp()
找到最优的 plan,规则的匹配都是在这里进行。
1. 初始化 HepProgram
这几步代码实现没有太多需要介绍的地方,先初始化 HepProgramBuilder 也是为了后面初始化 HepProgram 做准备,HepProgramBuilder 主要也就是提供了一些配置设置和添加规则的方法等,常用的方法如下:
-
addRuleInstance()
:注册相应的规则; -
addRuleCollection()
:这里是注册一个规则集合,先把规则放在一个集合里,再注册整个集合,如果规则多的话,一般是这种方式; -
addMatchLimit()
:设置 MatchLimit,这个 rule match 次数的最大限制;
HepProgram 这个类对于后面 HepPlanner 的优化很重要,它定义 Rule 匹配的顺序,默认按【深度优先】顺序,它可以提供以下几种(见 HepMatchOrder 类):
-
ARBITRARY:按任意顺序匹配(因为它是有效的,而且大部分的 Rule 并不关心匹配顺序);
-
BOTTOM_UP:自下而上,先从子节点开始匹配;
-
TOP_DOWN:自上而下,先从父节点开始匹配;
-
DEPTH_FIRST:深度优先匹配,某些情况下比 ARBITRARY 高效(为了避免新的 vertex 产生后又从 root 节点开始匹配)。
这个匹配顺序到底是什么呢?对于规则集合 rules,HepPlanner 的算法是:从一个节点开始,跟 rules 的所有 Rule 进行匹配,匹配上就进行转换操作,这个节点操作完,再进行下一个节点,这里的匹配顺序就是指的节点遍历顺序(这种方式的优劣,我们下面再说)。
2. HepPlanner.setRoot(RelNode –> Graph)
先看下 setRoot()
方法的实现:
1 2 3 4 5 6 7 |
// org.apache.calcite.plan.hep.HepPlanner public void setRoot(RelNode rel) { //note: 将 RelNode 转换为 DAG 表示 root = addRelToGraph(rel); //note: 仅仅是在 trace 日志中输出 Graph 信息 dumpGraph(); } |
HepPlanner 会先将所有 relNode tree 转化为 HepRelVertex,这时就构建了一个 Graph:将所有的 elNode 节点使用 Vertex 表示,Gragh 会记录每个 HepRelVertex 的 input 信息,这样就是构成了一张 graph。
在真正的实现时,递归逐渐将每个 relNode 转换为 HepRelVertex,并在 graph
中记录相关的信息,实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
//org.apache.calcite.plan.hep.HepPlanner //note: 根据 RelNode 构建一个 Graph private HepRelVertex addRelToGraph( RelNode rel) { // Check if a transformation already produced a reference // to an existing vertex. //note: 检查这个 rel 是否在 graph 中转换了 if (graph.vertexSet().contains(rel)) { return (HepRelVertex) rel; } // Recursively add children, replacing this rel's inputs // with corresponding child vertices. //note: 递归地增加子节点,使用子节点相关的 vertices 代替 rel 的 input final List<RelNode> inputs = rel.getInputs(); final List<RelNode> newInputs = new ArrayList<>(); for (RelNode input1 : inputs) { HepRelVertex childVertex = addRelToGraph(input1); //note: 递归进行转换 newInputs.add(childVertex); //note: 每个 HepRelVertex 只记录其 Input } if (!Util.equalShallow(inputs, newInputs)) { //note: 不相等的情况下 RelNode oldRel = rel; rel = rel.copy(rel.getTraitSet(), newInputs); onCopy(oldRel, rel); } // Compute digest first time we add to DAG, // otherwise can't get equivVertex for common sub-expression //note: 计算 relNode 的 digest //note: Digest 的意思是: //note: A short description of this relational expression's type, inputs, and //note: other properties. The string uniquely identifies the node; another node //note: is equivalent if and only if it has the same value. rel.recomputeDigest(); // try to find equivalent rel only if DAG is allowed //note: 如果允许 DAG 的话,检查是否有一个等价的 HepRelVertex,有的话直接返回 if (!noDag) { // Now, check if an equivalent vertex already exists in graph. String digest = rel.getDigest(); HepRelVertex equivVertex = mapDigestToVertex.get(digest); if (equivVertex != null) { //note: 已经存在 // Use existing vertex. return equivVertex; } } // No equivalence: create a new vertex to represent this rel. //note: 创建一个 vertex 代替 rel HepRelVertex newVertex = new HepRelVertex(rel); graph.addVertex(newVertex); //note: 记录 Vertex updateVertex(newVertex, rel);//note: 更新相关的缓存,比如 mapDigestToVertex map for (RelNode input : rel.getInputs()) { //note: 设置 Edge graph.addEdge(newVertex, (HepRelVertex) input);//note: 记录与整个 Vertex 先关的 input } nTransformations++; return newVertex; } |
到这里 HepPlanner 需要的 gragh 已经构建完成,通过 DEBUG 方式也能看到此时 HepPlanner root 变量的内容:
Root 转换之后的内容
3. HepPlanner findBestExp 规则优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//org.apache.calcite.plan.hep.HepPlanner // implement RelOptPlanner //note: 优化器的核心,匹配规则进行优化 public RelNode findBestExp() { assert root != null; //note: 运行 HepProgram 算法(按 HepProgram 中的 instructions 进行相应的优化) executeProgram(mainProgram); // Get rid of everything except what's in the final plan. //note: 垃圾收集 collectGarbage(); return buildFinalPlan(root); //note: 返回最后的结果,还是以 RelNode 表示 } |
主要的实现是在 executeProgram()
方法中,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
//org.apache.calcite.plan.hep.HepPlanner private void executeProgram(HepProgram program) { HepProgram savedProgram = currentProgram; //note: 保留当前的 Program currentProgram = program; currentProgram.initialize(program == mainProgram);//note: 如果是在同一个 Program 的话,保留上次 cache for (HepInstruction instruction : currentProgram.instructions) { instruction.execute(this); //note: 按 Rule 进行优化(会调用 executeInstruction 方法) int delta = nTransformations - nTransformationsLastGC; if (delta > graphSizeLastGC) { // The number of transformations performed since the last // garbage collection is greater than the number of vertices in // the graph at that time. That means there should be a // reasonable amount of garbage to collect now. We do it this // way to amortize garbage collection cost over multiple // instructions, while keeping the highwater memory usage // proportional to the graph size. //note: 进行转换的次数已经大于 DAG Graph 中的顶点数,这就意味着已经产生大量垃圾需要进行清理 collectGarbage(); } } currentProgram = savedProgram; } |
这里会遍历 HepProgram 中 instructions(记录注册的所有 HepInstruction),然后根据 instruction 的类型执行相应的 executeInstruction()
方法,如果instruction 是 HepInstruction.MatchLimit
类型,会执行 executeInstruction(HepInstruction.MatchLimit instruction)
方法,这个方法就是初始化 matchLimit 变量。对于 HepInstruction.RuleInstance
类型的 instruction 会执行下面的方法(前面的示例注册规则使用的是 addRuleInstance()
方法,所以返回的 rules 只有一个规则,如果注册规则的时候使用的是 addRuleCollection()
方法注册一个规则集合的话,这里会返回的 rules 就是那个规则集合):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
//org.apache.calcite.plan.hep.HepPlanner //note: 执行相应的 RuleInstance void executeInstruction( HepInstruction.RuleInstance instruction) { if (skippingGroup()) { return; } if (instruction.rule == null) {//note: 如果 rule 为 null,那么就按照 description 查找具体的 rule assert instruction.ruleDescription != null; instruction.rule = getRuleByDescription(instruction.ruleDescription); LOGGER.trace("Looking up rule with description {}, found {}", instruction.ruleDescription, instruction.rule); } //note: 执行相应的 rule if (instruction.rule != null) { applyRules( Collections.singleton(instruction.rule), true); } } |
接下来看 applyRules()
的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
//org.apache.calcite.plan.hep.HepPlanner //note: 执行 rule(forceConversions 默认 true) private void applyRules( Collection<RelOptRule> rules, boolean forceConversions) { if (currentProgram.group != null) { assert currentProgram.group.collecting; currentProgram.group.ruleSet.addAll(rules); return; } LOGGER.trace("Applying rule set {}", rules); //note: 当遍历规则是 ARBITRARY 或 DEPTH_FIRST 时,设置为 false,此时不会从 root 节点开始,否则每次 restart 都从 root 节点开始 boolean fullRestartAfterTransformation = currentProgram.matchOrder != HepMatchOrder.ARBITRARY && currentProgram.matchOrder != HepMatchOrder.DEPTH_FIRST; int nMatches = 0; boolean fixedPoint; //note: 两种情况会跳出循环,一种是达到 matchLimit 限制,一种是遍历一遍不会再有新的 transform 产生 do { //note: 按照遍历规则获取迭代器 Iterator<HepRelVertex> iter = getGraphIterator(root); fixedPoint = true; while (iter.hasNext()) { HepRelVertex vertex = iter.next();//note: 遍历每个 HepRelVertex for (RelOptRule rule : rules) {//note: 遍历每个 rules //note: 进行规制匹配,也是真正进行相关操作的地方 HepRelVertex newVertex = applyRule(rule, vertex, forceConversions); if (newVertex == null || newVertex == vertex) { continue; } ++nMatches; //note: 超过 MatchLimit 的限制 if (nMatches >= currentProgram.matchLimit) { return; } if (fullRestartAfterTransformation) { //note: 发生 transformation 后,从 root 节点再次开始 iter = getGraphIterator(root); } else { // To the extent possible, pick up where we left // off; have to create a new iterator because old // one was invalidated by transformation. //note: 尽可能从上次进行后的节点开始 iter = getGraphIterator(newVertex); if (currentProgram.matchOrder == HepMatchOrder.DEPTH_FIRST) { //note: 这样做的原因就是为了防止有些 HepRelVertex 遗漏了 rule 的匹配(每次从 root 开始是最简单的算法),因为可能出现下推 nMatches = depthFirstApply(iter, rules, forceConversions, nMatches); if (nMatches >= currentProgram.matchLimit) { return; } } // Remember to go around again since we're // skipping some stuff. //note: 再来一遍,因为前面有跳过一些节点 fixedPoint = false; } break; } } } while (!fixedPoint); } |
在这里会调用 getGraphIterator()
方法获取 HepRelVertex 的迭代器,迭代的策略(遍历的策略)跟前面说的顺序有关,默认使用的是【深度优先】,这段代码比较简单,就是遍历规则+遍历节点进行匹配转换,直到满足条件再退出,从这里也能看到 HepPlanner 的实现效率不是很高,它也无法保证能找出最优的结果。
总结一下,HepPlanner 在优化过程中,是先遍历规则,然后再对每个节点进行匹配转换,直到满足条件(超过限制次数或者规则遍历完一遍不会再有新的变化),其方法调用流程如下:
HepPlanner 处理流程
思考
1. 为什么要把 RelNode 转换 HepRelVertex 进行优化?带来的收益在哪里?
关于这个,能想到的就是:RelNode 是底层提供的抽象、偏底层一些,在优化器这一层,需要记录更多的信息,所以又做了一层封装。