☰
Calcite聚合优化规则AggregateFilterToCaseRule解析
2026/10/10 12:51:46 网站建设 项目流程

提到 Calcite 的聚合优化规则,AggregateFilterToCaseRule 是我觉得最容易被名字误导、但实际含金量很高的一条。最初我在处理一个带 FILTER 子句的计数查询时,执行计划里一直挂着 filterArg 标记,后续下推规则全都绕着走;后来沿着源码查到这个规则,才把问题彻底理顺。这个规则的核心动作很明确:把聚合函数调用上携带的 FILTER(WHERE ...) 条件,翻译成 CASE WHEN 表达式,作为聚合参数塞进去。不要把它和聚合节点下方的 LogicalFilter 混淆,那是另一条故事线。如果你正在做 Calcite 二次开发、SQL 优化器改造,或者只是想把关系代数表达式玩明白,这篇文章应该能帮你省不少时间;如果你只是刚接触 Calcite,我也会尽量用例子把里面的概念讲清楚。

1. 规则到底做了什么:一个让你少走弯路的定位

1.1 先看一条带 FILTER 的聚合查询

很多人第一次接触这个规则,都是从下面这类 SQL 开始的:

SELECT dept_id, COUNT(*) FILTER (WHERE status = 'PAID') AS paid_cnt FROM orders GROUP BY dept_id;

注意这里的FILTER (WHERE status = 'PAID')不是 SQL 里的普通 WHERE,而是 SQL 标准里专门给聚合函数用的过滤子句。它表达的意思是:在聚合时只统计满足条件的行,但不会像 WHERE 那样在聚合之前就把数据过滤掉。很多引擎会用“条件聚合”来描述这个能力。

Calcite 在解析这种 SQL 时,不会把 FILTER 子句当成一个独立的 Filter 节点放在聚合下面,而是把它编码在AggregateCall的filterArg上。为了得到这个布尔值,SqlToRelConverter通常会在聚合输入上先加一个 Project,把status = 'PAID'计算成一个额外列。整个逻辑计划看起来像这样:

LogicalAggregate(group=[{0}], paid_cnt=[COUNT(*) FILTER $2]) LogicalProject(dept_id=[$0], status=[$2], $f2=[=($2, 'PAID')]) LogicalTableScan(orders)

这里$2是 Project 新生成的一个布尔字段的位置。这个设计在逻辑上很清楚:聚合时所有输入行的过滤条件都已经可用了,只要给每个AggregateCall一个索引,指向对应的布尔列即可。

但问题也随之而来。filterArg这种“挂在聚合签名上的外部引用”,对后续规则并不友好。列裁剪规则要处理额外的布尔列,物化视图改写要判断这个布尔列是否等于某个函数,逻辑优化器里很多 pattern 匹配是看AggregateCall的argList的,不会去看filterArg。所以AggregateFilterToCaseRule存在的根本意义,就是把一个“签名上的特殊标签”降级成“参数里的普通表达式”。

1.2 转换前后,关系代数发生了什么

应用AggregateFilterToCaseRule之后,上面那个计划会变成:

LogicalAggregate(group=[{0}], paid_cnt=[COUNT(CASE WHEN =($2, 'PAID') THEN 1 ELSE NULL END)]) LogicalProject(dept_id=[$0], status=[$2], $f2=[=($2, 'PAID')]) LogicalTableScan(orders)

如果后面跟上 Project 合并、Project 消除等清理规则,那个多余的布尔列会被去掉,最终大概是:

LogicalAggregate(group=[{0}], paid_cnt=[COUNT(CASE WHEN =($1, 'PAID') THEN 1 ELSE NULL END)]) LogicalTableScan(orders)

这里最关键的变化有两点:第一,AggregateCall.filterArg的专用通道消失了,过滤逻辑变成了普通表达式;第二,CASE WHEN的未知/不满足分支返回 NULL,而 COUNT、SUM、AVG 这些聚合函数天然忽略 NULL,所以语义完全等价。

为什么说这个转换有价值?因为经过转换后,后续的表达式处理规则、列裁剪规则、谓词复用规则、物化视图改写规则,都用同一套“表达式”的语法来处理这个过滤条件了,不再需要为 FILTER 单独维护一套逻辑。打个不那么严谨的比方:FILTER 是把过滤条件做成一个特殊接口插在聚合上,CASE 则是把条件降级成普通数据流里的变换,后者对所有通用优化框架都更友好。

1.3 名字里的 Filter 指的是什么

必须澄清一个高频误区:AggregateFilterToCaseRule里的 Filter,并不是LogicalFilter这个关系表达式节点,而是AggregateCall上的filterArg。

如果你拿到一条普通 SQL:

SELECT COUNT(*) FROM orders WHERE status = 'PAID';

这里的WHERE status = 'PAID'在 Calcite 里会先变成一个LogicalFilter,位于LogicalAggregate下方或上方。AggregateFilterToCaseRule的 pattern 匹配的是Aggregate节点本身,不会去直接吞掉那个LogicalFilter。真正负责处理普通 WHERE 与聚合之间位置关系的是AggregateFilterTransposeRule这类谓词下推规则。

也就是说,这个规则专治的是AggregateCall自带的FILTER子句。理解这一点之后,再回看这个规则的名字就顺了:它是一个把“聚合上的 filter 标识”转成“CASE 表达式”的规则。

2. 源码级拆解:matches、onMatch 与 CASE 构造细节

2.1 规则匹配条件:为什么必须检查 group key

先看规则的匹配逻辑。AggregateFilterToCaseRule是RelRule的子类,它的matches方法决定了哪些场景会被触发。源码逻辑可以简化为下面这段示意代码:

@Override public boolean matches(RelOptRuleCall call) { final Aggregate aggregate = call.rel(0); return aggregate.getAggCallList().stream().anyMatch(aggCall -> aggCall.filterArg >= 0 && (aggregate.getGroupCount() == 0 || !aggregate.getGroupSet().asSet().contains(aggCall.filterArg))); }

判断条件有两个:第一,至少有一个聚合调用带filterArg;第二,这个filterArg不是分组键。

第二个条件值得展开聊。设想下面这条 SQL:

SELECT status, COUNT(*) FILTER (WHERE status = 'PAID') AS paid_cnt FROM orders GROUP BY status;

status已经是分组键了,FILTER (WHERE status = 'PAID')的过滤条件在每个组内实际上是常量条件。如果把这种条件转成CASE WHEN status = 'PAID' THEN 1 END,CASE 表达式会在每一行上都做一次判断,而其实分组本身已经把不同status分开了,这个判断完全是冗余的。更重要的是,status作为分组键在后续优化中有大量语义可以借用,比如 Grouping Sets 改写、分组裁剪,直接把这个条件并进 CASE 表达式,反而会抹掉这些优化线索。

所以源码里刻意排除了 filterArg 是 group key 的情况。这算是一个很好的设计范例:规则不是无脑转换,而是在改写前先判断改写是否还有优化空间。

2.2 onMatch 的核心改写流程

onMatch是真正干活的入口。核心流程如下:

@Override public void onMatch(RelOptRuleCall call) { final Aggregate aggregate = call.rel(0); final RelBuilder builder = call.builder(); final RexBuilder rexBuilder = builder.getRexBuilder(); for (AggregateCall aggCall : aggregate.getAggCallList()) { if (aggCall.filterArg < 0) { // 没有 filter,原样保留 continue; } // 1. 取出过滤条件表达式 RexNode filterExpr = rexBuilder.makeInputRef( aggregate.getInput().getRowType(), aggCall.filterArg); // 2. 构造 THEN 分支 RexNode thenExpr; if (aggCall.getArgList().isEmpty()) { // COUNT(*) 这种无参数聚合,必须给一个非 NULL 常量 thenExpr = builder.literal(1); } else { thenExpr = rexBuilder.makeInputRef( aggregate.getInput().getRowType(), aggCall.getArgList().get(0)); } // 3. 构造 CASE WHEN filterExpr THEN thenExpr ELSE NULL END RexNode caseExpr = rexBuilder.makeCall( SqlStdOperatorTable.CASE, filterExpr, thenExpr, rexBuilder.makeNullLiteral(thenExpr.getType())); } // 4. 用 RelBuilder 重建 Aggregate,更新 argList 和 filterArg // 5. 调用 call.transformTo(newAggregate) }

不同 Calcite 版本在细节上会有差异,比如有些版本会把 CASE 表达式先放到聚合输入之上的 Project 里,让新的AggregateCall参数指向这个新增列;有些版本则直接把表达式写进argList。但不管哪种实现,本质上的操作都是:把 filterArg 对应的条件表达式提取出来,和原聚合参数组合成 CASE,再重新构造聚合调用,并把 filterArg 清空。

2.3 THEN 分支选择:COUNT(*) 与普通聚合的差异

这里有一个特别容易忽略的细节:THEN 分支到底写什么,取决于聚合函数的参数类型。

对于COUNT(*),它没有聚合参数,所以需要人造一个非 NULL 常量出来。大多数实现用1:

COUNT(*) FILTER (WHERE cond) -- 转换成 COUNT(CASE WHEN cond THEN 1 ELSE NULL END)

如果这里写成THEN NULL,那无论 cond 是否为真,CASE 的结果都是 NULL,COUNT 会忽略所有 NULL,最终结果永远是 0。这是一个很低级但一旦踩中就很难排查的坑。

对于SUM(x)、COUNT(x)、MIN(x)、MAX(x)这类带参数的聚合,THEN 分支直接使用原来的参数表达式即可:

SUM(x) FILTER (WHERE cond) -- 转换成 SUM(CASE WHEN cond THEN x ELSE NULL END)

因为条件不满足时返回 NULL,而 SUM 会忽略 NULL,不会对结果产生任何污染。

对于COUNT(DISTINCT x)这类去重聚合,THEN 分支同样是 x。表面上DISTINCT + CASE的组合看起来有点危险,但实际上语义是一致的:条件为假时返回 NULL,NULL 本身也不参与 DISTINCT 计数,等于原 FILTER 把这些行排除掉了。这一点我在后面的踩坑部分还会再展开。

3. 落地实践:注册规则、执行顺序与真实案例

3.1 在 HepPlanner 和 VolcanoPlanner 中如何注册

这个规则在实际项目中一般放在 RBO 阶段使用。HepPlanner 是最常见的选择,示例代码如下:

HepProgram program = HepProgram.builder() .addRuleInstance(AggregateFilterToCaseRule.INSTANCE) .addRuleInstance(ProjectMergeRule.INSTANCE) .addRuleInstance(ProjectRemoveRule.INSTANCE) .build(); HepPlanner planner = new HepPlanner(program); planner.setRoot(relRoot.rel); RelNode optimized = planner.findBestExp();

为什么建议后面紧跟ProjectMergeRule和ProjectRemoveRule?因为我在 1.2 节说过,filterArg通常依赖聚合输入之上的一个额外 Project 列。规则把 FILTER 转成 CASE 之后,这个额外 Project 列很可能就不再被引用了,但不清理它,整个计划里就会残留一个冗余投影。HepPlanner 按顺序执行时,AggregateFilterToCaseRule在前,Project 清理在后,才能一次性得到干净的计划。

VolcanoPlanner 理论上也能加这个规则,但实际效果不那么稳定。因为这个规则不会带来明显的 cost 变化,优化器没有很强的动力去探索这条路径。我个人更倾向于把它放到自定义的 PreProcess 阶段,用 HepPlanner 强制跑一遍,而不是指望 VolanoPlanner 自己发现。

3.2 典型场景:多个条件聚合一次扫描转 CASE

这个规则真正发挥威力的场景,是一个 SQL 里同时出现多个带不同 FILTER 条件的聚合。看这个例子:

SELECT dept_id, COUNT(*) FILTER (WHERE status = 'OK') AS ok_cnt, COUNT(*) FILTER (WHERE status = 'FAIL') AS fail_cnt, SUM(amount) FILTER (WHERE status = 'OK') AS ok_amount FROM orders GROUP BY dept_id;

转换后变成:

SELECT dept_id, COUNT(CASE WHEN status = 'OK' THEN 1 END) AS ok_cnt, COUNT(CASE WHEN status = 'FAIL' THEN 1 END) AS fail_cnt, SUM(CASE WHEN status = 'OK' THEN amount END) AS ok_amount FROM orders GROUP BY dept_id;

注意,这里SUM(amount)的原始参数是amount,所以 THEN 分支必须写amount,而不是常量 1。如果写成THEN 1,那 original 的SUM(amount) FILTER (WHERE status='OK')就变成了SUM(CASE WHEN status='OK' THEN 1 END),结果从金额之和变成了命中行数的计数,语义完全错误。

经过这个转换,三个不同条件的聚合可以在同一次扫描和同一个聚合算子内完成,而FILTER写法在某些物理执行引擎里意味着三条独立的过滤路径,执行效率差别很大。尤其是在大宽表或多维分析场景,这个改写对减少扫描次数非常有帮助。

3.3 与其他规则的协同:什么时候用、什么时候不用

一个常见的误解是:只要我在优化器里加了AggregateFilterToCaseRule,普通 WHERE 条件下的 COUNT 查询就会被自动改写。实际上不是这样。

普通 WHERE 写法的 SQL 在 Calcite 里是先有LogicalFilter,再通过谓词下推规则把 Filter 挪到聚合下面,最终由 Scan 层处理。AggregateFilterToCaseRule无法直接作用于这种场景,它处理的是AggregateCall上的filterArg,而这个filterArg通常来自 SQL 的FILTER语法,或者来自你手动构建 RelNode 时的显式设置。

如果业务 SQL 里大量使用普通 WHERE,想达到同样的“条件聚合”效果,应该优先考虑谓词下推和物化视图改写,而不是死磕这个规则。反之,如果你们的产品需要解析带FILTER的查询,或者执行引擎不支持FILTER关键字,那这个规则就是不可或缺的一环。

另外还要注意子查询场景:如果 FILTER 条件里出现了相关子查询,直接转 CASE 可能会导致表达式被重复计算。工程上正确的顺序是先跑子查询去关联规则,比如SubQueryRemoveRule或DecorrelateProgram,把相关子查询消除之后,再应用这个规则。

4. 踩坑实录:覆盖大部分翻车现场的问题

4.1 COUNT 场景的 THEN 分支写错了,聚合结果恒为 0

这是我自己最早踩过的坑。当时在手动构造 RelNode,把一个带 filter 的COUNT(*)转成 CASE,THEN 分支顺手写了builder.nullLiteral()。结果查出来的数据永远是 0,排查了很久才发现原因。

COUNT(expr)只统计 expr 为非 NULL 的行数。如果 THEN 和 ELSE 全是 NULL,那 CASE 出来的结果每一行都是 NULL,COUNT 自然数不出来任何东西。正确写法是COUNT(CASE WHEN cond THEN 1 END),默认的 ELSE 就是 NULL,不需要额外填;如果要显式写 ELSE,就写ELSE NULL,但 THEN 必须是任意非 NULL 常量。

这个坑在写RelBuilder代码时尤其隐蔽,因为RelBuilder.literal(null)使用起来非常顺手,IDE 也不会报错,只有跑到真实数据时才会暴露。

4.2 DISTINCT 聚合转 CASE 后的语义检查

COUNT(DISTINCT x) FILTER (WHERE cond)转成COUNT(DISTINCT CASE WHEN cond THEN x END)后,语义是一致的,但这不代表可以完全不测试。

原因在于不同引擎对DISTINCT + CASE的聚合实现路径差别很大。某些执行引擎会对 DISTINCT 聚合做非常激进的优化,比如利用哈希表去重、利用排序去重;引入 CASE 后,这些优化路径可能失效,执行计划反而变慢。还有一个边界问题:如果x本身是 NULL,原来COUNT(DISTINCT x)不统计 NULL,转换后 CASE 当 cond 为真时返回 NULL,同样不统计 NULL,逻辑一致;但如果 cond 本身可能为 NULL,比如FILTER (WHERE amount > NULL),整个 CASE 的结果为 NULL,也是被忽略的。这些边界尽量用测试用例覆盖一遍。

4.3 过滤条件引用 GROUP BY 键时,规则为什么选择不匹配

前面源码部分提到过,如果过滤条件引用的是分组键,规则不会触发。我自己第一次看matches时觉得这个限制有点多余,后来在真实场景里才意识到它是合理的。

假设有这样的 SQL:

SELECT status, COUNT(*) FILTER (WHERE status = 'PAID') FROM orders GROUP BY status;

如果不加限制强制转换,等价于:

SELECT status, COUNT(CASE WHEN status = 'PAID' THEN 1 END) FROM orders GROUP BY status;

虽然结果一样,但优化器失去了一个关键信息:status = 'PAID'是一个分组内常量。这个信息可以用于分区裁剪、Grouping Sets 改写、甚至生成更紧凑的物理计划。转换成 CASE 之后,反而把这个优化线索掩盖了。所以源码里的排除逻辑不是偷懒,而是有意识地保留优化机会。

4.4 子查询、类型不匹配和引擎不识别 CASE 的坑

如果 FILTER 条件里的表达式复杂度较高,转成 CASE 之后可能引入类型问题。比如THEN分支是 DECIMAL,而ELSE NULL的 NULL 没有显式类型,有些引擎会报类型不匹配。解决办法是给 NULL 加一个显式 CAST:

SUM(CASE WHEN cond THEN amount ELSE CAST(NULL AS DECIMAL(18, 2)) END)

不要指望所有执行引擎都像 Calcite 一样自己去做类型合并。我遇到过一个场景是下游引擎对BOOLEAN和INTEGER的隐式转换比较严格,必须手动把条件表达式里的布尔结果转成可比较的类型。

还有一类坑是子查询。如果 FILTER 里的条件包含关联子查询,比如FILTER (WHERE EXISTS (...)),这个规则是不适合直接使用的。因为filterArg指向的是输入行里的一个布尔列,这个列可能是外层子查询计算出来的;直接把它拉进 CASE 表达式时,必须确保子查询执行一次而不是每行都执行。稳妥的做法是先走DecorrelateProgram,把子查询去掉后再应用规则。

4.5 规则不触发的排查思路

遇到“我加了规则但计划没变”的情况,按下面顺序排查:

  1. 先看 SQL 是不是真的用了FILTER子句。普通 WHERE 不会触发这个规则。
  2. 打印优化前的逻辑计划,看AggregateCall后面有没有FILTER $x这种标记。如果没有,说明filterArg = -1,规则当然不会匹配。
  3. 确认filterArg是不是分组键。如果是,matches会返回 false。
  4. 确认 HepProgram 的顺序。如果规则在另一个重写规则之前执行,但那个规则已经把 FILTER 语法吃掉了,也可能导致看不到效果。
  5. 在matches和onMatch里加日志,直接看规则有没有进入回调。

这个排查思路可以整理成一个速查表:

现象可能原因排查方法解决建议
规则完全不触发SQL 用的是普通 WHERE 而不是 FILTER打印计划看有没有 FILTER 标记改用 FILTER 语法,或先做谓词下推
规则触发但计划没变filterArg 是分组键检查 groupSet 是否包含 filterArg这是设计行为,不需要处理
返回结果恒为 0THEN 分支写成了 NULL检查 CASE 的 THEN 分支COUNT(*) 场景用常量 1
类型报错ELSE NULL 没有显式类型看下游引擎的报错信息给 NULL 加 CAST
计划里多了冗余 Project转换后布尔列没清掉打印最终计划后面加 ProjectMergeRule / ProjectRemoveRule

5. 扩展思考:什么时候该用它,什么时候该绕开

5.1 一个极简的 RelBuilder 复现实验

如果你想把规则放到本地环境里跑一遍,可以试试用 RelBuilder 手动构造一个带 filter 的聚合,然后交给 HepPlanner。思路大概是:

RelBuilder builder = RelBuilder.create(config); builder.scan("orders"); RexNode cond = builder.call( SqlStdOperatorTable.EQUALS, builder.field("status"), builder.literal("PAID")); // 不同 Calcite 版本的 AggCall API 差异较大,这里只说明方向 AggregateCall aggCall = AggregateCall.create( SqlStdOperatorTable.COUNT, false, // distinct false, // approximate ImmutableList.of(), // argList,表示 COUNT(*) -1, // filterArg,暂时不设置 cond, // 部分版本支持直接传 RexNode 作为 filter "paid_cnt"); RelNode plan = builder .aggregate(builder.groupKey("dept_id"), aggCall) .build(); HepPlanner planner = new HepPlanner( HepProgram.builder() .addRuleInstance(AggregateFilterToCaseRule.INSTANCE) .build()); planner.setRoot(plan); RelNode optimized = planner.findBestExp(); System.out.println(RelOptUtil.toString(optimized));

需要注意的是,不同 Calcite 版本的AggregateCall.create和RelBuilder.aggregate接口差异真的很大,有的版本要求传RelDataType,有的版本用RexNode作为 filter 参数,代码不能直接照搬。这个实验的目的主要是让你亲手确认规则的行为,遇到编译问题不要慌,以当前依赖的 API 为准。

5.2 从 FILTER 到 CASE 的心智模型

接触了一段时间之后,我觉得这个规则背后有一个更通用的思想:把特殊语法降级为通用表达式,让优化器用同一套机制处理所有情况。

FILTER子句在关系代数层面是一个很特殊的存在,它不像 Project、Filter 那样是独立的节点,而是聚合调用上的一个附加标签。这个标签对上层 SQL 解析友好,但对底层优化规则不友好。CASE WHEN是普通表达式,每个优化器都得支持,所以把它转换成 CASE 后,后续的常量折叠、表达式复用、列裁剪、甚至代码生成都能顺理成章地接手。

这个思路其实可以迁移到很多场景。比如某些引擎会把DISTINCT也当成聚合调用上的一个布尔标记,为了和物化视图改写统一,有时会把它转成某种特殊聚合或者去重节点。核心原则是一致的:越是通用的表示,越容易被更多规则处理。

5.3 更通用的表达式改写方向与替代方案

如果目标只是让执行引擎支持 FILTER 语法,其实有两个方向可以选择:一个是在逻辑优化阶段把 FILTER 转成 CASE,也就是这个规则做的;另一个是在物理执行阶段让引擎原生支持 FILTER,很多商业化引擎就是这么干的。

后者显然对执行器更友好,因为FILTER可以作为聚合算子的一个额外输入,不需要生成复杂的 CASE 表达式树。但前提是你的执行引擎是你自己写的,你可以改造它。如果你只是基于 Calcite 做上层查询引擎,无法轻易修改物理执行层的接口,那AggregateFilterToCaseRule几乎是最省事的方案。

还有一个稍微进阶的玩法:如果 SQL 里的过滤条件本身就能被改写得更简单,比如status IN ('PAID', 'FAIL'),转成CASE WHEN status IN ('PAID','FAIL') THEN ... END之后,后续的ReduceExpressionsRule可能把这个条件继续简化,甚至拆成多个条件聚合。这个链式反应只有在转成 CASE 之后才能发生,所以规则前面有什么规则、后面有什么规则,往往比规则本身更重要。

根据我自己的实际经验,现在遇到带 FILTER 的聚合,第一反应是先跑一遍AggregateFilterToCaseRule,然后再看整个计划。很多看似复杂的多条件聚合问题,在这个规则跑完之后都会变得清爽许多。如果你也正在调 Calcite 的计划,建议把它加进自定义的预优化 Program 里,配合 Project 清理规则一起用,效果会非常明显。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询