与 optd 对照(3–6 行)
本篇重点是生产路径上的 Enforcer / Property 级联 / 分发与聚合选型。教学骨架(Memo、OptimizeGroup、Winner)见 optd Cascades 与系列第 1–2 篇;下文默认读者已熟悉这些名词,只展开 StarRocks 在 Required Property 不满足时如何插入 Exchange/Sort 并计入 Cost。
在 Cascades 查询优化器(Query Optimizer)中,物理算子的成本必须连同满足输入要求的成本一起比较。父节点还会要求孩子提供特定的数据分布、排序或 CTE 属性;孩子已有的输出属性如果不能满足要求,优化器必须插入 Exchange 或 Sort,并把这些额外成本计入完整计划。只有把 Operator、Required Property、Enforcer 与 Cost 放在一起,所谓“最优物理计划”才有完整含义。
StarRocks 的优化器是一份很适合研究这一问题的生产级实现。它不只用 Memo / Group / GroupExpression 保存逻辑等价空间,还以 PhysicalPropertySet 为维度记录 Winner,通过显式任务栈完成需求下传、子计划优化、属性推导、Enforcer 插入和代价回传。与较精简的 Cascades 教学实现相比,这条链路更能解释分布式优化器为什么不能只保存“每个 Group 一个最佳计划”。
本文沿着 QueryOptimizer.optimizeByCost() 的生命周期,重点回答四个问题:
- StarRocks 如何调度 Cascades 搜索任务,并在规划超时前持续收紧搜索上界?
- Required/Output Property 如何在 Join、Aggregate 与 Scan 之间级联传导?
- Exchange 与 Sort Enforcer 在什么时机插入,又如何参与同一个 Cost 闭环?
- Join 实现、Join 分发、聚合阶段和 Sort Aggregate 分别在哪一层决策?
阅读基线与优化器生命周期
阅读范围与源码基线
StarRocks 用 PhysicalPropertySet 区分同一 Group 的搜索目标,并在候选计价时加入 Enforcer。理解这条主线之后,还需区分默认规则准入、Memo 内竞争和 CBO 后置改写:它们共同决定最终计划,不能全部归因于成本模型。
源码分析基线
默认规则和代价系数属于版本行为,以下结论仅覆盖该提交;不把源码系数解释为跨硬件通用的性能参数。
| 项目 | 源码快照 | 日期 | 重点范围 |
|---|---|---|---|
| StarRocks | 0fd27fd | 2026-03-30 | QueryOptimizer、Memo Task、Property、Enforcer、CostModel、Physical Rewrite |
本文结论对应这一确定源码快照。StarRocks 仍在快速演进,尤其 Join Implementation Rule 的默认注册集合、代价公式与后置物理规则可能继续变化,不应把本文描述外推为所有版本的固定行为。
优化器生命周期总览
逻辑改写、Memo 搜索与物理后处理分别拥有不同的决策权限,不能将最终计划中的所有变化都归因于 CBO。入口 QueryOptimizer.optimizeByCost() 组织以下六个阶段:
1SQL Statement
2 │ Analyzer / Transformer(AST → 逻辑算子树)
3 ▼
4┌──────────────────────────────────────────────────────────────┐
5│ ① logicalRuleRewrite(RBO) │
6│ 子查询解关联、谓词下推/推导、列裁剪、MV 文本改写、 │
7│ 分区裁剪、聚合下推、CTE inline、limit 合并……(数十组规则) │
8├──────────────────────────────────────────────────────────────┤
9│ ② memo.init + deriveAllGroupLogicalProperty │
10│ 逻辑树 → Memo/Group/GroupExpression 结构 │
11├──────────────────────────────────────────────────────────────┤
12│ ③ memoOptimize(CBO,Cascades 任务搜索)★ 本文核心 │
13│ 规则装载(join reorder 分级)→ 任务栈自顶向下搜索 │
14├──────────────────────────────────────────────────────────────┤
15│ ④ extractBestPlan(requiredProperty, rootGroup) │
16│ 沿 (property → bestExpr → inputProperties) 链递归提取 │
17├──────────────────────────────────────────────────────────────┤
18│ ⑤ physicalRuleRewrite(物理树后处理) │
19│ PhysicalDistributionAggOptRule(SortAgg/PerBucket)、 │
20│ ExchangeSortToMergeRule、PruneShuffleDistributionNode、 │
21│ 低基数字典编码(AddDecodeNode)、JoinLocalShuffle 等 │
22├──────────────────────────────────────────────────────────────┤
23│ ⑥ dynamicRewrite + PlanValidate → PlanFragmentBuilder │
24│ 物理树 → Fragment/Exchange,inputProperties 决定 join 分发 │
25└──────────────────────────────────────────────────────────────┘
关键设计取舍
前置规范化压缩搜索空间,也会放弃被不可逆改写掉的备选;StarRocks 将连接顺序(Join Order)等需要成本比较的选择保留在 Memo 中,并按 Join 数量控制搜索范围。
- RBO 前置、CBO 收窄:大部分确定性规范化(谓词下推(Predicate Pushdown)、列裁剪等)在进入 Memo 前完成,让搜索空间尽可能小;需要保留备选的 Join Reorder、物理实现、聚合阶段以及部分 MV 改写继续留在 Memo 中搜索。
requiredColumns全程携带:列裁剪需求放进TaskContext,贯穿整个搜索。- 规则分级装载(
QueryOptimizer.memoOptimize):- join 数 ≥
cbo_max_reorder_node→ 关闭 CBO reorder; - >
cbo_max_reorder_node_use_exhaustive→ 贪心ReorderJoinRule+ 只加交换律; - 小规模才加交换律 + 结合律穷举;
CboTablePruneRule仅当 join 数 < 10 时注册。
- join 数 ≥
搜索协议:任务、属性传导与 Enforcer
Cascades 任务调度框架
同一 Group 可以因父节点要求不同而被多次优化,任务上下文必须携带属性和代价上界。显式栈负责保存尚未完成的父任务,避免把孩子的未完成状态误当成失败。
数据结构
Group 保存按属性区分的最优结果,GroupExpression 保存具体候选及其输入属性组合;这种拆分使计划提取能够重建当时参与计价的子计划。
| 结构 | 职责 |
|---|---|
Memo | Group 集合 + 表达式去重(copyIn)+ enforcer 注册表(insertEnforceExpression) |
Group | 逻辑等价类;lowestCostExpressions: Map<PhysicalPropertySet, (cost, GroupExpression)> 每个 property 维度各记一个最优解;costLowerBounds 记代价下界;statistics |
GroupExpression | 一个算子 + 子 Group 指针;记录 (outputProperty → inputProperties)、(property, cost)、appliedRules 位图 |
TaskContext | requiredProperty + requiredColumns + upperBoundCost(代价上界,初始 MAX_VALUE) |
PhysicalPropertySet | Distribution + Sort + CTE 三元组 |
五类任务与调用链
TaskScheduler 用 Stack<OptimizerTask> 做自顶向下深度优先搜索,每弹一个任务调 checkTimeout() 防超时:
1OptimizeGroupTask(G, req)
2 ├─ 剪枝:costLowerBound(req) >= UB 或 hasBestExpression(req) → return
3 ├─ 每个逻辑表达式 → OptimizeExpressionTask (后压,后执行)
4 └─ 每个物理表达式 → EnforceAndCostTask (先压,先执行)
5 │
6 ├─ RequiredPropertyDeriver → 子节点候选需求组合
7 ├─ 子 Group 尚无 best expr?
8 │ → pushTask(clone()) ← 把自己挂起在栈里
9 │ → pushTask(OptimizeGroupTask(child, childReq, UB - curTotalCost))
10 │ → return(先优化子树) 【递归下降】
11 ├─ 子 Group 有 best expr → 累加代价;超上界即放弃
12 ├─ 全部子节点就绪 → ChildOutputPropertyGuarantor + OutputPropertyDeriver
13 └─ recordCostsAndEnforce → 登记 best / 不满足则插 enforcer
OptimizeExpressionTask:收集 transform + implement 规则,按rule.promise()排序(implement 规则 promise=2 > transform 规则 promise=1,物理实现先行以尽快建立代价上界);依次压入ApplyRuleTask × N、DeriveStatsTask、每个子 Group 的ExploreGroupTask。ApplyRuleTask:Binder 模式匹配 →rule.check→rule.transform→memo.copyIn去重;rule.exhausted()防死循环/耗时失控;新逻辑表达式压OptimizeExpressionTask,新物理表达式直接压EnforceAndCostTask。DeriveStatsTask:StatisticsCalculator推导统计,幂等(Idempotency)标记isStatsDerived。
“自顶向下"的准确含义
需求自顶向下传播,代价自底向上累积。EnforceAndCostTask 通过 clone() + 任务栈模拟协程挂起/恢复:栈即调用栈,curChildIndex / prevChildIndex / curPropertyPairIndex / curTotalCost 即栈帧。每次挂起时子任务拿到 UB - curTotalCost,代价上界沿树逐层收紧——先找到的可行解越好,后续分支被剪得越狠(Branch-and-Bound)。
Property 的生成与级联传导
子计划便宜并不足以构成合法父计划,两个输入还必须满足同一算子的分布或排序契约。需求推导、输出推导与兼容性修正分别处理这三个环节。
三个推导器
需求生成与输出验证分开,才能在孩子优化完成后判断是否仍需额外 Exchange。三个推导器的职责如下。
| 类 | 方向 | 职责 |
|---|---|---|
RequiredPropertyDeriver | 自顶向下 | 算子"对子节点的需求候选组合”(interesting properties) |
OutputPropertyDeriver | 自底向上 | 根据子输出 + 算子自身语义推导输出属性 |
ChildOutputPropertyGuarantor | 横向兜底 | 子输出组合不合法时修正(colocate/bucket-shuffle 转换) |
Required property 的生成
StarRocks 不做任意 property 枚举,候选来源只有两类:父节点传下来的 requirementsFromParent + 算子自身语义的有限候选集。典型规则:
| 算子 | 子需求候选 |
|---|---|
| Hash Join | 方案A [ANY, BROADCAST];方案B [hash(leftEq), hash(rightEq)](有等值列时);JoinHelper.onlyBroadcast/onlyShuffle 按类型与 hint 裁剪 |
| Merge Join | 同 Hash Join,但两侧额外要求按等值列的 SortProperty |
| NestLoop Join | [ANY, BROADCAST];right/full outer 双侧 GATHER |
| Hash Agg(GLOBAL) | 无 group by → GATHER;有 → hash(groupBy) SHUFFLE_AGG;LOCAL → EMPTY |
| Window | sort(partitionBy+orderBy) + 按 partition 列分布(无则 GATHER) |
| Limit / AssertOneRow | GATHER |
| CTE Anchor/NoCTE | 携带 CTEProperty(已用 CTE id 集合)做正确性约束 |
两个级联对齐机制(PropertyDeriverBase):
- 列序对齐:
computeShuffleJoinRequiredProperties若父需求的SHUFFLE_JOIN列集与本层等值列相同,按父列序调整本层 shuffle 列——下层分布可被上层直接复用,省一层 exchange。computeAggRequiredShuffleProperties对聚合同理(shouldAdjustGroupByOrder),并支持canRelaxGroupByCols的 null-relax 放宽。 - 等价类并查集:
HashDistributionSpec携带EquivalentDescriptor,join 时把等值列做unionDistributionCols(inner)/unionNullRelaxCols(outer)。这使得"按t1.ashuffle 的输出"可以满足上层"按t2.b分布"的需求(当t1.a = t2.b是连接条件)——分布属性穿越 join 向上传导的核心。
Output property 的推导(OutputPropertyDeriver)
输出属性必须反映算子实际保留的分布和顺序,不能直接沿用父节点的期望。例如 Broadcast Join 可继承未复制侧的分布,而开启 spill 的路径可能需要清除排序属性。
- broadcast join → 输出 = 左孩子属性(右孩子被复制,无分布信息);
- shuffle join → 按主导侧(inner 取左、right join 取右、full outer 取左侧的 null-relax 版本)构造
SHUFFLE_JOIN分布 + 等价类合并; - OlapScan →
LOCAL分布 + 表/分区等价信息(colocate 识别基础); - Hash Agg / Project / Filter / Limit → 透传子属性;
- TopN(FINAL) → 输出 SortProperty(+ split 时 GATHER);
- spill / partition join 开启时
resetSortProperty(落盘破坏顺序)。
级联传导全景图
分布复用依赖需求列序与等价列信息共同匹配,而不只是两层算子都使用 Hash。以下调用关系展示聚合如何复用 Join 的输出分布。
1 requiredProperty 自顶向下
2 root[EMPTY / sort+gather]
3 │ RequiredPropertyDeriver
4 ▼
5 agg[EMPTY] ──需求──> hash(groupBy) SHUFFLE_AGG
6 │ │ 列序对齐父层
7 ▼ ▼
8 join ──需求──> [ANY, BROADCAST] 或 [hash(l), hash(r)]
9 │ │
10 ▼ ▼
11 scan ──输出──> LOCAL(分布列, equivDesc) outputProperty 自底向上
12 ▲ │
13 └── equivDesc 并查集:t1.a = t2.b 使分布属性穿越 join 向上传导
Enforcer:触发过程与执行协议
Enforcer 将属性差距转换为可计价的物理工作,例如重分区或排序。插入位置与顺序必须符合执行协议,否则成本正确也可能产生不合法的属性组合。
物理形态
Enforcer 就是普通物理算子(PhysicalProperty.appendEnforcers):
1// DistributionProperty.appendEnforcers
2new GroupExpression(new PhysicalDistributionOperator(spec), Lists.newArrayList(child));
3// SortProperty.appendEnforcers
4new GroupExpression(new PhysicalTopNOperator(spec, ...), Lists.newArrayList(child));
分布不满足 → PhysicalDistributionOperator(翻译为 Exchange:shuffle/broadcast/gather/round-robin);顺序不满足 → PhysicalTopNOperator(Sort)。
触发入口与任务恢复
存量物理表达式和规则新生成的物理表达式都必须进入估价任务,否则新候选会错过 Group 初始枚举。孩子尚未就绪时,任务通过克隆状态恢复执行。
| 触发点 | 时机 |
|---|---|
OptimizeGroupTask.execute() | Group 被(重新)优化时,对存量物理表达式逐个压入(同一物理算子在不同需求下反复估价) |
ApplyRuleTask.execute() | implement 规则新生成物理表达式时立即压入(携带当前需求上下文 + 代价上界) |
clone() 自恢复 | 子 Group 优化完成后从断点续跑(不算新触发) |
为什么新物理表达式必须立刻压入:逻辑表达式无代价,物理表达式的代价只能在"需求上下文"下计算(子需求取决于父需求);且它错过了 OptimizeGroupTask 的存量快照,若不自己压任务就会漏估。
注意:enforcer 本身不会再触发 EnforceAndCostTask——其代价在 CostModel.calculateCost(enforcer) 就地结算后直接登记。
执行协议(EnforceAndCostTask.execute 四步)
只有取得孩子 Winner 并验证组合属性后,父候选的总代价才完整。任务按以下顺序完成计价、补偿和最优解登记。
11. initRequiredProperties:RequiredPropertyDeriver 得候选需求组合列表
22. 逐候选、逐子节点优化(状态机,可挂起/恢复):
3 - 查 childGroup.getBestExpression(childReq);没有 → 挂起自己 + 压子任务
4 - 累加子代价,任何时刻超上界 → recordLowerBoundCost 放弃该候选
5 - 熔断:canGenerateOneStageAgg / checkBroadcastRowCountLimit
63. 全部子节点就绪:
7 - ChildOutputPropertyGuarantor 保证组合合法(可能补插 bucket-shuffle 等)
8 - OutputPropertyDeriver 计算输出属性
94. recordCostsAndEnforce:
10 - setPropertyWithCost 登记 (requiredProperty → cost, inputProperties)
11 - outputProperty.isSatisfy(requiredProperty)?
12 满足 → 直接登记(一个表达式可同时满足多个 requirement)
13 不满足 → enforceProperty 插 enforcer:
14 只缺分布且无顺序要求 → enforceDistribute
15 只缺分布且有顺序要求 → 先清空旧属性再 Sort+Distribution
16 (防止 [order,gather]→[order,shuffle] 死循环)
17 只缺顺序 → enforceSort
18 都缺 → GATHER 需求先 Sort 后 Gather;否则先 Distribution 后 Sort
19 - curTotalCost < UB → 收紧上下文上界(全局剪枝发动机)
上界/下界剪枝闭环
候选的已知下界达到当前可行解上界时,继续展开不会改善结果。该判定依赖成本累计和下界记录正确,不能用任意估计值代替安全下界。
OptimizeGroupTask入口:costLowerBound(req) >= upperBoundCost→ 直接返回;EnforceAndCostTask每累加一个子代价都检查超上界 →recordLowerBoundCost并放弃;- 子任务被上界剪掉(恢复后
childBestExpr == null)→ 父任务记costLowerBound = UB + 1退出。
一个完整示例:Optimizer 生命周期走读
Join 的分布选择可能同时决定上层聚合是否需要 Exchange,因此必须比较完整子树。下面用两表等值连接后的分组聚合说明这条依赖链。
1SELECT t1.a, count(*) FROM t1 JOIN t2 ON t1.a = t2.b GROUP BY t1.a;
这是调用链示例,不是一次实测 Trace。假设 t1 按 a 列 hash 分布,且分区、桶映射等条件使其可复用;t2 分布与 b 无关。是否可省 Exchange 仍由属性兼容性检查决定,不能仅看分布列名。
Memo 结构(RBO 后)
聚合与连接属于不同 Group,聚合通过对连接 Group 提出分布要求来取得可执行输入,而不是固定引用某个 Join 实现。
1G0: LogicalAgg(group by a) → children: [G1]
2G1: LogicalJoin(t1.a = t2.b) → children: [G2, G3]
3G2: LogicalScan(t1)
4G3: LogicalScan(t2)
任务时序(按栈顶执行顺序)
父任务在孩子 Winner 缺失时暂停,孩子完成后才能累计成本并验证输出分布。以下顺序省略其他规则分支,用于说明这一依赖,不保证实际 Trace 的每次任务顺序相同。
阶段 1:从根下降到叶子
| # | 任务 | 动作 |
|---|---|---|
| 1 | OptimizeGroupTask(G0, EMPTY, UB=MAX) | 压入 OptimizeExpressionTask(G0) |
| 2 | OptimizeExpressionTask(G0) | 压入 ApplyRuleTask、DeriveStatsTask、ExploreGroupTask(G1) |
| 3 | ExploreGroupTask(G1) | 探索 join 交换律等逻辑变换 |
| 4 | DeriveStatsTask(G0) | 推导统计 |
| 5 | ApplyRuleTask(AggImplementRule) | 生成 PhysicalHashAggregate → 压入 EnforceAndCostTask(G0-Agg) |
| 6 | EnforceAndCostTask(G0-Agg) | GLOBAL agg → 子需求 [[hash(a) SHUFFLE_AGG]];G1 无 best → 挂起 + 压 OptimizeGroupTask(G1, hash(a), UB-aggCost) |
| 7 | OptimizeGroupTask(G1, hash(a)) | 压 join 的 OptimizeExpressionTask |
| 8 | ApplyRuleTask(JoinImplementRule) | 生成 PhysicalHashJoin → 压 EnforceAndCostTask(G1-Join) |
| 9 | EnforceAndCostTask(G1-Join) | 候选:A [ANY,BROADCAST]、B [hash(a),hash(b)];先走 A,G2 无 best → 挂起,下钻叶子 |
阶段 2:叶子产出 best(代价自底向上)
| # | 任务 | 动作 |
|---|---|---|
| 10 | EnforceAndCostTask(G2-ScanT1, ANY) | 输出 LOCAL(a),满足 ANY → G2.best[ANY]=scanT1;收紧上界 |
| 11 | EnforceAndCostTask(G3-ScanT2, BROADCAST) | scan 不满足 BROADCAST → enforceDistribute 插 broadcast exchange,代价 = scan + broadcast,登记 G3.best[BROADCAST] |
阶段 3:Join 逐方案收敛
| # | 任务 | 动作 |
|---|---|---|
| 12 | EnforceAndCostTask(G1-Join) 恢复 | 两子就绪:Guarantor(broadcast 合法)→ OutputDeriver(输出 = 左孩子 LOCAL(a))→ 满足 hash(a) → 登记 G1.best[hash(a)] = Join(broadcast) |
| 13 | 方案 B(shuffle) | 重置状态重估:G2 的 LOCAL(a) 直接满足 hash(a)(免 shuffle);G3 需插 shuffle enforcer;本例不满足双侧 colocate 前提。代价竞争取低者 |
阶段 4:回到根并提取
| # | 任务 | 动作 |
|---|---|---|
| 14 | EnforceAndCostTask(G0-Agg) 恢复 | G1.best[hash(a)] 存在 → 累加;agg 输出透传;满足根需求 EMPTY → 登记 G0.best[EMPTY] |
| 15 | extractBestPlan(EMPTY, G0) | 沿 inputProperties 递归:Agg → G1.best[hash(a)](inputProps [ANY, BROADCAST])→ scanT1 / broadcast(scanT2) |
若 Broadcast 候选总代价最低,可提取为:
1HashAggregate (group by a)
2 └─ HashJoin (broadcast)
3 ├─ OlapScan(t1)
4 └─ Exchange(BROADCAST) ← enforcer 产物
5 └─ OlapScan(t2)
若 shuffle 方案总代价更低(右表大、左表按 a 分布可免一侧交换),则提取为:
1HashAggregate (group by a) ← 其 SHUFFLE_AGG(hash(a)) 需求
2 └─ HashJoin (shuffle, 输出 SHUFFLE_JOIN(a)) ← 经列序对齐 + 等价类,免掉聚合侧交换
3 ├─ OlapScan(t1) ← LOCAL(a) 直接满足 hash(a)
4 └─ Exchange(SHUFFLE hash(b))
5 └─ OlapScan(t2)
候选范围:Join 与聚合如何选型
Join 的实现与分发:Cost 能决定什么,不能决定什么
Cost 只能比较已注册且通过准入的实现,不能绕过规则集合选择算子。该快照的 Auto 模式不注册 Merge Join,因此需要区分算法选择与 Hash Join 内部的分发选择。
Implementation Rule 的能力与注册方式
实现规则的注册和准入条件决定 Cost 能比较的候选集合,代码中存在某种算子并不代表默认开启。该快照包含三条 Join Implementation Rule:
| 规则 | 物理算子 | check() 准入 |
|---|---|---|
HashJoinImplementationRule | PhysicalHashJoinOperator | 非 cross join 且有等值谓词 |
MergeJoinImplementationRule | PhysicalMergeJoinOperator | 存在等值谓词 |
NestLoopJoinImplementationRule | PhysicalNestLoopJoinOperator | 没有等值谓词,并检查 Join Type 与功能开关 |
但“代码中存在三条规则”不等于“三种实现会同时进入同一 Group 竞价”。QueryOptimizer.memoOptimize 根据 join_implementation_mode 注册规则:
| 模式 | 实际注册的实现规则 | 含义 |
|---|---|---|
auto(默认) | Hash Join + Nested Loop Join | 等值连接走 Hash,非等值/Cross 由 NLJ 兜底 |
hash | Hash Join | 强制只生成 Hash Join 能处理的物理实现 |
merge | Merge Join | 显式启用 Merge Join 路径 |
nestloop | Nested Loop Join | 强制只生成 NLJ 能处理的物理实现 |
RuleSet.addAutoJoinImplementationRule() 中的 Merge Join 注册目前仍被注释,并保留 TODO: implement merge join。因此在本文源码快照中,标准 Auto 模式没有发生 Hash Join 与 Merge Join 的直接 Cost 竞争。当前 Cost 主要决定的是:
- 一个已注册 Join 实现采用哪组 Child Required Properties;
- Broadcast、Shuffle、Colocate、Bucket Shuffle 等分发形态的完整成本;
- 同一 Required Property 下,不同 Join Order 与子计划组合谁成为 Winner。
Hash 与 Merge 的 Property 需求差异
即使两者当前不会在 Auto 模式中直接竞价,RequiredPropertyDeriver 仍完整表达了两种算子的物理契约:
1HashJoin 候选:
2 方案A: [ANY, BROADCAST]
3 方案B: [hash(leftEq), hash(rightEq)]
4
5MergeJoin 候选:
6 方案A: [sort(leftEq), BROADCAST + sort(rightEq)]
7 方案B: [hash(leftEq)+sort(leftEq), hash(rightEq)+sort(rightEq)]
即:Merge Join 的每个方案都比 Hash Join 多一对按等值列的 Sort 需求。在 merge 模式中,如果子树没有现成有序性,优化器就会为孩子插入 Sort Enforcer,并把代价加入该 Merge Join 计划。
两套代价模型如何评价各自候选
两种 Join 的局部公式只有加上孩子与 Enforcer 的成本后才具有计划层面的含义。以下是源码模型而非耗时测量,其中权重和惩罚项不能直接当作真实 CPU 时间或内存占用。
Hash Join(HashJoinCostModel,执行模式由子节点实际输入属性反推):
1CPU = buildCost + probeCost + joinOutput.computeSize
2 BROADCAST: build = rightOutput // 每 BE 并行度 1 建全量 HT
3 SHUFFLE: build = rightOutput / parallelFactor // 并行建局部 HT
4
5probeCost = leftOutput × cachePenaltyFactor
6 mapSize = keySize × rightRows
7 BROADCAST: penalty = min(12, max(1, log(mapSize/100000)))
8 SHUFFLE: penalty = min(3, max(1, log(mapSize/100000) - log(parallelFactor)))
9
10MEM = BROADCAST ? rightOutput × beNum : rightOutput
Merge Join(visitPhysicalMergeJoin):
1有等值谓词: CPU = (leftSize + rightSize/2), MEM = 0
2无等值谓词: CPU = leftSize + rightSize, MEM = rightSize × EXECUTE_COST_PENALTY × 100 // 接近判死
单看算子本体,Merge Join 的 CPU 公式是线性的;但它的计划总代价必须加上 Sort Enforcer。Sort Enforcer 以 TopN 物理算子表达,其代价包含输入 CPU、输出内存与输入网络量。如果两侧都要从零排序,Merge Join 路径的总成本会显著增加。
这里应避免一个错误推论:两套公式同时存在,不代表当前默认模式会用它们完成 Hash-vs-Merge 自动选型。 CostModel 只会评价已经被当前 Rule Set 生成的物理表达式;候选没有被注册和生成,Cost 再精细也无法选择它。
最终折算统一走:
1realCost = cpu × 0.5 + memory × 2.0 + network × 1.5
Merge Join 路径如何复用已有顺序
在显式 merge 模式下,Merge Join 是否需要 Sort Enforcer,仍取决于孩子 Output Property 能否满足等值列排序要求:
1MergeJoin(leftKey=a, rightKey=b)
2 ├─ child output 已满足 sort(a) → 直接复用
3 └─ child output 不满足 sort(b) → 插入 Sort Enforcer
Property 复用仍然有价值:已有顺序可以避免重复排序,使强制 Merge Join 路径更便宜;但这属于 Merge Join 方案内部的 Property/Enforcer 优化,不是默认 Auto 模式下与 Hash Join 的胜负比较。
两个重要的相关机制:
CostModel.visitPhysicalTopN的 ∞ 惩罚:非 enforced、非 split 的单阶段全量 FINAL sort(即无 limit 的全局排序)被判CostEstimate.infinite()——强制排序拆成两阶段(各节点局部排序 + gather 后归并),防止产生天价单阶段 sort;physicalRuleRewrite的ExchangeSortToMergeRule(CBO 后):把Exchange(GATHER) 之上的 FINAL sort改写为PARTIAL sort → Exchange → FINAL sort(split)的两阶段归并排序。这里的“Merge”指分布式有序流归并,不是PhysicalMergeJoinOperator。
NestLoop 与防御性代价
硬性准入和代价惩罚不能互相替代:前者删除候选,后者只改变已生成候选的排序。源码分别在规则、任务和 Cost 层实施这些约束。
visitPhysicalNestLoopJoin:cpu = leftSize × rightSize × EXECUTE_COST_PENALTY,cross join 再乘cross_join_cost_penalty;right join 右表过大额外惩罚。惩罚提高候选代价;是否允许生成 NLJ 仍由实现规则的准入条件决定,不能由惩罚系数保证;EnforceAndCostTask.checkBroadcastRowCountLimit:右表行数超broadcast_row_count_limit且左表不够小 → 硬性否决 broadcast 方案(不走代价);JoinHelper.onlyBroadcast()/onlyShuffle():right outer/anti 等类型裁剪候选集。
Join 分发的第三/四/五种形态:colocate / bucket-shuffle
除 broadcast/shuffle 两个候选外,ChildOutputPropertyGuarantor 在子输出就绪后把组合修正为更省的形态(不改 Memo 搜索结构,只改 enforcer 形态与代价):
- 双侧
LOCAL且 colocate 组对齐 → colocate join,零 exchange; - 左
LOCAL+ 右SHUFFLE/LOCAL→transToBucketShuffleJoin:只按左表分布路由右表(单边交换)。
聚合选型:Memo 内阶段竞争与 Memo 外 Sort Aggregate
聚合阶段数由 Memo 内候选与代价共同决定,Sort Aggregate 则依赖 CBO 后的存储有序性检查。这两条路径的优化机会和限制不同。
关键事实:聚合阶段拆分发生在 CBO 内部
SplitTwoPhaseAggRule / SplitMultiPhaseAggRule 注册在 RuleSet 构造器(transform 规则),在 Memo 搜索中把单阶段 GLOBAL agg 变形成 GLOBAL(split) → LOCAL(split) 两层,与原形态放进同一个 Group 竞争:
1G_agg:
2 Expr1: GLOBAL agg ← 原始(一阶段)
3 Expr2: GLOBAL(split) → LOCAL(split) ← Split 规则生成(二阶段)
4 Expr3: 多 distinct 时的三/四阶段形态 ← SplitMultiPhaseAggRule
SplitTwoPhaseAggRule.check 要求 isGlobal() && !isSplit(防循环);isSuitableForTwoStageDistinct 用统计做预筛(isTwoStageMoreEfficient:group by 基数 × 系数 < 输入行数才值得拆)。
Hash Aggregate 的代价竞争(visitPhysicalHashAggregate)
基础代价 = input.computeSize × factor(cpu)+ output.computeSize × factor(mem),其上叠加三组政策性代价:
invalidOneStageAggCost→ ∞:必须多阶段的场景却生成非 split 单阶段 GLOBAL,判死;redundantTwoStageAggCost→ ∞:Group 已有input → GLOBAL一阶段最优解时,input → LOCAL(split) → GLOBAL(split)被判"多余的 local 步骤"——防止统计高估基数时选出"先本地无效聚合再全量交换"的坏计划;factor = 0.1(软奖励):isSplit && LOCAL的 local 阶段代价打一折;配套setExchangeCostFactor把 local agg 上方的 exchange 代价也 ×0.1(注释:“In most scenes, local agg → exchange → global agg is better than exchange → global agg”)。
另有倾斜场景系数 computeDataSkewPenaltyOfGroupByCountDistinct(0.2 / 0.5 / 1.5)引导三阶段拆分。
任务层硬闸门:canGenerateOneStageAgg
代价之外,EnforceAndCostTask 在拿到子 best 后硬否决不稳健的单阶段全局聚合:统计未知 / 行数不准、含 distinct、多 group by 列(>1)——可被 new_planner_agg_stage=1 或单 BE local-shuffle 豁免。
聚合需求与 join 分布的级联复用
聚合键与 Join 分布键兼容时,沿用下层分布可以省去再次交换;兼容性还包含列序和空值语义。computeAggRequiredShuffleProperties 负责调整这些要求。
visitPhysicalHashAggregate 生成 hash(groupBy) SHUFFLE_AGG 需求时,computeAggRequiredShuffleProperties 会:
- 若父层(上层 join)要求
SHUFFLE_JOIN且列集一致 → 按父列序调整 group by 的 shuffle 列(shouldAdjustGroupByOrder); canRelaxGroupByCols:父层列是 null-relax 时,本层也放宽——保证 outer join 之上的聚合不额外插一次交换。
反方向同样成立:聚合的 SHUFFLE_AGG 输出经等价类并查集可被上层 SHUFFLE_JOIN 需求满足(详见完整示例的 shuffle 变体)。
Sort Aggregate:不是 Memo 算子,而是 CBO 后的执行方式替换
StarRocks 没有 PhysicalSortAggregate 算子参与代价竞争;“sort aggregate” 是 physicalRuleRewrite 阶段 PhysicalDistributionAggOptRule.UseSortAGGRule 的执行层替换,当 enable_sort_aggregate = true 且满足全部条件时,把 PhysicalHashAggregateOperator 打上 useSortAgg=true 标记:
1准入条件(全部满足):
2 1. agg 是一阶段全局聚合(isGlobal && group by 非空)
3 2. 孩子是 OlapScan
4 3. 仅命中单个分区(多分区同 key 不在同 tablet,无法流式)
5 4. 所有 group by 列都是表的 key 列(前缀对齐)
6 5. group by 列恰好覆盖 schema 的 key 前缀
7满足 → scan.setNeedSortedByKeyPerTablet(true)
8 BE 按 tablet 内 key 有序输出,agg 退化为流式(不维护全部分组的 hash 表,仍需聚合状态及输入缓冲)
同规则还处理 enable_per_bucket_compute_optimize:单阶段全局聚合 + scan 直连时打 usePerBucketOptmize 标记,按 bucket 分组执行(本质仍是 hash agg,但利用分桶局部性减少冲突)。
Hash Agg vs Sort Agg 的选型小结
Sort Aggregate 用输入有序性换取较小的分组索引状态,但不能脱离单分区、键前缀和聚合阶段等准入条件使用。它也不保证聚合函数自身的状态有界。
| 维度 | Hash Aggregate | Sort Aggregate |
|---|---|---|
| 决策位置 | CBO 内(阶段拆分 + 代价竞争) | CBO 后 physicalRuleRewrite(条件满足即替换) |
| 决策依据 | 统计(基数/聚合率/倾斜)+ 代价权重 | 物理布局事实(key 列、单分区、单阶段) |
| 内存 | 有 hash 表,依赖阶段拆分与 spill 缓解 | 无需保存全部分组的 hash 表;单组聚合状态仍可能较大 |
| 依赖的 property | 分布(SHUFFLE_AGG/GATHER/EMPTY) | 有序性隐含在存储层(按 key 有序读),不经 Memo 传导 |
关键差异:Sort Agg 的"有序性"来自存储引擎(tablet 内数据按 key 有序),而不是 Memo 里的 SortProperty——所以它不产生/消费 enforcer,CBO 对它是"盲"的,由后置规则做确定性判定。
总结:决策层次与 Property 级联的全景
最终执行计划由硬性准入、成本竞争和后置改写共同产生,任一层都可能解释某个候选为何未出现。排查时应先定位决策层,再分析统计误差或成本系数。
1┌─ 硬排除(规则/任务层,不进代价)────────────────────────────┐
2│ implement rule.check()、JoinHelper.onlyXxx、 │
3│ checkBroadcastRowCountLimit、canGenerateOneStageAgg、CTE 校验 │
4├─ 代价竞争(CBO 内,数字裁决)───────────────────────────────┤
5│ Auto 模式下的 Join Order、Hash/NLJ 准入与分发方案 │
6│ Merge 模式下 Merge Join 的 Property/Enforcer 组合 │
7│ broadcast vs shuffle vs colocate/bucket(需求候选+enforcer) │
8│ Agg 一阶段/二阶段/多阶段(Split 规则 + ∞ 代价 + 0.1 奖励) │
9│ realCost = 0.5·cpu + 2·mem + 1.5·net │
10├─ 确定性后处理(CBO 后,条件替换)───────────────────────────┤
11│ SortAgg / PerBucket 标记、两阶段归并排序、冗余 shuffle 裁剪、 │
12│ 低基数字典编码 │
13└─────────────────────────────────────────────────────────────┘
实际排查应按“候选是否注册 → 准入是否通过 → 子属性是否兼容 → Enforcer 成本 → 后置改写”的顺序追踪。不能因为模型里存在 Merge Join 公式,就断言 Auto 模式会选择它;也不能把 Sort Aggregate 的条件替换解释成 Hash-vs-Sort 成本竞争。Broadcast 的代价是复制与内存放大,Shuffle 的代价是传输与重分区,Colocate 则依赖布局约束;缺少这些前提时,局部算子成本不具备可比性。
附:关键源码索引
复核上述决策需要同时阅读规则注册、属性推导和计划后处理;单看 CostModel 会遗漏未生成的候选。以下链接固定到同一提交。
| 主题 | 文件 |
|---|---|
| 优化器入口/流水线 | QueryOptimizer.java |
| 任务调度 | optimizer/task |
| Property 推导 | RequiredPropertyDeriver.java、OutputPropertyDeriver.java |
| Enforcer 形态 | DistributionProperty.java、SortProperty.java |
| 代价模型 | CostModel.java、HashJoinCostModel.java |
| Join 实现规则 | rule/implementation |
| 聚合拆分规则 | rule/transformation |
| SortAgg/PerBucket | PhysicalDistributionAggOptRule.java |
| 两阶段归并排序 | ExchangeSortToMergeRule.java |