【Calcite 系列】深入理解 Calcite 的 SetOpToFilterRule

本文围绕 SetOpToFilterRule 的源码实现,介绍 Calcite 如何把多个来自同一数据源、仅谓词不同的 UNION / INTERSECT / MINUS 分支,改写成单个数据源加复合过滤条件的等价形式,从而消除重复表扫描、增强谓词下推能力。


一、问题背景:为什么集合操作难优化?

对于优化器来说,下面这种写法非常常见,但往往不太友好:

sql 复制代码
SELECT mgr, comm FROM emp WHERE mgr = 12
UNION
SELECT mgr, comm FROM emp WHERE comm = 5

原因主要有几类:

  • 两个分支扫描的是同一张表 emp,产生了重复 I/O
  • 优化器难以跨 UNION 边界统一推导谓词
  • 后续的谓词下推、统计信息传播都受到 SetOp 节点的阻碍

SetOpToFilterRule 的作用,就是把上面的写法改写成下面这种:

sql 复制代码
SELECT DISTINCT mgr, comm FROM emp WHERE mgr = 12 OR comm = 5

二、这条规则做了什么?

一句话概括:

它把多个来自同一数据源、仅谓词不同的 SetOp 分支,合并为单个源加复合 Filter,消除重复扫描。

不同集合操作的谓词合并策略:

操作类型 合并方式
UNION cond1 OR cond2 OR cond3
INTERSECT cond1 AND cond2 AND cond3
MINUS / EXCEPT cond1 AND NOT(cond2) AND NOT(cond3)

这样做有两个直接好处:

  1. 同一张表只扫描一次,显著降低 I/O 代价
  2. 谓词合并到单个 Filter 节点后,后续规则(如 FilterTableScanRule)更容易将条件推入存储层

三、一个最小例子

原始逻辑计划可以想象成:

复制代码
LogicalUnion[all=false]
  ├── LogicalFilter[condition=$3=12]
  │     └── TableScan[emp]
  └── LogicalFilter[condition=$6=5]
        └── TableScan[emp]

改写后变成:

复制代码
LogicalAggregate[distinct]
  └── LogicalFilter[condition=$3=12 OR $6=5]
        └── TableScan[emp]

两个 TableScan 变成了一个,Filter 条件通过 OR 合并,最后补 DISTINCT 保持 UNION 的去重语义。


四、规则何时触发?

规则入口是 onMatch(RelOptRuleCall call),它针对 UnionIntersectMinus 三种类型分别注册了 Config:

java 复制代码
Config UNION = ImmutableSetOpToFilterRule.Config.builder()
    .withMatchHandler((rule, call) -> SetOpToFilterRule.match(call))
    .build()
    .withOperandSupplier(b0 -> b0.operand(Union.class).anyInputs())
    .as(Config.class);

4.1 match 的前置判断

源码里的判断非常直接:

java 复制代码
final SetOp setOp = call.rel(0);
if (setOp.all || inputs.size() < 2) {
    return;
}

也就是说:

  • all=true(即 UNION ALL)不触发,因为语义不同,不能直接 OR 合并
  • 输入数量必须 ≥ 2

4.2 核心判断:是否存在可合并的分支

java 复制代码
if (sourceToConds.size() == inputs.size()) {
    return;
}

如果每个输入都来自不同的源,没有任何可以合并的分组,则直接退出;只有至少两个输入来自同一个源时,规则才真正触发


五、不是所有分支都能合并

规则虽然目标是合并同源分支,但并不是无脑合并所有条件。

关键辅助方法是 extractSourceAndCond,负责从每个输入中提取 (source, condition) 对。

5.1 extractSourceAndCond 的策略

java 复制代码
private static Pair<RelNode, @Nullable RexNode> extractSourceAndCond(RelNode input) {
    if (input instanceof Filter) {
        Filter filter = (Filter) input;
        if (!RexUtil.isDeterministic(filter.getCondition())
                || RexUtil.SubQueryFinder.containsSubQuery(filter)) {
            // 非确定性或含子查询:整个输入作为"源",条件置为 null
            return Pair.of(input, null);
        }
        return Pair.of(filter.getInput().stripped(), filter.getCondition());
    }
    // 非 Filter 输入:源就是它自身,条件默认为 TRUE
    return Pair.of(input.stripped(),
            input.getCluster().getRexBuilder().makeLiteral(true));
}

逻辑非常清晰:只在充分安全 的表达式下才拆出谓词;不安全时,把整个节点当作独立源,条件标记为 null(不参与合并)。

5.2 分组时的索引标记策略

java 复制代码
sourceToConds.computeIfAbsent(
    Pair.of(pair.left, pair.right != null ? null : i),
    k -> new ArrayList<>()).add(pair.right);

可合并的条件,key 中的 position 字段为 null,自然归入同一组;不可合并的,用原始索引 i 区分,确保单独保留,不影响其他分支。


六、什么样的分支算"可合并分支"?

6.1 必须是确定性表达式

RAND() 这类每次调用结果不同的函数,合并后执行语义会发生变化,因此被拒绝:

java 复制代码
if (!RexUtil.isDeterministic(filter.getCondition())) {
    return Pair.of(input, null);
}

例如:

  • WHERE mgr = 12:✅ 可合并
  • WHERE mgr = RAND():❌ 不合并

6.2 不能包含子查询

java 复制代码
if (RexUtil.SubQueryFinder.containsSubQuery(filter)) {
    return Pair.of(input, null);
}

子查询的执行上下文依赖外部,不能简单地用 OR / AND 合并。

6.3 非 Filter 输入直接视为 TRUE 条件

如果某个 SetOp 的输入根本没有 Filter 节点(例如直接就是 TableScan),条件默认补充为 TRUE,仍然参与合并:

sql 复制代码
SELECT mgr, comm FROM emp
UNION
SELECT mgr, comm FROM emp WHERE mgr = 12

改写为:

sql 复制代码
SELECT DISTINCT mgr, comm FROM emp WHERE TRUE OR mgr = 12
-- 经过常量折叠等价于
SELECT DISTINCT mgr, comm FROM emp

七、为什么合并后语义仍然正确?

这是整条规则最关键的正确性问题。

7.1 UNION 的正确性

原始 UNION 本身带有去重语义,合并后加 DISTINCT 保持等价:

复制代码
UNION[all=false] → Filter[cond1 OR cond2] + DISTINCT

满足 cond1 OR cond2 的行集合,经 DISTINCT 后,与原 UNION 输出完全相同。

7.2 INTERSECT 的正确性

INTERSECT 要求行同时出现在两个分支,即同时满足两个条件。对同一张表:

复制代码
emp WHERE cond1 INTERSECT emp WHERE cond2
  =
emp WHERE cond1 AND cond2

7.3 MINUS 的正确性

EXCEPT 的语义是"第一个结果集中删掉出现在第二个结果集中的行"。对同一张表:

复制代码
emp WHERE cond1 EXCEPT emp WHERE cond2
  =
emp WHERE cond1 AND NOT(cond2)

源码里对应实现就在 andFirstNotRest

java 复制代码
private static RexNode andFirstNotRest(RelBuilder builder, List<RexNode> conds) {
    List<RexNode> allConds = new ArrayList<>();
    allConds.add(conds.get(0));
    for (int i = 1; i < conds.size(); i++) {
        allConds.add(builder.not(conds.get(i)));
    }
    return builder.and(allConds);
}

第一个条件保持不变,其余条件取反后与第一个 AND 合并,精确实现差集语义。


八、UNION 的改写方式

原始形式

sql 复制代码
SELECT mgr, comm FROM emp WHERE mgr = 12
UNION
SELECT mgr, comm FROM emp WHERE comm = 5

改写形式

sql 复制代码
SELECT DISTINCT mgr, comm FROM emp WHERE mgr = 12 OR comm = 5

源码对应:

  • combineConditions(...)builder.or(conds)
  • buildSetOp(...)builder.union(false, count)
  • 最后追加 .distinct()

九、INTERSECT 的改写方式

原始形式

sql 复制代码
SELECT mgr, comm FROM emp WHERE mgr = 12
INTERSECT
SELECT mgr, comm FROM emp WHERE comm = 5

改写形式

sql 复制代码
SELECT DISTINCT mgr, comm FROM emp WHERE mgr = 12 AND comm = 5

INTERSECT 本身就有去重语义,改写后 .distinct() 同样保证结果等价。


十、MINUS(EXCEPT)的改写方式

原始形式

sql 复制代码
SELECT mgr, comm FROM emp WHERE mgr = 12
EXCEPT
SELECT mgr, comm FROM emp WHERE comm = 5

改写形式

sql 复制代码
SELECT DISTINCT mgr, comm FROM emp WHERE mgr = 12 AND NOT(comm = 5)

多分支 EXCEPT

sql 复制代码
SELECT deptno FROM emp WHERE deptno = 12
EXCEPT
SELECT deptno FROM emp WHERE deptno = 5
EXCEPT
SELECT deptno FROM emp WHERE deptno = 6

改写为:

sql 复制代码
SELECT DISTINCT deptno FROM emp
WHERE deptno = 12 AND NOT(deptno = 5) AND NOT(deptno = 6)

十一、多源混合时的处理方式

当输入中既有来自 emp 的,也有来自 dept 的,规则只合并同源分支,跨源之间保留原有 SetOp

原始形式

sql 复制代码
SELECT deptno FROM emp  WHERE deptno = 12
UNION
SELECT deptno FROM dept WHERE deptno = 5
UNION
SELECT deptno FROM emp  WHERE deptno = 6
UNION
SELECT deptno FROM dept WHERE deptno = 10

改写后

复制代码
LogicalUnion[all=false]
  ├── LogicalFilter[deptno=12 OR deptno=6]  →  TableScan[emp]
  └── LogicalFilter[deptno=5 OR deptno=10]  →  TableScan[dept]

4 个分支减少为 2 个,每张表只扫一次。


十二、如何在 Calcite 中启用这条规则

这条规则已注册在 CoreRules 中,分别对应三种集合操作:

java 复制代码
CoreRules.SET_OP_TO_FILTER_UNION
CoreRules.SET_OP_TO_FILTER_INTERSECT
CoreRules.SET_OP_TO_FILTER_MINUS

如果你在自定义 Planner / Program / RuleSet 中使用 Calcite,加入对应规则即可:

java 复制代码
Programs.ofRules(
    CoreRules.FILTER_MERGE,               // 建议先运行,把嵌套 Filter 合并展平
    CoreRules.SET_OP_TO_FILTER_UNION,
    CoreRules.SET_OP_TO_FILTER_INTERSECT,
    CoreRules.SET_OP_TO_FILTER_MINUS
)

注意 :官方注释中明确建议先执行 FILTER_MERGE,因为嵌套 Filter(如 Filter(Filter(Scan)))会影响规则对"同源"的判断。


十三、这条规则适合什么场景?

比较适合

  1. 多个分支扫描的是同一张表,仅过滤条件不同
  2. 希望减少物理执行中的表扫描次数
  3. 需要谓词合并以便后续统计信息推导更精确
  4. SQL 是由模板/代码动态拼接产生的,容易出现同源多分支写法

不一定适合

  1. 各分支来自不同数据源,规则无法合并
  2. Filter 条件含非确定性函数或子查询,规则会跳过该分支
  3. 需要 UNION ALL(保留重复行)的场景,规则不适用
  4. 分支很多且合并后谓词极其复杂,计划可读性下降

十四、这条规则的优点

1. 消除重复表扫描

最直接的收益:同一张表只扫描一次,I/O 代价直接减半(对于两路 UNION)。

2. 谓词集中,易于下推

将分散在多个分支的谓词合并为单个 Filter 节点后,后续规则(如 FilterTableScanRule)更容易把条件推入存储层。

3. 计划树更简洁

SetOp 节点数量减少,优化器搜索空间缩小,规划耗时降低。

4. 工程上保守安全

不是所有条件都合并,只处理确定性、无子查询的 Filter,设计保守,不破坏语义。


十五、局限与边界

1. 依赖 RelNode 引用相等判断"同源"

规则通过 .stripped() 后的 RelNode 对象引用是否相等来判断"同源",两个逻辑上等价但对象不同的节点不会被识别为同源。

2. 不支持 UNION ALL

all=true 时规则直接退出,因为 UNION ALL 不去重,语义与 OR 合并不等价。

3. 条件安全性限制

非确定性函数(RAND()NOW() 等)和子查询会阻止对应分支参与合并。这是正确且必要的限制,但也意味着这类写法无法受益于该规则。

4. 建议配合 FILTER_MERGE 使用

如果输入是嵌套 Filter,需先用 FILTER_MERGE 展平,否则规则可能无法识别"同源"节点。


十六、总结

SetOpToFilterRule 的本质,是把"多个同源、仅谓词不同的集合操作分支"折叠成"单个源加复合谓词":

  • UNION:用 OR 合并所有条件
  • INTERSECT:用 AND 合并所有条件
  • MINUS:第一个条件保持,其余取反后 AND

它的关键价值在于:

  • 消除重复表扫描,直接降低执行代价
  • 谓词集中后,后续优化规则更容易利用
  • 对不同集合操作类型做了语义保持的细致处理
  • 已作为 CoreRules 的一部分提供,便于直接启用

如果你正在分析 Calcite 的集合操作优化,这条规则值得细读,因为它同时体现了关系代数等价改写、谓词安全性检查,以及优化器工程中保守性与实用性的平衡。


十七、相关阅读位置

如果你接着往下看源码,推荐一起对照这几个位置:

  • core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
  • core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
  • core/src/main/java/org/apache/calcite/rel/rules/FilterMergeRule.java
  • core/src/main/java/org/apache/calcite/rex/RexUtil.java(重点看 isDeterministicSubQueryFinder

其中 RexUtil.isDeterministicRexUtil.SubQueryFinder.containsSubQuery 是规则安全性的核心守门方法,值得一起仔细阅读。

相关推荐
云栖梦泽9 分钟前
AI安全合规与治理:行业发展趋势与职业展望
大数据·人工智能·安全
小陈工10 分钟前
2026年4月2日技术资讯洞察:数据库融合革命、端侧AI突破与脑机接口产业化
开发语言·前端·数据库·人工智能·python·安全
得物技术10 分钟前
财务数仓 Claude AI Coding 应用实战|得物技术
大数据·llm·aiops
solihawk29 分钟前
分区大表统计信息不准确引发的性能问题
数据库
百结2141 小时前
postgresql日常运用
数据库·postgresql·oracle
rainy雨1 小时前
免费且好用的精益工具在哪里?2026年精益工具清单整理
大数据·人工智能·信息可视化·数据挖掘·数据分析·精益工程
蚂蚁数据AntData1 小时前
破解AI“机器味“困境:HeartBench评测实践详解
大数据·人工智能·算法·机器学习·语言模型·开源
前进的李工2 小时前
MySQL大小写规则与存储引擎详解
开发语言·数据库·sql·mysql·存储引擎
CoovallyAIHub2 小时前
Sensors 2026 | 从无人机拍摄到跑道缺陷地图,机场巡检全流程自动化——Zadar机场全跑道验证
数据库·架构·github
Jane - UTS 数据传输系统2 小时前
立足国家“十五五”数智化战略大局,紧扣上海“2+3+6+6”产业布局,UTS数据传输系统筑牢数智化转型数据底座
大数据·人工智能·跨平台·信创·跨数据库·十五五·国产数据库适配