提到 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。如果你拿到一条普通 SQLSELECT 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不是分组键。第二个条件值得展开聊。设想下面这条 SQLSELECT 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 ENDCASE 表达式会在每一行上都做一次判断而其实分组本身已经把不同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 常量出来。大多数实现用1COUNT(*) FILTER (WHERE cond) -- 转换成 COUNT(CASE WHEN cond THEN 1 ELSE NULL END)如果这里写成THEN NULL那无论 cond 是否为真CASE 的结果都是 NULLCOUNT 会忽略所有 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的组合看起来有点危险但实际上语义是一致的条件为假时返回 NULLNULL 本身也不参与 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 statusOK)就变成了SUM(CASE WHEN statusOK 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(*)转成 CASETHEN 分支顺手写了builder.nullLiteral()。结果查出来的数据永远是 0排查了很久才发现原因。COUNT(expr)只统计 expr 为非 NULL 的行数。如果 THEN 和 ELSE 全是 NULL那 CASE 出来的结果每一行都是 NULLCOUNT 自然数不出来任何东西。正确写法是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时觉得这个限制有点多余后来在真实场景里才意识到它是合理的。假设有这样的 SQLSELECT 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 加一个显式 CASTSUM(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 规则不触发的排查思路遇到“我加了规则但计划没变”的情况按下面顺序排查先看 SQL 是不是真的用了FILTER子句。普通 WHERE 不会触发这个规则。打印优化前的逻辑计划看AggregateCall后面有没有FILTER $x这种标记。如果没有说明filterArg -1规则当然不会匹配。确认filterArg是不是分组键。如果是matches会返回 false。确认 HepProgram 的顺序。如果规则在另一个重写规则之前执行但那个规则已经把 FILTER 语法吃掉了也可能导致看不到效果。在matches和onMatch里加日志直接看规则有没有进入回调。这个排查思路可以整理成一个速查表现象可能原因排查方法解决建议规则完全不触发SQL 用的是普通 WHERE 而不是 FILTER打印计划看有没有 FILTER 标记改用 FILTER 语法或先做谓词下推规则触发但计划没变filterArg 是分组键检查 groupSet 是否包含 filterArg这是设计行为不需要处理返回结果恒为 0THEN 分支写成了 NULL检查 CASE 的 THEN 分支COUNT(*) 场景用常量 1类型报错ELSE NULL 没有显式类型看下游引擎的报错信息给 NULL 加 CAST计划里多了冗余 Project转换后布尔列没清掉打印最终计划后面加 ProjectMergeRule / ProjectRemoveRule5. 扩展思考什么时候该用它什么时候该绕开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 清理规则一起用效果会非常明显。