欢迎来到某某鲜果配送有限公司!

专注鲜果配送

新鲜 / 健康 / 便利 / 快速 / 放心

全国咨询热线020-88888888
汇丰-汇丰娱乐蔬菜果蔬鲜果配送中心

新闻中心

 

推荐产品

24小时服务热线 020-88888888

公司资讯

万字详解Calcite Volcano优化器

发布日期:2024-04-08 06:09浏览次数:

calcite提供了一个强大而又易于扩展的查询优化器,通过将planner中的Rule反复应用于Relnode来优化,通过Cost Model指导该过程,尝试生成与原始输入有相同语义但成本较低的执行/逻辑计划。

本文的目的:

  1. Volcano优化器的使用
  2. 理解优化器核心组件和流程
  3. 优化器运行机制的代码级拆解
  4. 二次开发和场景应用的探索
为了防止内容膨胀,本文尽量不大段引入源码,引用部分尽量选择段落文字较小的核心逻辑;每章的结尾会有这段内容的小结,想忽略细节,快速了解机制的朋友可以直接阅读这部分。以下内容基于Calcite 1.35的实现。
  • RelNode: 关系表达式
  • RelSet :一个表达式的等价集合, 具有相同语义。我们通常对具有最低代价的表达式感兴趣
  • RelSubset :等价类的子集,其中所有关系表达式具有相同的物理属性(RelTraitSet}的实例,包括调用convention和排序规则(排序顺序)等特征
  • Trait :描述 关系表达式 的特性

以一个简单的Sql为例子

select * from "order" a 
join 
"item" b 
on "a"."oItemId"="b"."iItemId" 
where a.oOrderDate > '2023-08-22'

RelNode树如下

LogicalProject(oOrderId=[$0], oItemId=[$1], oShopId=[$2], oPrice=[$3], oOrderDate=[$4], iItemId=[$5], iItemName=[$6], iItemType=[$7], iCategoryId=[$8])
  LogicalJoin(condition=[=($1, $5)], joinType=[inner])
    LiteLogicalBaseScan(table=[[order]])
    LiteLogicalBaseScan(table=[[item]])

每一行是一个Relnode

经过一个自定义的Logical Trait 转换后

如上图,一个set内有多个RelSubset,每个subset有自己的trait定义,比如一个是None,一个是Logical

RelsubSet包含对应物理属性的relNode,通过这张图, 可以简单理解下这几个概念。


关系代数的逻辑最终被 RelNode树来表示,其中Relset代表语义相同(可以有不同的特性),RelSubSet代表特性相同。相同逻辑下,有不同逻辑路径和不同的物理执行方法,优化器就是在这个由包含关系和等价关系组成的树中,寻找最优执行计划的

Apache Calcite 中的 VolcanoPlanner 是对 Volcano/Cascades 优化器的实现,我们会从以下几个角度展开,来一窥优化器运转的全貌

上面提到,优化器的主要目前是对于输入的RelNode求解出最优的输出RelNode

那么从使用层面,主流程主要包括三个部分

优化器的基本接口是RelOptPlanner,其中以下方法

  • addRelTraitDef/changeTraits : 特性设置和改变
  • addRule/removeRule : 添加删除Rule
  • addMaterialization/addLattice : 用于物化视图改写
  • addListener : 添加监听
  • setExecutor : 添加Rex执行器

都可以视为为优化器工作提前做的准备,用户可以根据自己的需要设置 特性、待执行的规则、物化视图或lattice用于查询改写、监听器用于跟踪流程等等

setRoot实质上就是将要处理的Relnode输入

这么说起来可能觉得这里很简单,但却不是一个单纯的设置动作,它会涉及到优化器中的一个重要动作--Register

Register动作的目的,是在planner里面注册节点,并生成或归因到Relset和RelsubSet

注册的过程非常复杂,但梳理起来大致包含以下几个步骤:

  • 注册当前节点,并查看是否有归属的RelSet及RelSubSet,如果没有则创建
  • 确认当前节点的子节点也已经注册到RelSet的树中
  • 注册节点的同时,在Rule 的集合中找到能匹配到 算子节点模式 的 可执行规则
  • 计算RelSubSet中的bestCost
  • 每当有规则执行并产生新的节点时,再次执行上述步骤

后面专门会细拆这个注册的过程

通过上面的步骤,我们可以获得一个初始输入和组织好的RelSet + RelSubSet + Relnode的树,在这一步,规则将在ruleDriver的驱动下执行,并不断产生RelNode、RelSubSet和RelSet,当可执行的规则已经执行完毕,Relsubset均已经计算出BestCost。最后通过buildCheapestPlan获取最优节点则会比较简单,直接从root向下寻找每个等价集合的最优节点,连接起来即可。下图是一个优化器处理RelNode例子,其中蓝色线部分为选出的最优计划。

因此一个最简化的使用Volcano优化器的非典型性代码如下

    VolcanoPlanner planner = rel.getCluster().getPlanner();

    planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
    planner.addRelTraitDef(RelCollationTraitDef.INSTANCE);

    for (RelOptMaterialization materialization : materializations) {
        planner.addMaterialization(materialization);
    }

    for (RelOptLattice lattice : lattices) {
        planner.addLattice(lattice);
    }

    for (RelOptRule rule : ruleSet) {
       planner.addRule(rule);
    }

    if (!rel.getTraitSet().equals(requiredOutputTraits)) {
        rel = planner.changeTraits(rel, requiredOutputTraits);
    }

    planner.setRoot(rel);
    planner.findBestExp()


概括来说,第一步提供了优化执行的上下文所需的各种素材,第二步设置根节点,并开始第一轮注册,最重要的第三步完成规则的驱动执行、子集的代价计算及最终方案的选出。在这个流程中,有很多组件需要配合来运作,后面几部分会结合核心组件来从细节上拆解优化器执行流程。

下面我们开始分块详解每部分的重要流程和逻辑,梳理其间联系

Listener是个很重要的辅助机制,能够在优化器的各个阶段提供回调。

暂且不提功能,最简单的场景是使得核心流程可以落到日志中或者可视化,目前已经比较常见的事件回调如下

  • relEquivalenceFound

当检测到等价或者断言等价后通知

  • ruleAttempted rule

将被调用,有两次:一次在规则被调用之前,一次在规则被调用之后。请注意,事件的rel属性始终是旧的表达式

  • ruleProductionSucceeded

rule已成功应用于特定的关系表达式,从而产生了一个新的等效表达式(除非新表达式与现有表达式相同,否则还将调用relEquivalenceFound)。该规则将被调用两次:一次在注册新rel之前,一次在之后。请注意,事件的rel属性始终是新的表达式;要获取旧的表达式,请使用event.getRuleCall().rels[0]

  • relDiscarded

relnode被丢弃

  • relChosen relnode

已被选作查询计划的最终实现的一部分,比如Hep的buildFinalPlan,Volcano的buildCheapestPlan等

这些核心事件的监听器为我们能track整个优化过程提供了很好的帮助

/**
 * Gets the rule queue.
 */
RuleQueue getRuleQueue();

/**
 * Applies rules.
 */
void drive();

/**
 * Callback when new RelNodes are added into RelSet.
 *
 * @param rel the new RelNode
 * @param subset subset to add
 */
void onProduce(RelNode rel, RelSubset subset);

/**
 * Callback when RelSets are merged.
 *
 * @param set the merged result set
 */
void onSetMerged(RelSet set);

规则执行的驱动器,包含了基本的rule队列操作,实现不同的drive机制来apply rule,提供一些回调等。

目前calcite实现了TopDown(TopDownRuleDriver)和BottomUp(IterativeRuleDriver)两种ruleDriver,本文以IterativeRuleDriver为主

优化器执行的重要载体,VolcanoRuleCall有两个重要方法: 匹配(onMatch)和转换(TransformTo)

  • onMatch

注意:这个方法不是父类中的

Step 1: 判断rule是否被排除,过滤条件是一个正则或者hint的策略

Step 2: rels是一个call的多个operand,判断是否有任意一个rel需要跳过,比如prune或者已经有等价set

for (int i=0; i < rels.length; i++){
        RelNode rel=rels[i];
        RelSubset subset=volcanoPlanner.getSubset(rel);

        if (subset==null){
          LOGGER.debug(
              "Rule[{}]not fired because operand #{}({}) has no subset",
              getRule(), i, rel);
          return;
        }

        if ((subset.set.equivalentSet !=null)
            // When rename RelNode via VolcanoPlanner#rename(RelNode rel),
            // we may remove rel from its subset: "subset.set.rels.remove(rel)".
            // Skip rule match when the rel has been removed from set.
            || (subset !=rel && !subset.contains(rel))){
          LOGGER.debug(
              "Rule[{}]not fired because operand #{}({}) belongs to obsolete set",
              getRule(), i, rel);
          return;
        }
}

Step 3: 调用rule的onmatch

  • transformTo
  /**
   * Registers that a rule has produced an equivalent relational expression,
   * but no other equivalences.
   *
   * <p>The hints are copied with filter strategies from
   * the root relational expression of the rule call(<code>this.rels[0]</code>)
   * to the new relational expression(<code>rel</code>).
   *
   * @param rel Relational expression equivalent to the root relational
   *            expression of the rule call,{@code call.rels(0)}
   */
  public final void transformTo(RelNode rel){
    transformTo(rel, ImmutableMap.of());
  }

从注释可以看出,这个转换动作完成了等价关系代数的生成和注册

这里我们主要看下VolcanoRuleCall的实现

      // Step 1
      rel=handler.propagate(rels[0], rel);
      
      // Step 2
      if (this.getRule() instanceof SubstitutionRule
          && ((SubstitutionRule) getRule()).autoPruneOld()){
        volcanoPlanner.prune(rels[0]);
      }

      // Step 3
      // Registering the root relational expression implicitly registers
      // its descendants. Register any explicit equivalences first, so we
      // don't register twice and cause churn.
      for (Map.Entry<RelNode, RelNode> entry : equiv.entrySet()){
        volcanoPlanner.ensureRegistered(
            entry.getKey(), entry.getValue());
      }

      RelSubset subset=volcanoPlanner.ensureRegistered(rel, rels[0]);

上面贴出了transformTo的核心逻辑,梳理下来包括以下过程

Step 1 : 将hint从root 的relnode 传播到新生成的等价relnode

Step 2 : 对于确定性优化的裁剪,避免在优化器的搜索空间引入过多的无意义节点

SubstitutionRule 实现这个接口表明 新的RelNode通常比旧的更好。所有的替换规则将首先执行,直到完成,如果该规则设置了autoPruneOld,那么老的relnode会直接prune掉

autoPrune的默认值是false,只有少量规则重写为true,比如ProjectRemove,但是仍然有很多Rule直接在规则中显示地裁剪了call.getPlanner().prune(xxx),为什么会有这个现象,因为SubstitutionRule的提交时间比较新,2020年,之前的规则已经有prune的逻辑

Step 3: ensureRegistered,确保注册等价类

过程中还伴随着各种事件通知,即使用到Listen来做一些日志记录等

Convention也是继承自Trait,常见的Convention有NONE,BindableConvention,EnumerableConvention,JdbcConvention,在虚拟化引擎中也可以自定义Convention来表示不是实际物理执行的约定

Trait目前常见的是排序RelCollation和分布RelDistribution

为什么需要转成带Convention的算子,从业务意义上来说,代表这个算子到了执行层面,从逻辑意义上来说,由于noneConventionHasInfiniteCost的开关,没有Convention的算子,代价为Infinite

Convention中还有一个方法:enforce

用来对齐relnode 的traitset,比如EnumerableConvertion的实现

AbstractConverter

AbstractConverter类是一个抽象的RelNode转换器,用于将RelNode转换为任意给定的输出规范。

与大多数RelOptRuleCall的Converter不同,AbstractConverter始终是抽象的。您通常在需要立即进行关系表达式转换的情况下创建一个AbstractConverter。随后,规则将会将其转换为可实现的关系表达式。

如果无法立即满足抽象转换器的要求(因为源子集是抽象的),则会标记该集合,以便在将非抽象的RelNode添加到集合中时立即扩展该转换器。

ExpandConversionRule是一个将AbstractConverter转换为从源关系到目标特征的转换链的规则。

它会生成一个最小的转换链,因为之前已经构建了转换图的传递闭包,因此我们可以选择最短的链路。

与AbstractConverter不同,这些转换器能够确保能够将任何符合其调用约定的关系转换为目标特征。此外,由于这些转换器在转换过程中会引入其他调用约定的子集,因此这些子集可能会生成不适用于一般情况的更高效的转换结果。

这段代码的目的是定义抽象转换器和相应的转换规则,用于在查询计划优化过程中进行关系转换。AbstractConverter是一个抽象的关系节点转换器,用于立即将RelNode转换为指定的输出规范。ExpandConversionRule是一个规则,用于将AbstractConverter转换为能够处理特定特征集的转换链。它通过计算最短的转换链来生成最佳的转换结果。


优化器操作对象的主体是RelNode/RelSubset/Relset, 规则调用的主体是RuleCall,特性的主体是Convention和Trait,再辅助以一些流程上的组件,构成了优化器运行的基本框架。

这一部分涉及到具体代码实现的逻辑,前面主流程里提到,实际优化器实际运作的入口是setRoot,这里就包含了两个重要的方法: ensureRootConverters 和 registerImpl

  public void setRoot(RelNode rel) {
    this.root = registerImpl(rel, null);
    if (this.originalRoot == null) {
      this.originalRoot = rel;
    }

    rootConvention = this.root.getConvention();
    ensureRootConverters();
  }

确保root的subset包含其等价集中的所有其他子集的converter,这是planner中唯一需要显式转换器的地方,其他地方都有consumer去要求做一个convention动作,而root没有consumer

注册一个新的relnode并排队规则匹配。如果传入的set参数不为null,则将relnode设置为该等价集的一部分。

如果已经注册了相同的relnode,则不需要再注册该表达式,也不应该排队规则匹配,最后返回一个等价集合RelSubset

  1. 一系列校验,并确保子表达式也都register完成
  2. 注册这个relnode 的class,生成class和operand的映射
  3. addRelToSet

当注册RelNodes的树时,有时节点的成本会改善,但子集却不知道。可能会出现一个子集,其中单个关系的成本是99,但它认为自己的最佳成本是100。我们认为这是因为与父节点的反向链接没有建立。因此,给子集另一次机会来计算出自己的成本

public RelSubset ensureRegistered(RelNode rel, @Nullable RelNode equivRel){
    RelSubset result;
    final RelSubset subset=getSubset(rel);
    if (subset !=null){
      if (equivRel !=null){
        final RelSubset equivSubset=getSubsetNonNull(equivRel);
        if (subset.set !=equivSubset.set){
          merge(equivSubset.set, subset.set);
        }
      }
      result=canonize(subset);
    }else{
      result=register(rel, equivRel);
    }

    // Checking if tree is valid considerably slows down planning
    // Only doing it if logger level is debug or finer
    if (LOGGER.isDebugEnabled()){
      assert isValid(Litmus.THROW);
    }

    return result;
  }

其中核心的动作是做两个Relset的merge

  1. 找到两个set等价树的root,如果相等,就没什么可做的直接返回
  2. 如果需要,交换集合,这样我们总是将较新的集合合并到较旧的集合中,或将父集合合并到子集合中
  3. mergeWith 完成此方法后,被合并的RelSet将变得过时,其它的equivalentSet成员指向当前的RelSet,而当前的RelSet仍然存在
  • 确定合并的两个RelSet(this和otherSet)不相等且都没有其他的等价集合。
  • 合并subset:根据subset的traitset和要求的状态创建新的RelSubset,并进行合并和更新。
  • 更新父节点和子节点的关联关系,处理重命名操作。
  • 更新cost信息并传播变化,确保cost改变得到正确传播。(propagateCostImprovements)

基于输入的关系节点(rel),初始化一个优先级队列(propagateHeap)和一个关系节点到成本的映射(propagateRels)。

  1. 将输入的relnode及其cost加入映射和优先级队列。
  2. 通过循环从优先级队列中取出relnode,并遍历relnode的子集。
  3. 检查subset的特征集是否满足reldnoe的特征集,以及是否有更低的成本。
  4. 如果relnode的成本更低,subset的最佳成本发生了变化,则更新subset的best cost、best relnode,并清除subset及其父节点的元数据缓存。
  5. 对于subset的每个父节点,更新父节点的成本,并将其添加到优先级队列中以进行进一步传播。
  6. 循环直到优先级队列为空。

该函数通过传播cost改进来优化查询计划。它使用优先级队列和成本映射来管理节点的传播顺序,并根据成本的变化更新subset的最佳成本和最佳关系节点,以及相关节点的元数据缓存。通过传播成本的改进,可以在计划优化过程中逐步改进计划的成本估算,从而得到更优化的查询执行计划。

到这一步subset里的代价计算已经结束了,实际上是在等价类生成时已经计算好了,直接从root的subset开始,向下找到所有节点的best,每个节点处理完之后标记为已访问。

 void fireRules(RelNode rel){
    for (RelOptRuleOperand operand : classOperands.get(rel.getClass())){
      if (operand.matches(rel)){
        final VolcanoRuleCall ruleCall;
        ruleCall=new DeferringRuleCall(this, operand);
        ruleCall.match(rel);
      }
    }
  }

DeferringRuleCall 延迟调用,不同与 RelOptRuleCall直接调用,这里不直接执行,而是放到queue中

主要靠 CheapestPlanReplacer来实现,求解root的relsubset的最优计划


buildCheapestPlan之上的几个步骤实际上是在不断交织运行的,也是最复杂的一段逻辑,但是对于使用者来说,这一部分需要修改做二次开发的场景不多,理解整体的功能链路还是比较清晰的

前面提到,我们认为Calcite 的 Volcanno优化器是一个 强大 并且 扩展性强 的优化器

  1. 强大:

实现层面尤其是在加入了TopDownRuleDriver之后,基本已经是Cascades机制的优化器,搜索空间的组织、规则执行的合理性、代价计算和传播、分支裁剪等保障了优化器在优化时间和优化效果上有一个比较好的预期

2. 扩展性强:

除了前面提到的add/remove系列方法外,还有一些非常好扩展的点

  • 开放的register方法,方便从外部去组织一些等价类,包括一些由业务或者模型确定等价而非规则确定等价的等价类
  • 自定义或者改写ruleDriver,实现更适合的比如一些启发式的规则执行驱动和裁剪策略
  • registerMaterializations的继承改写,不同的数据库引擎可能需要引入自己的物化视图过滤、预处理、处理机制,比如在匹配之前的filtertree过滤,比如更好地编排unifyrule、materialview、lattice改写方式的结合
  • Calcite开放的元数据Handler和代价计算体系,方便利用统计信息(min/max,histogram等)对于优化器执行过程中依赖的代价做精细化改进
优化器往往是数据库引擎非常核心的一部分,然后这套机制其实不仅限于生成执行计划,比如对于虚拟化引擎,在优化器层可以做到基于引擎特色的规则编排和代价评估来决定实际连接引擎执行的方式,同时也可以作为MVS(物化视图选择)话题的一个搜索和执行评估框架。

最后,最优计划不是追求一般意义的完全最优计划,计划实际效果和planning时间的trade off也同样重要。

020-88888888

平台注册入口