ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Calcite优化:AggregateCaseToFilterRule将CASE聚合改写为FILTER提升SQL性能

Calcite优化:AggregateCaseToFilterRule将CASE聚合改写为FILTER提升SQL性能

当你在数据报表里写count(case when ... then 1 end)的时候,可能没有意识到,这行代码正在把优化器关在门外。Apache Calcite 里有一条专门针对这种场景的规则,叫AggregateCaseToFilterRule,它做的事情很纯粹:把聚合参数里的 CASE 表达式,改写成语义等价但更容易被优化的 FILTER 聚合,比如count(*) filter (where ...)。这条规则看起来很小,但它直接影响着一类高频 SQL 的下推能力和执行效率。这篇文章我会从使用场景、匹配模式、源码实现、最小复现 Demo,讲到它和相邻规则的联动,以及实际工程里容易踩的坑。无论你是用 Calcite 做 SQL 解析与优化,还是打算自己写类似的 RelOptRule,这篇应该都能给你一些启发。

1. 为什么 CASE WHEN 条件聚合需要一条专门规则

1.1 条件聚合在报表 SQL 中的“统治地位”

只要是和数据报表打交道的人,几乎每天都在写条件聚合。统计转化率,要算“有购买行为的用户数”;看留存,要算“注册后第 7 天仍活跃的用户数”;做 AB 实验,要在一个分组里同时算“实验组事件数”和“对照组事件数”。最朴素的写法就是count(case when ... then 1 end)或者sum(case when ... then 1 else 0 end)。

这种写法本身没有错,它在一组 SQL 里同时塞进多个口径,读起来也直白。但问题在于,它把“哪些行参与聚合”这个信息,藏进了一个聚合函数的表达式参数里面。优化器看到的是一个复杂的 RexCall,而不是一个独立的谓词。换句话说,你写起来很爽,优化器却很难从里面提取出能用来做下推、裁剪、并行化决策的东西。

我之前接手过一个慢查询排查,一条分组统计 SQL 跑了十几秒,条件里有分区字段上的时间判断。SQL 大概长这样:

select user_type, count(case when create_time >= '2024-01-01' and order_cnt > 0 then 1 end) as paid_cnt from t_user group by user_type;

从语义上,这个create_time >= '2024-01-01'明明可以做分区裁剪,但因为它是嵌在 CASE 里的,扫描阶段根本认不出来。这就是条件聚合最常见的问题:不是算不对,而是没法被优化。

1.2 问题不在语义,而在“可优化性”

CASE 版本的问题从来不是算错,而是不好算快。执行引擎要在一个聚合调用内部逐行算完 CASE,才知道这一行该不该进入累加逻辑。碰上复杂 CASE 或者一层套一层的表达式,算力都浪费在无意义的表达式计算上。

FILTER 语法是另一回事。count(*) filter (where create_time >= '2024-01-01' and order_cnt > 0)把谓词显式地挂在聚合外面,优化器一眼就能看到条件是什么。它可以把这个谓词下推到扫描阶段,可以结合分区元数据做裁剪,也可以在代码生成阶段生成“先判断、再累加”的清晰循环逻辑。语义上两者等价,但 FILTER 是“给优化器看的形态”。

更关键的是,FILTER 是标准 SQL 能力,不少主流引擎都支持。优化器内部有了这个显式形态,后续规则才有机会插手。CASE 版本为什么会需要一条专门规则?就是因为靠用户手写 FILTER 不现实,靠通用表达式化简规则也做不到语义级别的转写,必须有一条针对“聚合上下文中的 CASE”做定向改写的规则。

1.3 反直觉的认知差

很多开发者并不知道 FILTER 语法的存在,默认条件聚合只能写 CASE;还有一部分人知道 FILTER,但以为“SQL 怎么写都一样,优化器总会给我调优”。这个认知差在 Calcite 里尤其危险。

Calcite 的优化能力完全取决于它注册了哪些规则。规则没覆盖到的情况,它再“智能”也不会替你联想。AggregateCaseToFilterRule就是 Calcite 选择补上的一块拼图:它主动把常见的 CASE 型条件聚合翻译成 FILTER 型,让后续的规则和引擎能够接手。理解这条规则,其实也是在理解“优化器能做哪些事,不能做哪些事”的分界线。

2. 规则到底改写了什么:匹配模式与语义等价性

2.1 它能匹配的 CASE 形态

AggregateCaseToFilterRule不是所有带 CASE 的聚合都处理,它只处理特化过的模式。从我读源码和实际测试的结果看,核心约束是这样的:CASE 只有一个 WHEN 分支,没有多个条件;THEN 分支是常量 1,或是某个输入列;如果有 ELSE,ELSE 必须是 0 或者 NULL。聚合函数本身聚焦在 COUNT 和 SUM 上。

常见的转换关系我整理成了一张表:

原始写法改写后
count(case when c then 1 end)count(*) filter (where c)
count(case when c then 1 else 0 end)count(*) filter (where c)
sum(case when c then 1 else 0 end)count(*) filter (where c)或sum(1) filter (where c)
sum(case when c then x else 0 end)sum(x) filter (where c)
sum(case when c then x end)sum(x) filter (where c)

前两种场景做完改写后,聚合参数直接清空,变成count(*),FILTER 条件就是原来的 WHEN 条件。第三种稍微特殊一点,因为sum(case when c then 1 else 0 end)本身就是计数,转成count(*)是最简洁的形态;即使规则保持sum(1),在语义上也没有问题。第四种则保留了原聚合函数,只是把参数从整个 CASE 换成了 THEN 分支里的表达式 x,同时把条件挂到 FILTER 上。

2.2 为什么这些改写是等价的:三值逻辑

说清等价性,必须回到 SQL 的三值逻辑。CASE 在没有匹配分支且没有 ELSE 时,返回的是 NULL,不是 0。而COUNT会忽略 NULL,SUM也会忽略 NULL。这就是一切等价的根基。

以count(case when c then 1 end)为例:当 c 为 TRUE,CASE 结果是 1,参与计数;当 c 为 FALSE 或 NULL,CASE 结果是 NULL,被 COUNT 忽略。折算下来,只有 c 为 TRUE 的行贡献了一次计数。count(*) filter (where c)的行为是:先对每行判断 c,只有 c 为 TRUE 的行才能进入 count(*) 的统计范围。注意,如果 c 是 NULL,FILTER 条件不满足,这一行同样被排除。两者口径完全一致。

sum(case when c then x else 0 end)同理。c 为 TRUE 时累加 x;c 为 FALSE 或 NULL 时累加 0。0 对 SUM 没有影响,所以等价于先筛出满足 c 的行,再做sum(x),也就是sum(x) filter (where c)。这里有个容易想岔的点:如果 x 本身是 NULL,CASE 版本在 c 为 TRUE 时会返回 NULL,SUM 忽略它;FILTER 版本是先通过 c 的过滤,然后 sum(x) 遇到 NULL 也忽略它,行为仍然一致。

我常用一组小数据验证这个等价性。比如三行数据,c 的值分别是 TRUE、FALSE、NULL,x 分别是 5、6、7:

  • sum(case when c then x else 0 end)的结果是 5 + 0 + 0 = 5。
  • sum(x) filter (where c)先过滤掉 FALSE 和 NULL 两行,只剩 x=5,结果也是 5。

2.3 不满足条件的 CASE 会被拒之门外

规则最保守的地方在于,不会碰它不确定能等价的情况。举几个我在实际使用中遇到过的例子:

多 WHEN 的 CASE,比如case when c1 then 1 when c2 then 1 end。语义上它等价于count(*) filter (where c1 or c2),但规则没有做这种推导。因为从一个简单的模式匹配突然跳到需要构造 OR 条件,规则复杂度会上升,而且多 WHEN 的语义判断更容易出错。宁可少优化,不能算错。

ELSE 分支非 0 非 NULL 的更危险。count(case when c then 1 else 2 end)实际上统计的是所有行,不管 c 对不对,每行都会贡献一个非 NULL 的值。如果规则简单粗暴地把 ELSE 丢掉再转成count(*) filter (where c),结果直接少算一批。规则一旦出现这种错误改写,优化器就变成 bug 制造器了。

非简单表达式同样不碰。THEN 分支如果是函数调用、子查询、易变表达式,规则不会动。因为这些表达式的求值时机在改写前后可能发生变化,稍有不慎就引入语义偏差。Calcite 选择只处理常量 1 和输入列,本质上是在用“最保守的匹配”换取“等价性的绝对安全”。

3. 源码视角:从匹配到 FILTER 的一整套动作

3.1 先定位规则

AggregateCaseToFilterRule在org.apache.calcite.rel.rules包下面。使用的时候一般不用手动 new,Calcite 的RelOptRules类里已经暴露了对应的规则常量,按名字找就能看到。它是典型的RelOptRule子类,operand 匹配的是LogicalAggregate,也就是说它作用于逻辑计划阶段。

有一点需要注意:Calcite 的规则体系和版本绑定得很紧。不同大版本里规则注册方式从直接继承RelOptRule逐步迁移到RelOptRule.Config模式,常量字段的位置也会变。如果你在本地源码里找不到某个字段,先确认项目依赖的 Calcite 版本,再按版本去查,别直接照抄老帖。

3.2 匹配与变换的骨架逻辑

这条规则的核心流程可以概括成几步。我用伪代码来描述,它和真实源码的结构基本一致:

for aggCall in aggregate.aggCalls: for operand in aggCall.operands: if operand is CASE(cond, thenExpr, elseExpr) and isSupportedShape(operand): newCall = aggCall.toBuilder() .setFilter(cond) .setOperand(thenExpr) // 甚至清空操作数 .build() replace aggCall with newCall

第一步是遍历聚合调用的参数,找到 CASE 类型的 RexCall。第二步是检查这个 CASE 的形态,包括 WHEN 个数、THEN 值、ELSE 值。第三步是整个改写的核心:构造一个新的 AggregateCall。

构造新 AggCall 时有几个细节很值得学习。一是聚合函数本身通常不变,变的是操作数和 filter。二是操作数的替换规则根据函数类型不同有差异,COUNT 场景可以把操作数直接换成空参数,SUM 场景要把 CASE 整体替换成 THEN 表达式。三是 filter 字段接受的是 RexNode,也就是原来的 WHEN 条件,Calcite 内部会把它挂到 AggregateCall 的 filter 属性上。

整个变换不需要修改 Aggregate 的输入节点,只是把一个 AggCall 描述换成另一个语义等价的描述。这也是它风险低的原因之一:它不改变输入行数,不改变分组键,只改变“怎么聚合”这件事。

3.3 源码阅读时要注意的版本差异

我最早接触这条规则时,Calcite 的版本还比较老,AggregateCall里的 filter 是用filterArg表示的,也就是一个整数索引,指向输入表达式列表中的某个位置。后来版本把 filter 改成了直接持有 RexNode,语义更清晰,但如果你打开新版源码,又对着老文章里面的字段名去找,就会觉得很迷惑。

还有一点是RexBuilder和RelOptUtil里一些工具方法的迁移。比如构造 FILTER 条件、处理化简等辅助逻辑,在不同版本里可能从内部类挪到了辅助类。我的建议是把源码下载到本地,直接跳到AggregateCaseToFilterRule的onMatch方法里读,几十行代码,很容易看完整个逻辑。

3.4 这个规则是学习写 OptimizeRule 的好样本

如果你想写自己的 RelOptRule,这条规则可以作为入门教材。它比那些动辄涉及成本计算和计划枚举的规则简单太多,又能完整展示“匹配-检查-构造新节点-替换”的标准流程。

我推荐做三件事:第一,把它的 operand 定义抄一遍,理解RelOptRule怎么声明匹配目标;第二,读它的 transform 方法,看它如何用aggCall.toBuilder()构造新调用;第三,自己扩展一个小场景,比如处理 AVG 的 CASE 聚合,写一个新的规则,用 HepPlanner 做等价性验证。这套练习做完,你对 Calcite 规则机制的理解会上一个台阶。

4. 跑一个最小 Demo:观察改写前后

4.1 环境准备

看代码不如跑代码。我们用一个最简环境来观察这条规则的效果。项目只需要引入calcite-core,我用的是 1.35 版本,具体 API 如果和你用的版本不一致,定位到对应源码就行。

<dependency> <groupId>org.apache.calcite</groupId> <artifactId>calcite-core</artifactId> <version>1.35.0</version> </dependency>

内存里放一张 EMP 表,字段就三个:DEPTNO、AGE、SAL。目标 SQL 是:

select deptno, count(case when age > 30 then 1 end) as cnt from emp group by deptno;

4.2 用 HepPlanner 定向验证规则

VolcanoPlanner 里规则是否触发和成本模型有关,不适合做单规则验证。我习惯用 HepProgramBuilder,只往里面加一条AggregateCaseToFilterRule,这样能干净地看到它做了什么。核心代码大致是:

RelBuilder builder = RelBuilder.create(RelBuilder.Config.defaultConfig()); RelNode input = builder .scan("EMP") .aggregate( builder.groupKey("DEPTNO"), builder.count(false, "C", builder.call(SqlStdOperatorTable.CASE, builder.call(SqlStdOperatorTable.GREATER_THAN, builder.field("AGE"), builder.literal(30)), builder.literal(1), builder.literal(null)))) .build(); HepProgram program = new HepProgramBuilder() .addRuleInstance(RelOptRules.AGGREGATE_CASE_TO_FILTER) .build(); HepPlanner planner = new HepPlanner(program); planner.setRoot(input); RelNode optimized = planner.findBestExp(); System.out.println(RelOptUtil.toString(optimized));

这段代码是示意性的,实际跑起来还要准备 schema 信息,RelBuilder 的某些 API 在不同版本里 signatures 也不同。但我建议你自己把环境搭起来,因为打印出来的 RelNode 树比任何文字描述都直观。

4.3 观察改写前后

改写前的 AggCall 大概长这样,参数是一个 CASE 表达式:

LogicalAggregate(groupSet=[{0}], cnt=[COUNT(CASE(>(AGE, 30), 1, null))])

经过AggregateCaseToFilterRule之后,计划会变成:

LogicalAggregate(groupSet=[{0}], cnt=[COUNT(*) FILTER (>(AGE, 30))])

注意看变化:聚合函数的参数从 CASE 表达式变成了空参数,也就是COUNT(*);后面多了一个 FILTER 条件,内容是原来的>(AGE, 30)。这一步就是我前面反复说的,把“藏在参数里的谓词”提取成了显式 FILTER。

4.4 版本不同,输出格式会有差异

我在自己环境里验证过上述改写,但不同 Calcite 版本的 RelNode toString 格式差别不小。比如老版本可能打印成COUNT(CASE...),说明规则没触发;新版本打印成COUNT(*) FILTER(...),说明触发成功。看到计划串里出现FILTER关键词,基本可以确认改写生效。如果你的输出里 CASE 还在,优先检查规则是否真的被 HepProgram 注册进去了,这是最常见的翻车点。

5. 与相邻规则的联动:它能给后续优化打开什么门

5.1 从 CASE 到 FILTER,最大的红利是谓词下推

AggregateCaseToFilterRule本身不带来立竿见影的性能提升,它设置的是后续优化的“钩子”。最有价值的下游规则是AggregateFilterTransposeRule,它专门处理聚合上的 FILTER,把 FILTER 条件往输入方向推。

举一个 join 场景:订单表和用户表 join 之后按部门分组,统计已支付用户数。如果 SQL 写成count(case when o.status = 'PAID' then 1 end),这个o.status = 'PAID'埋在 CASE 里,优化器拿不到它。经过AggregateCaseToFilterRule转成count(*) filter (where o.status = 'PAID')之后,AggregateFilterTransposeRule就可以尝试把o.status = 'PAID'下推到 join 的右表,先过滤掉未支付订单,再参与 join。后者的执行成本可能差一个数量级。

分区表场景更明显。FILTER 条件如果落在分区键上,下推到扫描层之后就能直接做分区裁剪。而 CASE 版本连条件都提取不出来,更别说裁剪了。

5.2 FILTER 在执行引擎手里的优势

从执行引擎的角度看,FILTER 聚合也比 CASE 聚合友好。代码生成时,引擎可以直接生成一个“谓词判定 + 累加器更新”的紧凑循环,每行数据先过一遍 filter,过了才进入累加逻辑,逻辑链路非常短。CASE 版本则需要把表达式值算出来,聚合函数内部再判断它是不是 NULL,可能需要额外的一层中间值。

在分布式执行框架里,FILTER 还有一个附带好处:部分聚合阶段可以先过滤掉不满足条件的行,减少 shuffle 数据量。这些收益虽然不是这条规则直接产生的,但没有规则在前面做改写,后续阶段什么都拿不到。

5.3 规则顺序与规划器配置

规则之间的顺序很重要。用 HepPlanner 时,要把AggregateCaseToFilterRule放在AggregateFilterTransposeRule前面,否则后者没有机会消费 FILTER。用 VolcanoPlanner 时,规则执行顺序由优化器控制,但你仍然要想清楚整个规则集是否会因为这一条改写而引入更多计划分支。

我自己在实际项目中不会把全量规则无脑打开跑 VolcanoPlanner,计划空间膨胀带来的优化时间成本不可忽视。更可控的做法是分层优化:逻辑阶段用定向的 HepProgram 做好几类必要的改写,比如把 CASE 转 FILTER,再交给成本优化阶段去枚举物理计划。

6. 踩坑与边界:这些情况它不会处理,也不该处理

6.1 最常见的“手动改写翻车”

很多人知道 FILTER 等价于 CASE 之后,会手痒自己改 SQL。我见过最典型的错误是把sum(case when c then 1 else 2 end)改成sum(1) filter (where c)。这两个完全不等价,因为 ELSE 分支是 2,每一行都会往 SUM 里贡献值,正确的语义应该是不管 c 是什么,所有行都累加。改成 filter 之后,只有 c 为 TRUE 的行参与累加,结果直接少一大截。

还有一类错误是丢掉 COUNT 的 FILTER 条件。count(case when c then 1 end)和count(1)之间只差了一个 filter 吗?不,前者没有 filter 条件时,本质上统计全表,而 CASE 版本只统计满足 c 的行。很多人改写时记住了 FILTER 语法,忘了带条件,结果自然错得离谱。

6.2 NULL 聚合的隐蔽差异

COUNT 忽略 NULL 这个特性,在等价转换里是基石,但也容易造成隐蔽的错误。比如count(case when c then null end),无论 c 是什么,CASE 结果都是 NULL,COUNT 永远统计不到任何东西,结果恒为 0。如果规则盲目把它转成count(*) filter (where c),那统计的就不是 0 了。

SUM 场景里还有一个细节要留意:sum(case when c then x else 0 end)转成sum(x) filter (where c),要求 x 是普通列或常量,不能存在需要特殊处理的语义。如果 x 来自一个 volatile 函数,改写后求值次数可能发生变化,这种规则不该碰。Calcite 的规则保持简单匹配,宁可不转,不转错。

6.3 表达式代价与确定性

我在模拟项目里有一次是想把sum(case when expensive_function() > 0 then 1 else 0 end)转成 filter,攒了个很大的 CASE 表达式,信心满满地以为规则会处理。结果规则直接跳过了。原因不复杂:THEN 分支不是简单常量或输入列,规则不敢保证改写后语义完全一致。

这个保守策略对生产环境是友好的。表达式求值的时机变化,尤其遇到非确定性函数,可能让 SQL 从“每次返回随机结果”变成“某行被过滤前先算了函数”,行为差异说不清。如果你自己写类似的规则,强烈建议在模式匹配阶段就加上确定性和副作用检查,只处理简单、纯函数、可推断语义的表达式。

6.4 多条件统计的替代写法

如果你的统计口径是多条件 OR,比如“c1 或 c2 满足其一就算一次”,规则不会帮你合并成filter (where c1 or c2)。我的建议是直接在 SQL 里写两个 COUNT,最后相加:

select count(case when c1 then 1 end) + count(case when c2 then 1 end) as cnt from t;

或者直接写 FILTER 语法,让优化器少做一层猜测:

select count(*) filter (where c1 or c2) as cnt from t;

日常开发里,我更推荐后面这种写法。既然目标是让优化器更容易理解你的意图,为什么不直接给它最容易理解的形式呢?规则是用来兜底的,不是用来给不规范 SQL 擦屁股的。


最后说点个人体会。刚接触 Calcite 的时候,我总觉得这种“小规则”不值一提,真正难的是成本模型和 join 枚举。但在一个模拟项目里排查慢查询时,我发现一组报表 SQL 慢的根因就是条件聚合全部写成 CASE,导致分区裁剪和谓词下推全都失效。把AggregateCaseToFilterRule加到逻辑优化阶段之后,后续规则终于“接住了”那些谓词,整体计划立刻不一样了。从那以后,我读源码的顺序也改了,从最小的规则开始读,反而是理解整套优化器最快的方式。如果你也想写自定义规则,记住一点:规则不是越聪明越好,而是越安全越好,正例和反例的测试用例,一个都不能少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进