本文围绕 IntersectToSemiJoinRule 的源码实现,介绍 Calcite 如何把 INTERSECT 操作改写成一系列 SEMI JOIN,从而让优化器能利用 Join 相关的优化手段(哈希连接、索引等)来执行交集语义。
一、问题背景:为什么 INTERSECT 难优化?
对于优化器来说,下面这种写法很常见:
sql
SELECT ename FROM emp WHERE deptno = 10
INTERSECT
SELECT ename FROM emp WHERE ename IN ('a', 'b')
原因主要有几类:
INTERSECT通常被实现为排序 + 归并,或者 Hash 去重,代价较高- 优化器很难为
INTERSECT选择更灵活的执行策略(如索引) - 多路
INTERSECT的代价会随分支数线性增长
IntersectToSemiJoinRule 的作用,就是把上面的写法改写成:
sql
SELECT DISTINCT ename
FROM emp
SEMI JOIN emp ON IS NOT DISTINCT FROM(ename_left, ename_right)
WHERE deptno = 10 AND ename IN ('a', 'b')
让优化器有机会选择更高效的 Join 执行策略。
二、这条规则做了什么?
一句话概括:
它把
INTERSECT DISTINCT改写成一系列两两之间的SEMI JOIN,再加DISTINCT保持语义。
改写策略非常直接:
A INTERSECT B INTERSECT C
→
((A SEMI JOIN B) SEMI JOIN C) + DISTINCT
其中每个 SEMI JOIN 的连接条件是对应列的 IS NOT DISTINCT FROM,用来正确处理 NULL 值(INTERSECT 语义要求 NULL = NULL)。
三、一个最小例子
原始 SQL
sql
SELECT ename FROM emp WHERE deptno = 10
INTERSECT
SELECT CAST(deptno AS VARCHAR) FROM emp WHERE ename IN ('a', 'b')
INTERSECT
SELECT ename FROM empnullables
原始逻辑计划
LogicalIntersect(all=[false])
LogicalProject(ENAME=[$1])
LogicalFilter(condition=[=($7, 10)])
LogicalTableScan(table=[EMP])
LogicalProject(DEPTNO=[CAST($7):VARCHAR NOT NULL])
LogicalFilter(condition=[OR(=($1,'a'), =($1,'b'))])
LogicalTableScan(table=[EMP])
LogicalProject(ENAME=[$1])
LogicalTableScan(table=[EMPNULLABLES])
改写后的逻辑计划
LogicalAggregate(group=[{0}])
LogicalJoin(condition=[IS NOT DISTINCT FROM($0,$1)], joinType=[semi])
LogicalJoin(condition=[IS NOT DISTINCT FROM($0,$1)], joinType=[semi])
LogicalProject(ENAME=[CAST($0):VARCHAR])
LogicalProject(ENAME=[$1])
LogicalFilter(condition=[=($7, 10)])
LogicalTableScan(table=[EMP])
LogicalProject(ENAME=[CAST($0):VARCHAR])
LogicalProject(DEPTNO=[CAST($7):VARCHAR NOT NULL])
LogicalFilter(condition=[OR(=($1,'a'), =($1,'b'))])
LogicalTableScan(table=[EMP])
LogicalProject(ENAME=[CAST($0):VARCHAR])
LogicalProject(ENAME=[$1])
LogicalTableScan(table=[EMPNULLABLES])
三路 INTERSECT 被展开成两层嵌套 SEMI JOIN,最外层加 DISTINCT(通过 LogicalAggregate 实现)。
四、规则何时触发?
规则入口是 onMatch(RelOptRuleCall call),匹配 LogicalIntersect 节点:
java
Config DEFAULT = ImmutableIntersectToSemiJoinRule.Config.of()
.withOperandFor(LogicalIntersect.class);
4.1 前置判断
源码里的判断非常直接:
java
final Intersect intersect = call.rel(0);
if (intersect.all) {
return; // nothing we can do
}
if (inputs.size() < 2) {
return;
}
也就是说:
all=true(即INTERSECT ALL)不触发,当前不支持- 输入数量必须 ≥ 2
4.2 n 路 INTERSECT 的迭代处理
规则不是一次性把所有分支展开,而是从左到右迭代,每次把当前结果与下一个输入做一次 SEMI JOIN:
java
RelNode current = inputs.get(0);
for (int i = 1; i < inputs.size(); i++) {
RelNode next = inputs.get(i);
// ... 构造 SEMI JOIN
current = builder.peek();
}
这意味着 n 路 INTERSECT 最终变成 n-1 层嵌套的 SEMI JOIN。注释也明确说明:
This rule supports n-way Intersect conversion, as this rule can be repeatedly applied during query optimization to refine the plan.
五、为什么用 IS NOT DISTINCT FROM 而不是等号?
这是整条规则最关键的语义问题。
INTERSECT 对 NULL 的处理遵循 SQL 标准:
sql
NULL INTERSECT NULL → 结果包含 NULL
但普通等号 = 对 NULL 的行为是:
NULL = NULL → UNKNOWN(即 false)
所以如果用 = 作为 SEMI JOIN 条件,含 NULL 的行会被错误丢弃。
IS NOT DISTINCT FROM 则把 NULL 视为相等:
NULL IS NOT DISTINCT FROM NULL → true
源码中对每一列都使用这个操作符:
java
for (int j = 0; j < fieldCount; j++) {
joinPredicates.add(
builder.isNotDistinctFrom(
builder.field(2, 0, j),
builder.field(2, 1, j)));
}
final RexNode condition = RexUtil.composeConjunction(rexBuilder, joinPredicates);
builder.join(JoinRelType.SEMI, condition);
所有列的条件用 AND 组合(composeConjunction),确保多列情况下的精确匹配。
六、为什么要先做类型统一(Cast)?
INTERSECT 允许各分支的列类型不完全相同,但要求可以统一提升为一个公共类型(least type)。
规则在构造 SEMI JOIN 之前,先把左右两侧的每一列都 Cast 到这个公共类型:
java
private RelNode projectJoinInput(
RelBuilder builder, RelDataType leastRowType, RelNode joinInput) {
builder.push(joinInput);
final int fieldCount = joinInput.getRowType().getFieldCount();
final List<String> names = leastRowType.getFieldNames();
final List<RexNode> joinKeys = new ArrayList<>(fieldCount);
final RexBuilder rexBuilder = builder.getRexBuilder();
for (int j = 0; j < fieldCount; j++) {
final RelDataType leastType = leastRowType.getFieldList().get(j).getType();
joinKeys.add(rexBuilder.makeCast(leastType, builder.field(j)));
}
return builder.project(joinKeys, names).build();
}
leastRowType 来自 intersect.getRowType(),是 Calcite 在构建 Intersect 节点时已经计算好的全局公共类型。这个 Project + Cast 确保了 IS NOT DISTINCT FROM 在语义上的正确性。
七、为什么合并后语义仍然正确?
SEMI JOIN 的语义是:保留左侧中,在右侧能找到至少一条匹配记录的行。
所以:
A SEMI JOIN B ON IS NOT DISTINCT FROM(a_col, b_col)
等价于:
A 中,所有在 B 里存在匹配值(含 NULL)的行
这与 A INTERSECT B 完全一致。
最后加 DISTINCT(通过 builder.distinct())消除重复,保持 INTERSECT DISTINCT 的去重语义。
八、多路 INTERSECT 的改写方式
对于三路及以上的 INTERSECT,规则通过嵌套实现:
原始形式
sql
A INTERSECT B INTERSECT C
改写过程
第 1 步:A SEMI JOIN B → 结果记为 AB
第 2 步:AB SEMI JOIN C → 结果记为 ABC
最后:DISTINCT(ABC)
这与关系代数中交集的结合律一致:
(A ∩ B) ∩ C = A ∩ B ∩ C
九、如何在 Calcite 中启用这条规则
这条规则已注册在 CoreRules 中:
java
CoreRules.INTERSECT_TO_SEMI_JOIN
如果你在自定义 Planner / Program / RuleSet 中使用 Calcite,加入对应规则即可:
java
Programs.ofRules(
CoreRules.INTERSECT_TO_SEMI_JOIN
)
或者加入某个 HepPlanner / VolcanoPlanner 的规则集:
java
hepPlanner.addRule(CoreRules.INTERSECT_TO_SEMI_JOIN);
十、这条规则适合什么场景?
比较适合
- 两路或多路
INTERSECT DISTINCT操作 - 参与交集的列上有索引,希望通过 Join 走索引路径
- 执行引擎对
SEMI JOIN支持良好(如 Hash Semi Join) - 各分支数据量差异较大,Semi Join 可以利用小表驱动大表
不一定适合
INTERSECT ALL:规则不支持,直接跳过- 各分支数据量相近且都很大时,SEMI JOIN 与 Hash INTERSECT 代价相近
- 分支很多时,嵌套 SEMI JOIN 层数过深,计划变复杂
十一、这条规则的优点
1. 让优化器能利用 Join 的优化手段
INTERSECT 是一个特定算子,优化器对它的处理方式有限;改成 SEMI JOIN 后,哈希连接、索引 Nested Loop、Broadcast 等优化策略都可以参与竞争。
2. 正确处理 NULL 语义
通过 IS NOT DISTINCT FROM 而不是普通等号,保证了 SQL 标准对 INTERSECT 中 NULL 等值语义的正确实现。
3. 支持 n 路 INTERSECT
规则不依赖特定的双路结构,通过迭代可以把任意多路 INTERSECT 转化为等深嵌套的 SEMI JOIN 链。
4. 类型安全
在构造 SEMI JOIN 之前先做 Cast,确保各分支列类型对齐,不会因类型不匹配导致条件失效。
十二、局限与边界
1. 不支持 INTERSECT ALL
当 all=true 时规则直接退出。INTERSECT ALL 需要保留重复,用 SEMI JOIN 实现比较复杂,当前未覆盖。
2. 嵌套层数随分支数线性增长
n 路 INTERSECT 生成 n-1 层嵌套 SEMI JOIN,计划树变深,可能影响后续规则的匹配效率。
3. Cast 引入额外 Project 节点
每个 SEMI JOIN 的左右侧都会插入一个 Project 做类型统一,在列数较多时会增加计划复杂度。
4. 依赖执行引擎对 SEMI JOIN 的支持
改写后能否实际获益,取决于底层执行引擎是否对 SEMI JOIN 有良好的物理实现,如果引擎对 SEMI JOIN 支持有限,改写可能无法带来性能提升。
十三、总结
IntersectToSemiJoinRule 的本质,是把 INTERSECT DISTINCT 这个特殊的集合操作改写成优化器更熟悉的 SEMI JOIN 形式:
- n 路
INTERSECT变成 n-1 层嵌套SEMI JOIN - 连接条件使用
IS NOT DISTINCT FROM正确处理NULL - 各分支列 Cast 到公共类型保证类型安全
- 最终加
DISTINCT恢复去重语义
它的关键价值在于:
- 把优化器不擅长的
INTERSECT转化为它擅长的JOIN - 正确保持了
NULL等值语义(这是很多实现容易忽略的细节) - 支持任意路数的
INTERSECT,覆盖面广 - 已作为
CoreRules的一部分提供,便于直接启用
如果你正在分析 Calcite 的集合操作优化,这条规则值得细读,因为它同时体现了关系代数等价改写、NULL 语义的精确处理,以及优化器工程中"把陌生算子转成熟悉算子"的核心思路。
十四、相关阅读位置
如果你接着往下看源码,推荐一起对照这几个位置:
core/src/main/java/org/apache/calcite/rel/rules/IntersectToSemiJoinRule.javacore/src/main/java/org/apache/calcite/rel/rules/CoreRules.javacore/src/main/java/org/apache/calcite/rel/core/Intersect.javacore/src/main/java/org/apache/calcite/rex/RexUtil.java(重点看composeConjunction)
其中 IS NOT DISTINCT FROM 的语义以及 composeConjunction 的用法,是理解这条规则正确性的关键,值得一起仔细阅读。