本文围绕 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) |
这样做有两个直接好处:
- 同一张表只扫描一次,显著降低 I/O 代价
- 谓词合并到单个
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),它针对 Union、Intersect、Minus 三种类型分别注册了 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)))会影响规则对"同源"的判断。
十三、这条规则适合什么场景?
比较适合
- 多个分支扫描的是同一张表,仅过滤条件不同
- 希望减少物理执行中的表扫描次数
- 需要谓词合并以便后续统计信息推导更精确
- SQL 是由模板/代码动态拼接产生的,容易出现同源多分支写法
不一定适合
- 各分支来自不同数据源,规则无法合并
- Filter 条件含非确定性函数或子查询,规则会跳过该分支
- 需要
UNION ALL(保留重复行)的场景,规则不适用 - 分支很多且合并后谓词极其复杂,计划可读性下降
十四、这条规则的优点
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.javacore/src/main/java/org/apache/calcite/rel/rules/CoreRules.javacore/src/main/java/org/apache/calcite/rel/rules/FilterMergeRule.javacore/src/main/java/org/apache/calcite/rex/RexUtil.java(重点看isDeterministic、SubQueryFinder)
其中 RexUtil.isDeterministic 和 RexUtil.SubQueryFinder.containsSubQuery 是规则安全性的核心守门方法,值得一起仔细阅读。