Memo / RelSet / RelSubset 的基本结构见 系列第 1 篇 Calcite 总览;本文只讲 Cost 账本、两种 RuleDriver 与 Trait Enforcement 的差异。
VolcanoPlanner 的代价比较、候选枚举与物理属性(Physical Property)补偿是三个独立机制。默认 driver 不用成本上界截断规则枚举,但搜索期间仍持续更新最优候选;最终提取计划时只是读取这些结果。混淆两者,会误判 CBO 的工作时机。
上一篇《深入 Calcite 优化器:Hep、Volcano、谓词推导与 Join Reorder》讨论了优化器的接入边界。本文继续沿 RelSubset 的代价状态、两种 RuleDriver 和 Trait Enforcement 追踪一条查询,区分默认框架行为与集成方需要提供的能力。
源码基线与搜索入口
阅读范围与源码基线
Calcite 的代价接口、搜索驱动器和 Trait 协议可以分别扩展,默认实现不等于集成引擎的行为。本文沿 findBestExp() 追踪 RelSubset 如何维护最优状态,并对照默认迭代搜索与可选自顶向下搜索。
源码分析基线
以下结论限定到同一个 Calcite 提交及文中列出的 Enumerable 配置。其他引擎的 CostFactory、规则注册与 Metadata 扩展可能改变行为。
| 项目 | 源码快照 | 日期 | 重点范围 |
|---|---|---|---|
| Apache Calcite | e8e0dd5 | 2026-05-04 | VolcanoPlanner、RelSet/RelSubset、VolcanoCost、IterativeRuleDriver、TopDownRuleDriver、Trait Enforcement |
后文完整执行示例是在这个快照上实际编译运行得到的,计划、代价数字与任务 trace 都是程序真实输出,不是手工推演。但要强调:Rule 注册集合、Program 编排、Adapter 提供了哪些 Metadata,最终都由集成方决定;本文描述的是"用 Programs.standard() + Enumerable Convention 跑这个快照"时的行为。
findBestExp() 的生命周期
候选注册时已开始维护成本状态,findBestExp() 负责驱动后续搜索并提取结果。调用链应按“注册与更新—驱动搜索—提取 Winner”理解:
1setRoot(rel)
2 │ registerImpl(rel, null) 递归注册整棵树
3 │ ├─ RelDigest 去重(mapDigestToRel)
4 │ ├─ 建 RelSet(逻辑等价类)
5 │ └─ 建 RelSubset(RelSet × RelTraitSet)
6 ▼
7findBestExp()
8 │
9 ├─① ensureRootConverters()
10 │ 为与 root traitSet 只差 1 个 trait 的 subset 注册 AbstractConverter
11 │ 注意:这一步与 topDownOpt 无关,两种 driver 都会插
12 │
13 ├─② registerMaterializations() 物化视图候选入 Memo
14 │
15 ├─③ ruleDriver.drive() ★ 唯一的搜索循环
16 │ ├─ IterativeRuleDriver:裸 while,无剪枝 (默认)
17 │ └─ TopDownRuleDriver:Cascades 任务栈 + branch&bound(opt-in)
18 │
19 ├─④ dumpRuleAttemptsInfo()
20 │
21 └─⑤ root.buildCheapestPlan(this) ★ 提取搜索期间已维护的 best
22 CheapestPlanReplacer 沿 subset.best 链条把 Memo 还原成物理树
③ 负责触发规则、注册候选,并在注册与代价传播过程中维护 best;⑤ 沿 best 指针提取结果。默认 driver 不以成本上界停止某个搜索分支,但这与搜索期间是否比较候选成本是两回事。
ensureRootConverters() 有一个容易看错的细节:
1for (RelNode rel : root.getRels()) {
2 if (rel instanceof AbstractConverter && !topDownOpt) {
3 subsets.add((RelSubset) ((AbstractConverter) rel).getInput());
4 }
5}
6
7for (RelSubset subset : root.set.subsets) {
8 final ImmutableList<RelTrait> difference =
9 root.getTraitSet().difference(subset.getTraitSet());
10 // WHY MUST 1?
11 if (difference.size() == 1 && subsets.add(subset)) {
12 register(new AbstractConverter(subset.getCluster(), subset,
13 difference.get(0).getTraitDef(), root.getTraitSet()), root);
14 }
15}
!topDownOpt 只影响去重集合的预填充,第二个循环无条件执行。所以打开 topDownOpt 之后,根节点上依然会出现 AbstractConverter —— 完整执行示例中的真实 trace 第 10 行就是它。
代价状态:RelSubset 与 VolcanoCost
Memo 的代价账本:RelSubset 上到底记了什么
RelSet 是逻辑等价类,RelSubset 是 RelSet × RelTraitSet。所有代价状态都挂在 RelSubset 上:
| 字段 | 类型 | 语义 | 谁在写 |
|---|---|---|---|
best | @Nullable RelNode | 当前该 subset 下最便宜的物理表达式 | propagateCostImprovements |
bestCost | RelOptCost | best 的累计代价,初值 infCost | 同上 |
upperBound | RelOptCost | 搜索上界,初值 infCost | TopDownRuleDriver |
taskState | @Nullable OptimizeState | null / OPTIMIZING / COMPLETED | TopDownRuleDriver |
state | int 位掩码 | 1=DELIVERED、2=REQUIRED | setDelivered / setRequired |
enforceDisabled | boolean | 是否跳过为该 subset 生成 Enforcer | disableEnforcing() |
timestamp | int | 代价变更计数,用于 Metadata 缓存失效 | propagateCostImprovements |
其中 upperBound / taskState / state / enforceDisabled 只在 top-down 路径下有实际读者。默认 driver 跑完之后,taskState 一直是 null,upperBound 一直是 infCost。这正是结论 2 与 3 的结构性原因:账本上留好了剪枝用的字段,但默认没人去读。
累计代价 vs 自身代价
算子局部代价与子树累计代价必须分开,否则父节点会重复计算孩子工作量。源码提供两组接口:
RelMetadataQuery.getNonCumulativeCost(rel)—— 算子自身代价,由各computeSelfCost提供;VolcanoPlanner.getCost(rel, mq)—— 累计代价,递归求和。
1@Override public @Nullable RelOptCost getCost(RelNode rel, RelMetadataQuery mq) {
2 if (rel instanceof RelSubset) {
3 return ((RelSubset) rel).bestCost; // ① 遇到 subset 直接取账本
4 }
5 if (noneConventionHasInfiniteCost
6 && rel.getTraitSet().getTrait(ConventionTraitDef.INSTANCE) == Convention.NONE) {
7 return costFactory.makeInfiniteCost(); // ② 逻辑节点视为无穷贵
8 }
9 RelOptCost cost = mq.getNonCumulativeCost(rel);
10 if (cost == null) {
11 return null;
12 }
13 if (!zeroCost.isLt(cost)) {
14 // cost must be positive, so nudge it
15 cost = costFactory.makeTinyCost(); // ③ 非正代价抬到 TINY
16 }
17 for (RelNode input : rel.getInputs()) {
18 RelOptCost inputCost = getCost(input, mq);
19 if (inputCost == null) {
20 return null;
21 }
22 cost = cost.plus(inputCost);
23 }
24 return cost;
25}
三处细节都值得单独记住:
- ① 递归在
RelSubset处终止,取的是bestCost快照。这意味着累计代价是账本的函数,账本没更新时上层代价也不会变——所以才需要propagateCostImprovements主动推送。 - ② 逻辑 Convention 的节点代价为无穷(因为需要将算子转换成真正的物理算子)。这是 Volcano 逼迫搜索走到物理形态的机制,也是逻辑节点无法给出有意义 Lower Bound 的根源(见“上下界剪枝闭环”)。
- ③
zeroCost.isLt(cost)用的还是 rowCount-only 比较。所以一个(0 rows, 1e9 cpu, 0 io)的自身代价会被判定为"非正"并被抬成TINY = (1, 1, 0),cpu 那1e9直接消失。
代价改善如何向上传播
propagateCostImprovements 是一个以代价为优先级的堆遍历,方向自下而上:
1propagateRels.put(rel, getCostOrInfinite(rel, mq));
2propagateHeap.offer(rel);
3
4while ((relNode = propagateHeap.poll()) != null) {
5 RelOptCost cost = requireNonNull(propagateRels.get(relNode), ...);
6 for (RelSubset subset : getSubsetNonNull(relNode).set.subsets) {
7 if (!relNode.getTraitSet().satisfies(subset.getTraitSet())) {
8 continue; // trait 不满足,不能当这个 subset 的 best
9 }
10 if (relNode != subset.best && !cost.isLt(subset.bestCost)) {
11 continue; // 不更便宜,跳过
12 }
13 if (relNode == subset.best && cost.equals(subset.bestCost)) {
14 continue;
15 }
16 subset.timestamp++;
17 subset.bestCost = cost;
18 subset.best = relNode;
19 mq.clearCache(subset); // 账本变了,Metadata 缓存必须失效
20 for (RelNode parent : subset.getParents()) {
21 mq.clearCache(parent);
22 RelOptCost newCost = getCostOrInfinite(parent, mq);
23 ... // 更便宜则入堆,继续向上
24 }
25 }
26}
三个要点:
- 一个物理表达式会被尝试写进
RelSet里所有它能满足的 subset。relNode.getTraitSet().satisfies(subset.getTraitSet())就是 trait 侧的准入条件——它同时也是"为什么一个EnumerableMergeJoin.[[0],[3]]能同时充当.[0]subset 的 best"的解释。 - 注释里坦白承认代价可能变大。 源码原文提到
JdbcAdapterTest#testVolcanoPlannerInternalValid观察到代价上升的情形,因此relNode == subset.best时无条件更新而非只接受下降。所以bestCost不是严格单调递减的。 mq.clearCache出现两次,subset 与每个 parent 各一次。Metadata 缓存与代价账本的一致性靠这里手工维护。
计划提取:buildCheapestPlan
计划提取不会重新比较候选,而是将每个 subset 替换为搜索期间已维护的 best。如果这个指针缺失,说明目标属性下没有可提取的实现。
1RelNode buildCheapestPlan(VolcanoPlanner planner) {
2 CheapestPlanReplacer replacer = new CheapestPlanReplacer(planner);
3 final RelNode cheapest = replacer.visit(this, -1, null);
4 ...
5 return cheapest;
6}
CheapestPlanReplacer 从 root subset 出发,每遇到 RelSubset 就换成它的 best,递归到底。整个"选最优计划"的动作就是沿 best 指针走一遍——没有任何搜索、没有任何比较。
如果某个 subset 的 best 是 null,就走到 DeadEndFinder + traitDiff(),产出那条大家都很熟的 CannotPlanException(“There are not enough rules to produce a node with desired properties”)。换个视角看,这条异常的真实含义是:账本上有一格始终没被填上。
代价模型:VolcanoCost 只比 rowCount
VolcanoPlanner 的默认代价工厂就是 VolcanoCost.FACTORY(VolcanoPlanner:234):
1super(costFactory == null ? VolcanoCost.FACTORY : costFactory, ...);
它持有 (rowCount, cpu, io) 三元组,plus / minus / multiplyBy 都是逐维运算。但比较不是:
1@Override public boolean isLe(RelOptCost other) {
2 VolcanoCost that = (VolcanoCost) other;
3 if (true) {
4 return this == that || this.rowCount <= that.rowCount;
5 }
6 return (this == that)
7 || (this.rowCount <= that.rowCount
8 && this.cpu <= that.cpu
9 && this.io <= that.io); // 死代码
10}
11
12@Override public boolean isLt(RelOptCost other) {
13 if (true) {
14 VolcanoCost that = (VolcanoCost) other;
15 return this.rowCount < that.rowCount;
16 }
17 return isLe(other) && !equals(other); // 死代码
18}
if (true) 是源码里真实存在的写法,不是我为了说明而简化的。它的后果贯穿全文:
- cpu / io 是"会计科目"而不是"裁判依据"。 它们被
plus累加、被RelWriter打印进cumulative cost = {... rows, ... cpu, ... io},看起来很详细,但任何两个计划的胜负只由 rowCount 决定。 - rowCount 相同即为平局,胜负由搜索顺序决定。 完整执行示例正好命中这一点:两种 driver 选出的 Merge Join 左右孩子完全相反,而累计代价一字不差。
minus在非 rowCount 维度上算出负数不会被发现。 结论 6 的那条 trace 就是这样产生的。- 想要真正的多维代价,必须自带
RelOptCostFactory。 这也是 Flink、Hive 等集成方普遍自定义代价的原因之一——不只是权重不合适,而是默认实现根本不比较除 rowCount 之外的维度。
几个常量也值得记住:ZERO = (0,0,0)、TINY = (1,1,0)、HUGE、INFINITY。AbstractConverter.computeSelfCost 返回 makeInfiniteCost(),后文 Trait Enforcement 一节会沿该占位符继续追踪。
搜索调度:两种 Driver 与上下界
两种 RuleDriver:默认路径不按成本上界剪枝
driver 在 initRuleQueue() 里二选一,开关是 VolcanoPlanner.topDownOpt:
1CalciteSystemProperty.TOPDOWN_OPT
2 = booleanProperty("calcite.planner.topdown.opt", false) ★ 默认 false
3 │
4 ├─ CalciteConnectionProperty.TOPDOWN_OPT("topDownOpt", ...)
5 │ └─ CalcitePrepareImpl:459
6 │ planner.setTopDownOpt(prepareContext.config().topDownOpt())
7 │
8 └─ PlannerImpl:184
9 new VolcanoPlanner(costFactory, context) ← 没有 setTopDownOpt
10 (只能靠系统属性生效)
也就是说:走 JDBC 时可以用连接参数 topDownOpt=true 打开;走 Frameworks / PlannerImpl 时只能靠 -Dcalcite.planner.topdown.opt=true。两条路的默认值都是 false。
IterativeRuleDriver:84 行,没有成本上界剪枝
默认 driver 不根据当前候选成本截断枚举,但仍依赖候选注册时的代价传播维护最优解。以下循环应与RelSubset 一节的更新路径一起阅读。
1@Override public void drive() {
2 while (true) {
3 LOGGER.debug("Best cost before rule match: {}", planner.root.bestCost);
4 VolcanoRuleMatch match = ruleQueue.popMatch();
5 if (match == null) {
6 break;
7 }
8 try {
9 match.onMatch();
10 } catch (VolcanoTimeoutException e) {
11 planner.canonize();
12 break;
13 }
14 planner.canonize();
15 }
16}
17
18@Override public void onProduce(RelNode rel, RelSubset subset) { }
19@Override public void onSetMerged(RelSet set) { }
循环与队列共同限定了默认路径的搜索行为:
- 循环条件里没有代价。
planner.root.bestCost只被打进 debug 日志,从不参与break判断。唯一的提前退出是VolcanoTimeoutException。 - 两个回调都是空实现。
onProduce/onSetMerged是 driver 接口留给增量维护的钩子,iterative 路径完全不需要。 - 队列是 FIFO。
IterativeRuleQueue的MatchList只分preQueue(SubstitutionRule)与queue(其余),注释直说 “The rules are not sorted in any way”。早期 Calcite 的 importance 启发式(按 subset 重要性排序规则匹配)已不复存在。
结论:默认路径是穷举式的自底向上枚举。它靠 RelDigest 去重和 RuleQueue.skipMatch()(prunedNodes + 重复 subset 环检测)控制规模,通过 Convention.NONE 的无穷代价排除逻辑计划;只有生成了满足根属性且成本有限的物理候选,才能成功提取结果,但它不会因为"这个分支已经比现有最优贵"而放弃搜索。
TopDownRuleDriver:11 类任务的 Cascades 栈
Top-down driver 将搜索状态显式分解为任务,才能在优化孩子时传递剩余上界并在返回后继续父任务。drive() 从压入根任务开始:
1OptimizeGroup(planner.root, planner.infCost)
任务类型与职责:
| 任务 | 职责 |
|---|---|
OptimizeGroup | 优化一个 subset;已有 winner 直接返回 |
GroupOptimized | 标记 taskState = COMPLETED |
OptimizeMExpr | 优化单个物理/逻辑表达式,分 explore / implement 两种模式 |
ExploreInput | 只对孩子做逻辑变换(不 implement) |
EnsureGroupExplored | 保证某个 input group 已被探索完 |
ApplyRules | 批量触发规则,explore 时过滤为 transformation rule |
ApplyRule | 执行单条 VolcanoRuleMatch |
OptimizeInput1 | 单孩子算子的孩子优化 |
OptimizeInputs | 多孩子算子,有状态,逐孩子收紧上界 |
CheckInput | 检查某个孩子是否拿到 winner,收紧或放弃 |
DeriveTrait | 按 DeriveMode 从孩子已交付 trait 上拉 |
两个设计细节:
Enforcer 被降优先级。 OptimizeGroup.perform() 里:
1List<RelNode> physicals = new ArrayList<>();
2for (RelNode rel : group.set.rels) {
3 if (planner.isLogical(rel)) {
4 tasks.push(new OptimizeMExpr(rel, group, false));
5 } else if (rel.isEnforcer()) {
6 // Enforcers have lower priority than other physical nodes.
7 physicals.add(0, rel);
8 } else {
9 physicals.add(rel);
10 }
11}
12// Always apply O_INPUTS first so as to get a valid upper bound.
13for (RelNode rel : physicals) {
14 ...
15}
add(0, rel) 看起来像是提高优先级,实际相反:physicals 随后被顺序压入 tasks 栈,索引 0 的元素最先入栈、最后出栈。这与 TopDownRuleQueue 是同一种"写法与语义相反"的栈序技巧。
降优先级的动机就在第二条注释里——先优化真实物理算子的孩子,才能拿到一个有限上界;若让无穷代价的 Enforcer 先跑,上界永远是 inf,剪枝无从下手。
规则栈序被刻意反转。 TopDownRuleQueue 为每个 RelNode 维护一个 Deque:
1if (!planner.isSubstituteRule(match)) {
2 queue.addFirst(match);
3} else {
4 queue.addLast(match);
5}
因为消费端按栈弹出,addLast 的 substitution rule 反而先执行。这是"先做等价替换再做实现选择"的语义,写法上却是反的。
ApplyRules 的规则过滤同样体现两阶段:
1exploring ? planner::isTransformationRule : m -> true
explore 阶段只跑变换规则,implement 阶段放开全部。
上下界剪枝闭环(仅 top-down 有效)
分支限界(Branch-and-Bound)依赖可行计划上界与未完成子计划的安全下界;二者不是同一种估计。Top-down 在任务状态中显式维护这些信息。
Winner 的严格定义
存在 best 不代表本轮搜索已在当前上界下完成;getWinnerCost() 同时检查任务完成状态和上界约束。
1// RelSubset
2public @Nullable RelOptCost getWinnerCost() {
3 if (taskState == OptimizeState.COMPLETED && bestCost.isLe(upperBound)) {
4 return bestCost;
5 }
6 return null;
7}
两个条件都必须成立:任务已完成,且 bestCost 不超过上界。所以"有 best"不等于"有 winner"。默认 driver 下 taskState 恒为 null,getWinnerCost() 恒返回 null——这也解释了为什么剪枝逻辑在默认路径上等于不存在。
Lower Bound 对逻辑节点直接返回 null
RelMdLowerBoundCost 全文很短:
1public @Nullable RelOptCost getLowerBoundCost(
2 RelSubset subset, RelMetadataQuery mq, VolcanoPlanner planner) {
3 if (planner.isLogical(subset)) {
4 return null; // "currently only support physical"
5 }
6 return subset.getWinnerCost();
7}
8
9public @Nullable RelOptCost getLowerBoundCost(
10 RelNode node, RelMetadataQuery mq, VolcanoPlanner planner) {
11 if (planner.isLogical(node)) {
12 return null;
13 }
14 RelOptCost selfCost = mq.getNonCumulativeCost(node);
15 if (selfCost != null && selfCost.isInfinite()) {
16 selfCost = null; // 无穷自身代价(如 AbstractConverter)不计入下界
17 }
18 for (RelNode input : node.getInputs()) {
19 RelOptCost lb = mq.getLowerBoundCost(input, planner);
20 if (lb != null) {
21 selfCost = selfCost == null ? lb : selfCost.plus(lb);
22 }
23 }
24 return selfCost;
25}
isLogical(rel) 的判定是 !(rel instanceof PhysicalNode) && rel.getConvention() != rootConvention。含义:在物理候选出现之前,下界一律不可用。这类逻辑节点无法提供该 Metadata 下界;不能把它扩大为所有搜索状态在早期都无法剪枝。有效的物理候选和已有上界仍决定后续剪枝机会。这是 Calcite 与"从一开始就能用统计下界剪枝"的理想 Cascades 之间最实际的差距。
逐孩子收紧上界
VolcanoPlanner.upperBoundForInputs 先扣掉父节点自身代价:
1protected RelOptCost upperBoundForInputs(RelNode mExpr, RelOptCost upperBound) {
2 if (!upperBound.isInfinite()) {
3 RelOptCost rootCost = mExpr.getCluster().getMetadataQuery().getNonCumulativeCost(mExpr);
4 if (rootCost != null && !rootCost.isInfinite()) {
5 return upperBound.minus(rootCost);
6 }
7 }
8 return upperBound;
9}
OptimizeInputs 再按源码里写明的公式分给当前孩子:
1// UB(one input) = UB(current subset) - Parent's NonCumulativeCost - LB(other inputs)
2upper = upperForInput.minus(lowerBoundSum).plus(lowerBounds.get(processingChild));
CheckInput 负责收尾:孩子拿到 winner 就用真实代价替换它原先的下界估计(进一步收紧),孩子没有 winner 就把 lowerBoundSum 置为 planner.infCost,等价于放弃这个物理表达式的所有兄弟孩子,不再浪费搜索。
这套减法需要与代价的排序关系一致。执行示例记录的负 cpu 展示了默认多维账本与单维排序之间的差异;若改用多维排序,需要重新验证上界运算,而非直接沿用。
属性协议:Trait 的需求、交付与补偿
Trait Enforcement:满足、交付、需求与两种 Enforcer
属性需求不等于已存在满足它的物理实现,转换机制必须将缺口变成可执行候选。Calcite 区分 subset 的需求与交付状态,并按 driver 选择占位转换或直接补偿。
satisfies 是逐位比较,长度取 min
Trait 满足关系由各维度自身定义,且该快照只比较两个集合共有的位置。集成方应保持 TraitDef 注册和集合结构一致,不能把较短集合当作完整的属性证明。
1public boolean satisfies(RelTraitSet that) {
2 if (this == that) {
3 return true;
4 }
5 final int n = Math.min(this.size(), that.size());
6 for (int i = 0; i < n; i++) {
7 if (!this.traits[i].satisfies(that.traits[i])) {
8 return false;
9 }
10 }
11 return true;
12}
Math.min 这一步含义很强:较短的 traitSet 里没提到的维度视为自动满足。配套的 simplify() 会把 RelCompositeTrait 折叠成单 trait(只剩一个候选时),所以比较之前通常要先 simplify,否则复合 trait 与单 trait 之间的 satisfies 结果会不符合直觉。
对排序而言,RelCollationTraitDef 的三个属性决定了行为:multiple() 返回 true(同一节点可携带多个 collation)、getDefault() 是 RelCollations.EMPTY、canConvert() 恒为 true(任何 collation 都可以通过加 Sort 达成)。开关 calcite.enable.collation.trait 默认 true。
DELIVERED / REQUIRED:一个 subset 的两种身份
RelSubset 用两个 bit 区分"我能提供这个 trait"与"有人要求这个 trait":
1private int state = 0; // 0 逻辑, 1 DELIVERED, 2 REQUIRED, 3 both
2
3void setDelivered() { triggerRule = !isDelivered(); state |= DELIVERED; }
4void setRequired() { triggerRule = false; state |= REQUIRED; }
5void disableEnforcing() { assert isDelivered(); enforceDisabled = true; }
triggerRule 的赋值差异是关键:只有"新交付"会打开规则触发,“被需求"不会。因为需求本身不产生新候选,只是给搜索定目标。
disableEnforcing() 解决的是一个具体的浪费:当 DeriveTrait 从孩子交付的 trait 推出一个新需求,而这个需求恰好落回孩子自己所在的 subset(newInput == subset)时,再为它生成 Enforcer 毫无意义——孩子已经天然满足。此时直接关掉。
唯一的分叉点:RelSet.addConverters
RelSet.addConverters 根据搜索模式选择抽象占位符或具体 Enforcer;分支参数直接取 !planner.topDownOpt,因此两种 Driver 不只是任务执行顺序不同:
1if (needsConverter) {
2 addConverters(subset, required, !planner.topDownOpt);
3}
第三个参数叫 useAbstractConverter,值就是 !topDownOpt。方法体里:
1if (useAbstractConverter) {
2 enforcer = new AbstractConverter(cluster, from, null, to.getTraitSet());
3} else {
4 enforcer = convention.enforce(from, to.getTraitSet());
5}
6if (enforcer != null) {
7 cluster.getPlanner().register(enforcer, to);
8}
于是两条路径的 Enforcer 完全不同:
| 自底向上(默认) | 自顶向下(opt-in) | |
|---|---|---|
| Enforcer | AbstractConverter | Convention.enforce() 的返回值 |
| 自身代价 | makeInfiniteCost() | 真实物理代价 |
| 是否可执行 | 否,纯占位符 | 是 |
| 谁把它变成真算子 | ExpandConversionRule | 已经是真算子 |
AbstractConverter 的两个方法解释了它的定位:
1@Override public @Nullable RelOptCost computeSelfCost(RelOptPlanner planner, RelMetadataQuery mq) {
2 return planner.getCostFactory().makeInfiniteCost();
3}
4@Override public boolean isEnforcer() { return true; }
无穷代价意味着它永远不可能被 buildCheapestPlan 选中,所以必须被替换。替换者是它的内嵌规则:
1@Override public void onMatch(RelOptRuleCall call) {
2 final VolcanoPlanner planner = (VolcanoPlanner) call.getPlanner();
3 AbstractConverter converter = call.rel(0);
4 final RelNode child = converter.getInput();
5 RelNode converted = planner.changeTraitsUsingConverters(child, converter.traitSet);
6 if (converted != null) {
7 call.transformTo(converted);
8 }
9}
类注释还给出了一条自我约束:“AbstractConverters can be messy, so they restrain themselves: they don’t fire if the target subset already has an implementation (with less than infinite cost).”
对照 top-down 侧,EnumerableConvention.enforce() 一次做完 convention 转换与排序:
1@Override public RelNode enforce(final RelNode input, final RelTraitSet required) {
2 RelNode rel = input;
3 if (input.getConvention() != INSTANCE) {
4 rel = ConventionTraitDef.INSTANCE.convert(
5 input.getCluster().getPlanner(), input, INSTANCE, true);
6 requireNonNull(rel, ...);
7 }
8 RelCollation collation = required.getCollation();
9 if (collation != null && collation != RelCollations.EMPTY) {
10 rel = EnumerableSort.create(rel, collation, null, null);
11 }
12 return rel;
13}
14
15@Override public boolean canConvertConvention(Convention toConvention) { return false; }
16@Override public boolean useAbstractConvertersForConversion(RelTraitSet f, RelTraitSet t) { return true; }
排序 Enforcer 就是一个 EnumerableSort,直接带真实代价进入竞争——这才是 Cascades 语义下的 Enforcer。
Top-down 的属性传导:passThrough 与 derive
Calcite 里没有 RequiredPropertyDeriver / OutputPropertyDeriver / ChildOutputPropertyGuarantor 这样的集中式组件。等价能力放在 PhysicalNode 接口的两组方法上,由每个物理算子自己实现:
1父需求 ──▶ passThrough(required) / passThroughTraits(required)
2 把父要求翻译成孩子要求(能满足则返回带 trait 的自己,否则 null)
3
4孩子交付 ──▶ derive(childTraits, childId) / deriveTraits(...)
5 从孩子已交付的 trait 推出自己能交付什么
derive 的搜索范围由 DeriveMode 控制:
| DeriveMode | 语义 |
|---|---|
LEFT_FIRST | 只从左孩子推 |
RIGHT_FIRST | 只从右孩子推 |
BOTH | 左右各推一轮 |
OMAKASE | 一次拿到所有孩子的 trait 组合自行决定 |
PROHIBITED | 不参与 derive |
EnumerableMergeJoin.getDeriveMode() 返回 BOTH,这正是执行示例里 depts 能被换到左侧的原因之一。
EnumerableMergeJoin 的两个方法很能说明这套机制的粒度。passThroughTraits 按父要求与左键的关系分三种情况处理(父要求恰好等于左键集合时原样下压并映射右侧 collation;是左键前缀子集时用 extendCollation 补齐;否则放弃)。deriveTraits 则做严格校验:
1if (colCount < keyCount || keyCount == 0) {
2 return null; // 孩子 collation 不够长,或没有 join key
3}
4... // 截断到 keyCount
5if (!childCollationKeys.equals(keySet)) {
6 return null; // 前 keyCount 列必须正好是 join key
7}
EnumerableMergeJoinRule 则负责准入与 collation 构造:拒绝 IS NOT DISTINCT FROM、拒绝不支持的 join 类型与笛卡尔积,然后为每个输入按 join key 构造 ASCENDING / NULLS LAST 的 collation,用 convert(ord.e, traits) 提出需求,最后把 join 自身的 traitSet .replace(collations)。
PhysicalNode 的类注释本身就是一份"如何让自己的算子支持 top-down trait 传导"的六步清单,自定义 Convention 时值得逐条对照。
一个具体例子的完整执行流程
同一 SQL 在不同 driver 下可能得到不同形状的同成本计划,因此应同时对照输出属性、累计代价与搜索顺序。下文保留原稿记录的单次 Trace,不把任务计数外推为普遍性能结论。
复现方式
Schema 用 ReflectiveSchema 包一个 POJO:emps 10000 行(deptno = i % 10),depts 10 行。SQL:
1SELECT e.deptno, e.ename, d.dname
2FROM emps e JOIN depts d ON e.deptno = d.deptno
3ORDER BY e.deptno;
配置 Frameworks.newConfigBuilder().programs(Programs.standard()),目标 traitSet 为 ENUMERABLE + root collation。任务 trace 的开关是一个专用 logger:
1# CalciteTrace.getPlannerTaskTracer()
2log4j.logger.org.apache.calcite.plan.volcano.task=DEBUG
driver 用 -Dcalcite.planner.topdown.opt=true|false 切换。下面所有计划、代价和 trace 都是这个程序的真实输出。
逻辑计划(两种 driver 相同)
两种 driver 接收相同的 Join 与根排序需求,后续物理树差异来自搜索和属性实现,而不是 SQL 输入不同。
1LogicalSort(sort0=[$0], dir0=[ASC])
2 LogicalProject(deptno=[$2], ename=[$1], dname=[$4])
3 LogicalJoin(condition=[=($2, $3)], joinType=[inner])
4 LogicalTableScan(table=[[hr, emps]])
5 LogicalTableScan(table=[[hr, depts]])
默认 driver(topDownOpt=false)的物理计划
这次默认路径选择了两侧排序后的 Merge Join,并用其输出排序满足根需求。该结果依赖本例规则和 Metadata,不代表默认总会优先 Merge Join。
1EnumerableCalc(deptno=[$t1], ename=[$t0], dname=[$t3])
2 : cumulative cost = {70030.0 rows, 2007540.5914138353 cpu, 0.0 io}
3 EnumerableMergeJoin(condition=[=($1, $2)], joinType=[inner])
4 : cumulative cost = {55030.0 rows, 1902540.5914138353 cpu, 0.0 io}
5 EnumerableSort(sort0=[$1], dir0=[ASC])
6 : cumulative cost = {30000.0 rows, 1902069.0743952366 cpu, 0.0 io}
7 EnumerableCalc(ename=[$t1], deptno=[$t2])
8 : cumulative cost = {20000.0 rows, 60001.0 cpu, 0.0 io}
9 EnumerableTableScan(table=[[hr, emps]])
10 : cumulative cost = {10000.0 rows, 10001.0 cpu, 0.0 io}
11 EnumerableSort(sort0=[$0], dir0=[ASC])
12 : cumulative cost = {20.0 rows, 471.5170185988092 cpu, 0.0 io}
13 EnumerableTableScan(table=[[hr, depts]])
14 : cumulative cost = {10.0 rows, 11.0 cpu, 0.0 io}
这棵计划体现了两种属性处理:
- 顶层没有 Sort。
ORDER BY e.deptno被 Merge Join 输出的 collation 直接满足了,这里直接复用了 Merge Join 的输出排序属性,无需额外的根 Sort;该结果由规则产生候选和属性推导共同支持。 - 两个输入各有一个
EnumerableSort。 它们是EnumerableMergeJoinRule通过convert(ord.e, traits)提出 collation 需求后,由 Enforcer 机制补上的。
Top-down driver(topDownOpt=true)的物理计划
本次 Top-down 路径选择了相反的 Join 输入顺序,但累计成本未变。对这一平局只能解释搜索结果,不能据此推断某个成本维度是否参与比较。
1EnumerableCalc(deptno=[$t3], ename=[$t2], dname=[$t1])
2 : cumulative cost = {70030.0 rows, 2007540.5914138353 cpu, 0.0 io}
3 EnumerableMergeJoin(condition=[=($0, $3)], joinType=[inner])
4 : cumulative cost = {55030.0 rows, 1902540.5914138353 cpu, 0.0 io}
5 EnumerableSort(sort0=[$0], dir0=[ASC]) ← depts 在左
6 : cumulative cost = {20.0 rows, 471.5170185988092 cpu, 0.0 io}
7 EnumerableTableScan(table=[[hr, depts]])
8 EnumerableSort(sort0=[$1], dir0=[ASC]) ← emps 在右
9 : cumulative cost = {30000.0 rows, 1902069.0743952366 cpu, 0.0 io}
10 EnumerableCalc(ename=[$t1], deptno=[$t2])
11 EnumerableTableScan(table=[[hr, emps]])
两个方向的累计代价相同(70030.0 / 55030.0,cpu 与 io 也相同),因此该例说明等价成本候选可能因搜索顺序而保留不同计划,但不能单独证明比较器只看 rowCount。即使同时比较 cpu 和 io,这组数值仍然相同;rowCount-only 结论应以 VolcanoCost.isLe / isLt 的源码为依据。
Top-down 任务时序
Top-down 搜索通过显式任务区分 Group 优化、逻辑探索与物理输入优化,属性条件决定哪些任务继续展开。这份 Trace 共记录 410 个 Execute task,下面按阶段摘取关键行;任务次数不代表各阶段的 CPU 耗时:
阶段 ①:根组与根 Enforcer
1Execute task: OptimizeGroup(group=rel#41:RelSubset#5.ENUMERABLE.[0], upperBound={inf})
2Execute task: OptimizeInput1(mExpr=rel#42:AbstractConverter.ENUMERABLE.[0](
3 input=RelSubset#36,convention=ENUMERABLE,sort=[0]), upperBound={inf})
4Execute task: OptimizeGroup(group=rel#36:RelSubset#5.NONE.[0], upperBound={inf})
5Skip optimizing because of traits: rel#42:AbstractConverter.ENUMERABLE.[0](...)
第 2 行就是搜索入口中提到的现象:打开 topDownOpt 之后根节点依然有 AbstractConverter,来自 ensureRootConverters() 那个无条件的第二重循环。第 4 行的 “Skip optimizing because of traits” 说明它被识别为 Enforcer 后跳过了常规优化路径。
阶段 ②:explore(只跑变换规则)
1Execute task: OptimizeMExpr(mExpr=rel#33:LogicalProject.NONE.[](...), explore=true)
2Execute task: ExploreInput(group=rel#32:RelSubset#3.NONE.[])
3Execute task: OptimizeMExpr(mExpr=rel#31:LogicalJoin.NONE.[](...), explore=true)
4Execute task: ExploreInput(group=rel#29:RelSubset#1.NONE.[])
5Execute task: OptimizeMExpr(mExpr=rel#10:LogicalTableScan.NONE.[](table=[hr, emps]), explore=true)
6Execute task: ApplyRules(mExpr=rel#10:LogicalTableScan.NONE.[](...), exploring=true)
7Execute task: EnsureGroupExplored(mExpr=rel#28:LogicalProject.NONE.[](...), i=0)
8...
9Execute task: ApplyRules(mExpr=rel#31:LogicalJoin.NONE.[](...), exploring=true)
自顶向下深入到叶子,再靠 EnsureGroupExplored 逐个孩子回收。这一阶段命中 JoinConditionPushRule、JoinCommuteRule、ProjectMergeRule、JoinPushExpressionsRule。
阶段 ③:implement 与孩子优化
1Execute task: OptimizeInputs(mExpr=rel#74:EnumerableHashJoin.ENUMERABLE.[](
2 left=RelSubset#72,right=RelSubset#73,condition==($0, $3),joinType=inner),
3 upperBound={inf}, processingChild=0)
4...
5Execute task: OptimizeInputs(mExpr=rel#74:EnumerableHashJoin..., upperBound={inf}, processingChild=1)
Hash Join 先被实现出来,两个孩子在 upperBound={inf} 下依次优化——这时还没有任何有限上界可用。
阶段 ④:上界变为有限,剪枝开始生效
1Execute task: OptimizeGroup(group=rel#87:RelSubset#2.ENUMERABLE.[0],
2 upperBound={20023.02585092994 rows, 30012.0 cpu, 0.0 io})
3Execute task: OptimizeInputs(mExpr=rel#91:EnumerableMergeJoin.ENUMERABLE.[[0], [3]](
4 left=RelSubset#87,right=RelSubset#89,condition==($0, $3),joinType=inner),
5 upperBound={45033.02585092994 rows, 30012.0 cpu, 0.0 io}, processingChild=1)
6Execute task: OptimizeGroup(group=rel#89:RelSubset#1.ENUMERABLE.[1],
7 upperBound={20003.02585092994 rows, 29540.48298140119 cpu, 0.0 io})
Merge Join 的第 0 号孩子解出 winner 后,第 1 号孩子拿到的上界从 inf 收紧为 45033.02 rows —— 第 7.3 节那个公式的真实运行结果。
再往后就出现了结论 6 的证据:
1Execute task: OptimizeGroup(group=rel#98:RelSubset#3.ENUMERABLE.[2],
2 upperBound={30044.02585092994 rows, -29527.48298140119 cpu, 0.0 io})
3Execute task: OptimizeGroup(group=rel#58:RelSubset#6.ENUMERABLE.[],
4 upperBound={30044.02585092994 rows, -4008165.7846168266 cpu, 0.0 io})
cpu 分量已经是负数(−29527、−4008165)。因为 upperBound.minus(...) 逐维相减,而 cpu 维的下界估计与实际扣减并不自洽。这没有触发任何断言或异常——bestCost.isLe(upperBound) 只看 rowCount,负 cpu 对判定毫无影响。把 VolcanoCost 换成一个真正比较三维的代价实现之前,这类数值是无害的;换之后,它就是一个必须先修的前提。
阶段 ⑤:DeriveTrait 上拉与收尾
1Execute task: DeriveTrait(mExpr=rel#95:EnumerableMergeJoin.ENUMERABLE.[0](...),
2 group=rel#96:RelSubset#6.ENUMERABLE.[0])
3Execute task: DeriveTrait(mExpr=rel#102:EnumerableHashJoin.ENUMERABLE.[0](
4 left=RelSubset#87,right=RelSubset#73,...), group=rel#96:RelSubset#6.ENUMERABLE.[0])
5Execute task: GroupOptimized(group=rel#96:RelSubset#6.ENUMERABLE.[0],
6 upperBound={45044.02585092994 rows, 30472.51701859881 cpu, 0.0 io})
7...
8Execute task: CheckInput(parent=rel#42:AbstractConverter.ENUMERABLE.[0](input=RelSubset#55,...), i=0)
9Execute task: ApplyRule(match=rule [ExpandConversionRule] rels [#42], exploring=false)
10...
11Execute task: GroupOptimized(group=rel#41:RelSubset#5.ENUMERABLE.[0], upperBound={inf})
最后一行闭合根 subset,drive() 返回,buildCheapestPlan() 沿 best 链条抽出 8.4 的计划。注意 ExpandConversionRule 在 top-down 路径下依然被触发了一次——就是为了展开 ensureRootConverters() 插入的那个根 AbstractConverter。
这一轮触发的规则分布
任务次数与规则执行次数衡量的是不同事件,不能按数量直接换算耗时。以下计数仅对应这一轮 Trace。
1 8 EnumerableLimitRule
2 7 EnumerableProjectRule(in:NONE,out:ENUMERABLE)
3 4 ProjectMergeRule
4 2 SortProjectTransposeRule
5 2 JoinPushExpressionsRule
6 2 JoinConditionPushRule
7 2 JoinCommuteRule
8 2 EnumerableTableScanRule(in:NONE,out:ENUMERABLE)
9 2 EnumerableMergeJoinRule(in:NONE,out:ENUMERABLE)
10 2 EnumerableJoinRule(in:NONE,out:ENUMERABLE)
11 2 EnumerableInterpreterRule(in:BINDABLE,out:ENUMERABLE)
12 2 BindableTableScanRule
13 1 SortRemoveRule
14 1 SortRemoveConstantKeysRule
15 1 ExpandConversionRule
16 1 EnumerableSortRule(in:NONE,out:ENUMERABLE)
410 个任务只对应 41 次 ApplyRule。绝大部分任务是调度与状态管理(OptimizeGroup / OptimizeMExpr / ExploreInput / CheckInput / DeriveTrait)。这个比例只反映任务数量分布,不能证明 CPU 时间主要消耗在状态维护上:一次规则匹配和一次状态检查的耗时可能不同,性能归因还需要采样或任务计时。
同一条 SQL 在默认 driver 下不产生任何任务日志——它没有任务概念。
关于 ExpandConversionRule 的最小验证
CollationConversionTest 用最小计划验证抽象转换器到真实 Sort 的转换链,隔离了复杂 Join 搜索的干扰。它显式 planner.setTopDownOpt(false)、注册 ExpandConversionRule,并让自定义的 TestRelCollationTraitDef.convert() 返回一个 PhysicalSort,最终断言:
1RootSingleRel(ENUMERABLE-like phys, ROOT_COLLATION)
2 PhysicalSort
3 LeafRel(LEAF_COLLATION)
这正是 5.3 节那张表左列的最小可运行形态。
与 StarRocks 的对照
Calcite 与 StarRocks 都按物理属性区分子问题,但执行器耦合程度决定了默认代价与补偿职责的不同。结合《深入 StarRocks Optimizer:Enforcer、Property 级联与 Cost 选型》,可以对照以下实现边界:
| 维度 | Apache Calcite | StarRocks |
|---|---|---|
| 搜索子目标 | RelSubset = RelSet × RelTraitSet | Group.lowestCostExpressions 按 PhysicalPropertySet 分桶 |
| 代价比较 | VolcanoCost 只比 rowCount | realCost = 0.5·cpu + 2·mem + 1.5·net |
| 剪枝 | 默认 driver 无剪枝;上下界仅 top-down 且逻辑节点无下界 | EnforceAndCostTask 内在的 Upper Bound 收紧 |
| Property 推导 | 分散在 PhysicalNode.passThrough* / derive* + DELIVERED/REQUIRED bit | 集中的 Required/Output Deriver + Guarantor 三件套 |
| Enforcer 代价 | 默认路径是无穷代价占位符,需二次展开 | Exchange/Sort 直接带真实代价入竞争 |
结构性的解释是定位不同。StarRocks 的优化器服务于一个已知的分布式执行器,分布属性是一等公民,Enforcer 就是 Exchange,代价公式可以针对自家执行器标定权重。Calcite 是要被 Flink、Hive、Druid 等各自不同的执行器复用的框架,它无法预设分布模型,也无法预设哪一维代价重要——其 trait 体系因此提供了扩展接口(RelTraitDef + multiple() + RelCompositeTrait)。默认代价仅按 rowCount 比较是该快照的事实;是否源于特定设计意图,不能只凭这段代码推断。
这也给集成 Calcite 的人一份具体的清单:
- 需要按 cpu / io 等维度排序时,自定义
RelOptCostFactory。 默认比较器不使用这些维度,额外估算它们不会改变isLe/isLt的排序。 - 明确决定是否打开
topDownOpt。 打开才有 branch-and-bound 与精确 trait 传导,但要求物理算子实现PhysicalNode(passThrough*/derive*/getDeriveMode),否则 top-down 的优势用不上。 - 自定义 Convention 时实现好
enforce()。 这是 top-down 路径下 Enforcer 的唯一来源;useAbstractConvertersForConversion与canConvertConvention的返回值决定了搜索的形状。 - 换代价实现前先审计
minus的使用。 8.5 节的负 cpu 说明现有上下界减法只在 rowCount 维上自洽。
集成时应先固定 Rule Set、Metadata、Convention 和 CostFactory,再比较两种 driver 的计划质量与规划耗时。Top-down 增加状态管理和属性接口实现成本,并非仅打开开关就能获得收益;默认 rowCount 排序也不适合直接表达网络、内存或真实执行延迟。没有工作负载测量时,本文只能说明搜索机制,不能判断哪种 driver 在目标系统中更快。
附:关键源码索引
核查计划选择应同时追踪代价更新与 driver 调度,而不是只看最终 Explain。下列索引固定到本文分析快照。
| 主题 | 文件 |
|---|---|
| 搜索入口与代价递归 | VolcanoPlanner.java |
| 代价模型(rowCount-only 比较) | VolcanoCost.java |
| 代价账本与状态位 | RelSubset.java |
Enforcer 分叉点 addConverters | RelSet.java |
| 默认 driver(无剪枝) | IterativeRuleDriver.java、IterativeRuleQueue.java |
| Cascades 任务栈 | TopDownRuleDriver.java、TopDownRuleQueue.java |
| 占位 Enforcer 与展开规则 | AbstractConverter.java |
| Trait 满足判定 | RelTraitSet.java |
| 排序 Trait 定义 | RelCollationTraitDef.java |
| 属性传导契约 | PhysicalNode.java、DeriveMode.java |
| Lower Bound Metadata | RelMdLowerBoundCost.java |
| 真实 Enforcer 生成 | EnumerableConvention.java |
| Merge Join 的 passThrough/derive | EnumerableMergeJoin.java、EnumerableMergeJoinRule.java |
| 开关与默认值 | CalciteSystemProperty.java、CalciteConnectionProperty.java |
| Enforcement 最小测试 | CollationConversionTest.java |