YON CISE

Calcite 逻辑执行计划错误

昨天在使用 Calcite 的项目里新增了一条自定义规则,导致最终生成了不符合 SQL 语义的逻辑执行计划,今天排查了一天后发现是自定义规则里生成的节点没有重写 explainTerms 方法,执行计划优化时错误的认为两个不同的自定义节点语义等价然后合并了两个节点所属的 RelSet。

网上介绍 Calcite 进行执行计划优化时一般举的都是比较简单的例子,通常只涉及到 RelNode 的替换。实际应用场景里会复杂很多,优化时会同时涉及到 RelSet、RelSubset 和 RelNode 之间的相互操作,例如,一条规则在对一个节点优化时生成了一个等价的节点,该等价节点出现在了另外一个 RelSet 内,这时候 Calcite 就会对两个 RelSet 进行 merge 操作。这里两个问题,一是 Calcite 怎么判断两个节点等价的,二是 merge 操作时是怎么 merge 的。

判断节点等价的地方是:

// org.apache.calcite.plan.volcano.VolcanoPlanner#registerImpl

// If it is equivalent to an existing expression, return the set that
// the equivalent expression belongs to.
RelDigest digest = rel.getRelDigest();
RelNode equivExp = mapDigestToRel.get(digest);

可以看到节点的等价判断是基于节点的 RelDigest 对象来的,RelDigest 的默认实现是 InnerRelDigest类,该类重写了 hashCode方法:

@Override public int hashCode() {
  if (hash == 0) {
    hash = deepHashCode();
  }
  return hash;
}

@Override public int deepHashCode() {
int result = 31 + getTraitSet().hashCode();
List<Pair<String, @Nullable Object>> items = this.getDigestItems();
for (Pair<String, @Nullable Object> item : items) {
  Object value = item.right;
  final int h;
  if (value == null) {
    h = 0;
  } else if (value instanceof RelNode) {
    h = ((RelNode) value).deepHashCode();
  } else {
    h = value.hashCode();
  }
  result = result * 31 + h;
}
return result;
}

private List<Pair<String, @Nullable Object>> getDigestItems() {
  RelDigestWriter rdw = new RelDigestWriter();
  explainTerms(rdw);
  if (this instanceof Hintable) {
    List<RelHint> hints = ((Hintable) this).getHints();
    rdw.itemIf("hints", hints, !hints.isEmpty());
  }
  return rdw.attrs;
}

代码比较简单不详说了,可以看到最终是基于节点的 explainTerms 方法来判断节点是否一致的。

RelSet 合并的大步骤是:

  1. 确定合并时以哪个 RelSet 为主,主要逻辑是新的 RelSet 合并到旧的,父的 RelSet 合并到子的
  2. 合并 RelSubset
  3. 合并 RelNode,这一步中如果 RelNode 是另一个 RelSet 的父节点,那么该节点就会被删掉
  4. 后置处理,例如,重新计算 Cost,更新所有依赖了被合并的 RelSet 的节点的引用,触发规则

大逻辑上还是比较简单的,代码里的细节处理还是比较多的就不细说了,代码入口在 org.apache.calcite.plan.volcano.VolcanoPlanner#merge

最后说下今天是怎么调试的。默认情况下,要看到 Calcite 的优化过程只需要把日志级别调到 Trace 即可,另外关注下 org.apache.calcite.config.CalciteSystemProperty#DUMP_GRAPHVIZ 的值,这样 Calcite 就会输出 graphviz 格式的节点图,网上随便找个在线的 graphviz 渲染引擎就可以看了。优化过程成日志输出比较多,建议在 org.apache.calcite.plan.volcano.VolcanoPlanner#dump 方法里下个断点,然后去控制台找生成的 graphviz 代码。

P.S. graphviz 生成的图里如果 RelSubset 指向另外一个 RelSubset 的含义不是 RelSubset 以另外一个 RelSubset 为 input,她的含义是被指向的 RelSubset 是另一个 RelSubset 的子集:

// org.apache.calcite.plan.volcano.Dumpers#dumpGraphviz
for (RelSubset subset : subsetPoset) {
  List<RelSubset> children = subsetPoset.getChildren(subset);
  if (children == null) {
    continue;
  }
  for (RelSubset parent : children) {
    pw.print("\t\tsubset");
    pw.print(subset.getId());
    pw.print(" -> subset");
    pw.print(parent.getId());
    pw.print(";");
  }
}