☰
Calcite聚合优化:AggregateFilterToCaseRule原理与实战
2026/10/10 12:51:45 网站建设 项目流程

在写这个规则分享之前,我想先问你一个问题:你有没有在 Calcite 的执行计划里见过类似AggregateCall带着一个filterArg的情况?如果你追踪过COUNT(*) FILTER (WHERE ...)这种标准 SQL 写法的最终计划,八成会遇到一个名字很拗口的规则:AggregateFilterToCaseRule。

我第一次看到这个规则的时候,第一反应是“这有什么好优化的,FILTER 和 CASE WHEN 不是一回事吗”。后来真在一套 OLAP 系统里踩了坑,才发现这个看似不起眼的规则,直接决定了你的聚合能不能被下推、能不能被裁剪字段、能不能走某些引擎的快速执行路径。这篇就围绕它展开,讲讲它到底在做什么、为什么这么做,以及你接入优化器的时候要注意哪些细节。

1. 这个规则解决的是什么问题

1.1 聚合 FILTER 在 Calcite 里的表示方式

在 SQL 标准里,FILTER (WHERE ...)是聚合函数后面的一个独立子句:

SELECT dept_no, COUNT(*) FILTER (WHERE salary > 10000) AS high_cnt FROM employee GROUP BY dept_no;

这段 SQL 的语义很直观:先按dept_no分组,在每组里只统计满足salary > 10000的行数。

但如果你看 Calcite 内部的结构,情况就没有 SQL 语句那么简单了。Calcite 在解析之后,会把聚合操作建模成LogicalAggregate,它的每个聚合调用由AggregateCall表示。AggregateCall里保存了函数类型、参数索引、返回值类型、是否 distinct 等元数据,其中有一个字段专门记录过滤条件对应的输入列序号,这就是filterArg。

关键点在于:过滤条件并不是聚合函数的参数。比如COUNT(status) FILTER (WHERE status = '1'),函数参数是status列,过滤条件引用的是另一个列或者同一个列,但它被归类为 filter 列,放在filterArg上。这样的设计让优化器没有办法把 FILTER 当作普通表达式的一部分来对待,在很多规则眼里,它是一个“看不见的副作用”。

1.2 等价写法的烦恼

COUNT(*) FILTER (WHERE cond)和COUNT(CASE WHEN cond THEN 1 END)在绝大多数数据库里是等价的。可恰恰因为它们等价,优化器反而多了一个麻烦:不同来源的 SQL 写进 Calcite 后,生成的内部结构不同。

有些团队的程序会动态生成 SQL,统一用 FILTER 子句;有些团队祖传下来的 SQL 则习惯写CASE WHEN。这两种写法在用户视角没有任何差别,但在RelNode树里,一种是携带filterArg的AggregateCall,一种只是聚合函数参数里嵌套了一个CASE表达式。

这种分叉会让后续规则非常尴尬。举一个实际例子:如果你的执行器只认识某种“不带 FILTER 的聚合”表示,或者某些物理转换规则只在CASE表达式上才做匹配,那么同样的业务语义,会因为写法不同而得到完全不同的优化效果。AggregateFilterToCaseRule就是为了消除这种分叉而生:它把带filterArg的聚合调用,统一改写成用CASE WHEN包住参数的形式。

1.3 规则的真实收益在哪里

从 SQL 层面看,这个规则只是改了个表达形式,似乎没有减少计算量。真正的收益在三个看不见的地方:

第一是计划形态标准化。所有聚合过滤只要过一遍这个规则,就变成CASE WHEN包裹参数的样子,后续规则不用维护两套匹配逻辑。这就像把团队里所有人都要求用同一种代码风格,短期看多了一次重写,长期看维护成本大幅下降。

第二是字段裁剪变得可行。RelFieldTrimmer这类工具在裁剪无用字段时,会尝试把RelNode里不再被上层引用的列删掉。但 FILTER 子句里的列是隐式依赖,很多裁剪逻辑不会把它当作普通输入列来分析。一旦 FILTER 变成参数里的CASE WHEN,这些列就成了聚合函数的显式参数,裁剪和推导规则才能正确识别它到底引用了哪些输入。

第三是为更激进的聚合优化打开入口。比如某些引擎会把COUNT(CASE WHEN cond THEN 1 END)识别成“按条件计数”的专用算子,或者进一步改写成SUM(CASE WHEN cond THEN 1 ELSE 0 END)。如果计划里带着filterArg,这种改写就得专门写一套规则去处理;做了FILTER到CASE的转换后,后续优化路径全部复用普通表达式推断能力,性价比很高。

2. 源码级拆解:匹配逻辑与转换步骤

2.1 规则匹配条件与触发场景

从规则命名就能看出来,它的核心动作是“Aggregate 里的 Filter 转成 Case”。在 Calcite 的规则体系里,它属于RelOptRule,匹配目标是Aggregate节点。

触发条件比我预期中要简单,我整理成这几个点:

  • 当前Aggregate至少存在一个AggregateCall,且filterArg >= 0。如果所有调用的filterArg都是-1,说明根本没有 FILTER,规则直接放弃。
  • AggregateCall的函数参数是确定可重写的类型。COUNT、SUM、AVG、MIN、MAX这类常见函数都可以处理,但并不是所有实现都能无脑套CASE WHEN。
  • 这个Aggregate最好是逻辑阶段的节点。如果已经转换成某个物理执行算子,规则往往不会再去动它。

在一个标准的onMatch流程里,规则会读取当前Aggregate的调用列表,逐个检查filterArg,找到需要转换的调用后,通过RexBuilder构造新的RexNode,替换成新的AggregateCall,最后用call.transformTo(newAggregate)生成替代计划。

有一点我要特别提醒:规则触发时通常不会改变groupSet,也不会改动Aggregate本身的分组语义。它只负责把“过滤条件”从filterArg挪进聚合参数,整体行数和分组边界都不变。这保证了重写前后结果一定一致,也是这条规则能够被默认应用的前提。

2.2 转换示意:FILTER 变为 CASE

用一个最简单的例子看效果。原始 SQL:

SELECT dept_no, COUNT(*) FILTER (WHERE salary > 10000) AS high_cnt FROM employee GROUP BY dept_no;

经过AggregateFilterToCaseRule之后,等价变成:

SELECT dept_no, COUNT(CASE WHEN salary > 10000 THEN 1 END) AS high_cnt FROM employee GROUP BY dept_no;

注意这后面的细节:为什么THEN后面是1而不是其他值?因为原始的COUNT(*)没有参数,改成CASE WHEN之后需要补一个非空的常量作为计数对象。这个常量只要保证非空就行,取1是最常见的做法。

更重要的是ELSE分支。规则生成的CASE表达式必须是ELSE NULL,不能写成ELSE 0。原因很好理解:COUNT是数非空的个数,如果ELSE 0,条件不满足的行会变成数字0参与计数,那结果就是错的;只有ELSE NULL,条件不满足的行才会被COUNT自动忽略。

对于带参数的聚合,转换逻辑更自然。比如:

SUM(amount) FILTER (WHERE gmt_paid >= '2024-01-01') --> SUM(CASE WHEN gmt_paid >= '2024-01-01' THEN amount END)

SUM天然忽略 NULL,所以当条件不成立时,CASE返回 NULL,该行不会累加。AVG、MIN、MAX同理。

2.3 边界情况:COUNT(DISTINCT) 与嵌套表达式

COUNT(DISTINCT x) FILTER (WHERE cond)这种写法容易让人犹豫:DISTINCT和CASE WHEN放在一起会不会有奇怪的语义?

实际上转换是安全的:

COUNT(DISTINCT user_id) FILTER (WHERE status = 'PAID') --> COUNT(DISTINCT CASE WHEN status = 'PAID' THEN user_id END)

DISTINCT去重的是CASE的结果。当条件不满足时,CASE返回 NULL,NULL 不计入COUNT,和 FILTER 的过滤效果一致;当条件满足时,返回原始user_id,去重逻辑不变。

还有一种比较常见的场景是聚合参数本身已经是复杂表达式,比如:

SUM(amount * quantity) FILTER (WHERE discount > 0)

转换后就是SUM(CASE WHEN discount > 0 THEN amount * quantity END)。从 Calcite 的RexNode角度来看,它只是把原始表达式作为CASE的THEN分支包了一层,不影响内部表达式结构。

不过也有需要小心的情况。像某些非标准聚合函数,或者带ORDINALITY、WITHIN GROUP这种特殊语法的调用,直接套CASE WHEN可能踩到类型不匹配或语义变化。所以规则内部通常会维护一个可重写函数集合,不是见到 FILTER 就无脑转换。你在自定义规则时也要遵循这个原则,别把ARRAY_AGG这种复杂聚合也拿CASE去乱包。

3. 把规则接入你的优化器

3.1 在 HepPlanner 中注册

多数人接 Calcite 时用的是HepPlanner,也就是启发式规则引擎,规则按固定顺序执行,不依赖成本模型。如果你希望计划树稳定地经过AggregateFilterToCaseRule,可以在构建Program时把它加进规则集合:

Program program = Programs.hep( ImmutableList.of( CoreRules.AGGREGATE_FILTER_TO_CASE, CoreRules.AGGREGATE_PROJECT_MERGE, CoreRules.FILTER_PROJECT_TRANSPOSE ), false, RelOptPlanner.Strategy.RULE_SEQUENCE);

RULE_SEQUENCE模式会严格按照列表顺序触发规则,这对验证单条规则的效果很有帮助。我在调优器的时候喜欢先把规则列表精简到最小,逐步加规则,避免多个规则互相干扰。

有一点需要注意:规则列表的顺序决定了CASE生成之后是否有机会被后续规则进一步化简。如果把AggregateFilterToCaseRule放在比较靠后的位置,前面那些规则已经匹配过旧的带 FILTER 结构,自然就错过了新的CASE形态;放在靠前的位置,后面规则能马上看到CASE表达式,优化链更顺。

3.2 在 VolcanoPlanner 中配置

如果项目用的是默认的VolcanoPlanner,也就是基于代价的优化器,注册方式不太一样。你需要在构建FrameworkConfig的RuleSet里显式加上这条规则:

planner.addRule(AggregateFilterToCaseRule.Config.DEFAULT.toRule());

由于VolcanoPlanner靠代价驱动,它不一定保证规则对所有符合条件的节点立即触发,而是会结合RelSubset的探索过程来决定。大多数情况下,加上这个规则后,VolcanoPlanner会自动把它产生的等价计划纳入比较,不需要额外设置。

需要留意的是,如果你用的是非常老的 Calcite 版本,规则类可能不是以Config.DEFAULT.toRule()这种方式注册,而是直接暴露一个单例字段。建议以你当前版本的 API 为准。

3.3 通过 EXPLAIN 验证转换是否生效

规则到底有没有触发,不能靠猜。我在本地验证时通常写一段小代码,把转换前后的RelNode用RelOptUtil.toString()打印出来。以最初的例子为例,转换前大概是这样的形态:

LogicalAggregate(group=[{0}], high_cnt=[COUNT() FILTER $2]) LogicalProject(dept_no=[$0], salary=[$1], $f2=[>($1, 10000)]) LogicalTableScan(table=[[employee]])

注意FILTER $2,它表示聚合调用挂着一个指向第 2 个输出字段的过滤条件。转换后的形态应该是:

LogicalAggregate(group=[{0}], high_cnt=[COUNT(CASE WHEN >($1, 10000) THEN 1 END)]) LogicalProject(dept_no=[$0], salary=[$1]) LogicalTableScan(table=[[employee]])

看到FILTER消失,聚合参数变成CASE WHEN,就说明规则生效了。同时注意,LogicalProject里原来专门为 FILTER 生成的那一列也不见了,因为过滤器已经被内联到聚合参数里,不再需要额外的投影列。

4. 真实项目中的收益与取舍

4.1 案例:订单数据多条件统计

我曾经在一个数据统计模块里处理过这样一张自由表,为了说明问题,字段简化成:category、pay_status、reject_reason、gmt_paid。业务上需要按类目统计付费单数、拒单数和已付款金额,一个自然 SQL 写法是:

SELECT category, COUNT(*) FILTER (WHERE pay_status = 'PAID') AS paid_cnt, COUNT(*) FILTER (WHERE reject_reason IS NOT NULL) AS reject_cnt, SUM(amount) FILTER (WHERE gmt_paid >= '2024-01-01') AS paid_amount FROM order_detail GROUP BY category;

这套 SQL 交付到下游某个 OLAP 引擎时,问题立刻暴露了:引擎的 SQL 解析器对FILTER (WHERE ...)的兼容性很差,甚至某些中间层直接把 FILTER 翻译成了子查询关联,查询慢了一个数量级。

4.2 转换前后的计划对比

在 Calcite 侧加完AggregateFilterToCaseRule之后,逻辑计划先变成这样:

LogicalAggregate(group=[{0}], paid_cnt=[COUNT(CASE WHEN =(pay_status, 'PAID') THEN 1 END)], reject_cnt=[COUNT(CASE WHEN IS NOT NULL(reject_reason) THEN 1 END)], paid_amount=[SUM(CASE WHEN >=(gmt_paid, '2024-01-01') THEN amount END)]) LogicalProject(category=[$0], pay_status=[$1], reject_reason=[$2], gmt_paid=[$3], amount=[$4]) LogicalTableScan(table=[[order_detail]])

这个计划的好处立刻能看到:

  • 聚合调用全部变成单一参数表达式,不再有filterArg,执行器生成代码时不需要再维护一套“先判断过滤条件再决定是否参与聚合”的逻辑。
  • 引擎如果支持把SUM(CASE WHEN ...)改写为条件聚合算子,就能直接命中快速路径。
  • 如果某个字段在所有聚合里都不再被引用,字段裁剪规则可以更安全地把它删掉,因为 FILTER 里不再藏着隐式依赖。

实际验证下来,把规则加到优化链后,逻辑计划统一成了CASE形态,再往下游翻译时,SQL 里不再出现FILTER关键字,查询耗时从原来接近秒级下降到百毫秒级。真正起作用的不是规则本身,而是转换之后暴露出的、能匹配下游引擎语义的优化机会。

4.3 什么时候不该用这个规则

任何优化规则都有两面性。AggregateFilterToCaseRule并不是在所有场景下都能带来收益。

如果你的目标执行引擎对FILTER子句支持得很好,甚至原生执行器专门针对 FILTER 做了快速路径,那强行转换成CASE反而可能丢失原有优势。尤其当过滤条件非常复杂、参数又很大的时候,CASE表达式会在聚合参数里保留一个较大子树,影响后续表达式的化简效率。

另外,如果后续有规则专门识别FILTER,比如要把聚合过滤往Filter节点下推,那么你在它之前做FILTER -> CASE转换,等于把信息藏进了表达式里,可能导致那条规则失效。引入任何规则之前,都要先梳理清楚整个规则链的意图和依赖关系。

5. 高频问题与避坑实录

5.1 为什么我加了规则却没触发

这是我被问得最多的问题。加了规则,计划没变化,通常不是规则本身的问题,而是以下原因之一:

可能原因排查方式
Program规则列表里没有加这条规则检查Programs.hep或自定义 RuleSet
当前Aggregate已经是物理算子只在LogicalAggregate阶段生效
聚合调用本身没有filterArg确认 SQL 确实用了FILTER (WHERE ...)
规则顺序靠后,前面规则已经改变了结构单测时把规则列表精简到只剩一条
上游把 SQL 改写成了CASE WHEN形态打印计划树,看参数里有没有CASE

一种很容易被忽略的情况是:有些团队用了RelBuilder直接构建计划,而不是通过 SQL 解析。RelBuilder.aggregate的调用里,如果手动传入的是COUNT(CASE WHEN ...)而不是FILTER模式,那从根上就没有filterArg,规则当然不会触发。碰到这种问题,先打印RelNode.toString(),看聚合参数里到底是FILTER还是CASE。

5.2 CASE 与 NULL 的坑

CASE的ELSE分支是写NULL还是写0,这是最容易写错的地方。我见过不止一次有人为了“让条件不满足的行变成 0”,把表达式写成:

COUNT(CASE WHEN cond THEN 1 ELSE 0 END)

这在语义上完全不等价。COUNT对 NULL 不计数,但对 0 会计数。条件不满足的行返回的是0,而这些行原本在 FILTER 下是被排除的,计数结果会多出一大截。

正确写法是ELSE NULL,或者干脆省略ELSE,因为CASE的默认ELSE就是 NULL。这一点在自定义实现规则的时候也要格外小心。

另一个相关问题是CASE类型推断。如果THEN分支是字符串类型,而ELSE分支是 NULL,Calcite 会推断出字符串类型,问题不大;但如果THEN分支是某种自定义 UDT,可能需要显式加 CAST,否则规则会抛出类型异常。

5.3 与谓词下推的区别

很多新手会把AggregateFilterToCaseRule和谓词下推混为一谈。这两者作用层面不一样:

  • 普通谓词下推,针对的是Filter节点,把WHERE条件从上层推到TableScan之上,减少扫描数据量。
  • AggregateFilterToCaseRule,处理的是聚合函数自带的FILTER子句,它不会减少表的扫描范围,只改变聚合调用的结构。

举个例子,WHERE status = 'PAID'是普通过滤,会被Filter下推;而COUNT(*) FILTER (WHERE status = 'PAID')是聚合过滤,它不应该被当作普通 WHERE 下推,因为那样会破坏分组语义。理解到这一层,你才不会被计划树上同时出现的多个 Filter 搞晕。

5.4 和其他聚合规则的配合关系

Calcite 里和聚合相关的规则不少,AggregateProjectMerge、AggregateFilterTranspose、AggregateExpandDistinctAggregates都会和这条规则产生互动。

我的建议是:

  • 如果要做DISTINCT聚合展开,尽量先执行AggregateFilterToCaseRule,让所有聚合调用形态统一,展开逻辑更简单。
  • 如果要做字段裁剪,也尽量把这条规则放在裁剪规则之前,让 FILTER 依赖的列显式出现在参数里。
  • 如果你依赖某个规则匹配FILTER结构做下推,那就别引入这条规则,或者把顺序调到最后,避免计划形态被提前改写。

规则之间没有绝对的最佳顺序,最好通过一组有代表性的查询反复对比验证。

写在最后:几条实践心得

这个规则给我的最大启发,是“等价重写”在优化器里的价值。我们平时写规则时,总想着怎么减少计算量,但很多时候优化器缺的并不是一个聪明的下推算法,而是一个把各种写法统一成同一种形态的转换器。AggregateFilterToCaseRule就是这样一类基础但重要的规则,它做的事情不多,却为后续一堆规则铺平了路。

如果你正准备在自己的 Calcite 项目里做类似优化,我建议先从打印计划树开始,亲手对比一下 FILTER 写法转换成 CASE 写法前后计划的变化。你可能会发现,很多看似复杂的执行计划问题,根源都在于优化器没能认清两个表达式其实是同一个人。规则越简单,越要弄清楚它到底在什么阶段、什么条件下生效,这样你才能放心地把整条优化链路交给它。

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

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

立即咨询