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

本文围绕 JoinExpandOrToUnionRule 的源码实现,介绍 Calcite 如何把带 OR 条件的 Join 改写成多个更容易优化的 Join 分支,并通过 UNION ALLANTI JOIN 和补 NULL 的方式保持语义等价。


一、问题背景:为什么 OR Join 难优化?

对于优化器来说,下面这种 Join 条件通常不太友好:

sql 复制代码
SELECT *
FROM t1
JOIN t2
  ON t1.id = t2.id
  OR t1.age = t2.age

原因主要有几类:

  • OR 会把一个 Join 条件变成"多个候选匹配路径的并集"
  • 优化器不容易把它当成标准的等值连接键来处理
  • 很难直接受益于哈希连接、索引连接或其它基于 join key 的优化
  • 不同 OR 分支之间可能重叠,简单拆开后会引入重复结果

JoinExpandOrToUnionRule 的作用,就是把后者拆成前者。


二、这条规则做了什么?

一句话概括:

它把 JOIN ON (A OR B OR C) 改写成一组互斥的 Join 分支,再用 UNION ALL 拼回原结果。

最经典的 INNER JOIN 等价改写是:

text 复制代码
A OR B OR C

改成:

text 复制代码
A
UNION ALL
(B AND NOT A)
UNION ALL
(C AND NOT A AND NOT B)

这样做有两个直接好处:

  1. 每个分支都更接近"普通 Join"
  2. 通过 NOT 前面分支,保证分支互斥,不会重复输出

三、一个最小例子

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

text 复制代码
Project[*]
   └── Join[OR(t1.id=t2.id, t1.age=t2.age), inner]
       ├── TableScan[t1]
       └── TableScan[t2]

改写后变成:

text 复制代码
Project[*]
   └── UnionAll
       ├── Join[t1.id=t2.id, inner]
       │   ├── TableScan[t1]
       │   └── TableScan[t2]
       └── Join[t1.age=t2.age AND NOT(t1.id=t2.id), inner]
           ├── TableScan[t1]
           └── TableScan[t2]

Join ON A OR B
分解为两个分支
Join ON A
Join ON B AND NOT A
UNION ALL

这张图就是这条规则最核心的思想。


四、规则何时触发?

规则入口是:

  • matches(RelOptRuleCall call)
  • onMatch(RelOptRuleCall call)

4.1 matches 的逻辑

源码里的判断非常直接:

java 复制代码
Join join = call.rel(0);
List<RexNode> orConds = RelOptUtil.disjunctions(join.getCondition());
return orConds.size() > 1;

也就是说,只有 Join 条件被按 OR 拆开后,分支数大于 1,规则才会触发。

例如:

  • a = b:不会触发
  • a = b OR c = d:会触发

4.2 onMatch 的逻辑

onMatch 会根据 Join 类型分发到不同处理分支:

  • INNER
  • ANTI
  • LEFT
  • RIGHT
  • FULL

如果展开后结果和原条件完全一样,则不做转换;否则 transformTo(expanded)


匹配到 Join
条件中是否含多个 OR 分支
不触发
Join 类型
INNER
ANTI
LEFT / RIGHT
FULL
展开并替换原 Join


五、不是所有 OR 都能拆

规则虽然是"Expand OR to UNION",但并不是把所有 OR 条件无脑展开。

关键辅助方法有:

  • splitCond(Join join)
  • isValidCond(RexNode node, int leftFieldCount)
  • isEquiJoinCond(RexCall call, int leftFieldCount)
  • doesNotReferToBothInputs(RexNode rex, int leftFieldCount)

5.1 splitCond 的策略

它会先取出所有 OR 分支,然后把它们分成两类:

  • 合法的、适合独立展开的分支
  • 不合法的、保留在合并 OR 中的分支

如果一个分支不适合单独展开,不会直接丢掉,而是会和其它类似分支重新组合成一个 OR 子句。

所以它更像是在做:

text 复制代码
从一个大 OR 中,挑出"值得拆"的部分

而不是简单的"所有分支都拆"。


六、什么分支算"合法分支"?

一个 OR 分支要被当成"单独 Join 分支",大致要满足这些条件。

6.1 至少包含一个真正的等值 Join 条件

也就是像:

sql 复制代码
t1.id = t2.id

或者:

sql 复制代码
t1.id IS NOT DISTINCT FROM t2.id

这种"左右输入各拿一个字段比对"的条件。

源码里只接受这两类操作符:

  • EQUALS
  • IS_NOT_DISTINCT_FROM

并且左右操作数都必须是 RexInputRef

6.2 必须是真正的跨左右输入比较

规则会要求:

  • 一个字段来自 Join 左输入
  • 另一个字段来自 Join 右输入

像下面这些通常不算有效 join key:

sql 复制代码
t1.id = 1
t1.id = t1.age
abs(t1.x) = t2.y

6.3 不能包含子查询或相关变量

这部分规则直接拒绝:

  • 子查询
  • correlation

因为这些场景语义更复杂,当前实现没有覆盖。


七、为什么拆完不会重复?

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

假设原始条件是:

text 复制代码
A OR B OR C

如果直接拆成:

text 复制代码
Join(A)
UNION ALL
Join(B)
UNION ALL
Join(C)

那一条同时满足 AB 的匹配,会在两个分支都出现,结果就错了。

所以规则实际做的是:

text 复制代码
Join(A)
UNION ALL
Join(B AND NOT A)
UNION ALL
Join(C AND NOT A AND NOT B)

这样每一行只会落在第一个命中的分支里。

源码里对应实现就在 expandInnerJoinToRelNodes(...)

java 复制代码
for (int i = 0; i < orConds.size(); i++) {
  RexNode orCond = orConds.get(i);
  for (int j = 0; j < i; j++) {
    orCond = relBuilder.and(orCond, relBuilder.not(orConds.get(j)));
  }
  ...
}

原条件: A OR B OR C
分支1: A
分支2: B AND NOT A
分支3: C AND NOT A AND NOT B
UNION ALL

这就是为什么它敢用 UNION ALL,而不是 UNION DISTINCT


八、INNER JOIN 的展开方式

这是最简单、也最容易理解的一种。

原始形式

sql 复制代码
SELECT *
FROM t1
JOIN t2
  ON t1.id = t2.id
  OR t1.age = t2.age

改写形式

text 复制代码
Join[t1.id=t2.id, inner]
UNION ALL
Join[t1.age=t2.age AND NOT(t1.id=t2.id), inner]

源码对应:

  • expandInnerJoin(...)
  • expandInnerJoinToRelNodes(...)

逻辑非常直白:

  1. 调用 splitCond(join)
  2. 为每个 OR 分支构造一个 INNER JOIN
  3. 为后续分支补上 NOT(previous conditions)
  4. 把所有分支 UNION ALL

Inner Join: A OR B
Join on A
Join on B AND NOT A
UNION ALL


九、ANTI JOIN 的展开方式

ANTI JOIN 的语义不是"找到匹配",而是"保留找不到匹配的左侧记录"。

如果条件是:

text 复制代码
A OR B

那么 anti 的含义更接近:

text 复制代码
NOT A AND NOT B

所以规则不会把它展开成多个 inner join 再 union,而是构造一串 ANTI JOIN 链

源码路径:

  • expandAntiJoin(...)
  • expandAntiJoinToRelNode(...)

可以理解成:

text 复制代码
先剔除满足 A 的行
再从剩余结果中剔除满足 B 的行
再继续剔除满足 C 的行

最后留下的,就是对整个 A OR B OR C 都不匹配的记录。
输入左表
ANTI JOIN on A
ANTI JOIN on B
ANTI JOIN on C
最终剩余 = 对 A/B/C 全都不匹配


十、LEFT JOIN / RIGHT JOIN 的展开方式

外连接比内连接复杂,因为它不仅要保留匹配结果,还要保留未匹配的一侧,并对另一侧补 NULL

所以规则对 LEFT JOIN / RIGHT JOIN 的处理分两部分:

  1. 用多个 INNER JOIN 分支处理"匹配上的部分"
  2. 用一个 ANTI JOIN + NULL padding 分支处理"未匹配部分"

LEFT JOIN 为例,可以理解成:

text 复制代码
LEFT JOIN = 匹配部分 + 左侧未匹配部分

其中:

  • 匹配部分:展开成多个 inner join,再 union all
  • 左侧未匹配部分:通过 anti join 得到,再为右边列补 null

源码路径:

  • expandLeftOrRightJoin(...)
  • expandLeftOrRightJoinToRelNodes(...)

LEFT JOIN ON A OR B
匹配部分: Inner Join on A
匹配部分: Inner Join on B AND NOT A
未匹配部分: Anti Join
Project 右侧列补 NULL
UNION ALL

RIGHT JOIN 同理,只是左右方向相反。


十一、FULL JOIN 的展开方式

FULL JOIN 要同时保留:

  • 匹配上的结果
  • 左侧未匹配结果
  • 右侧未匹配结果

因此它最终会拆成三大块:

  1. 多个 INNER JOIN 分支:处理匹配行
  2. 左侧 ANTI JOIN + NULL padding:处理左侧未匹配
  3. 右侧 ANTI JOIN + NULL padding:处理右侧未匹配

源码路径:

  • expandFullJoin(...)

最后它还会再做一次 project,把字段类型对齐到原来的 full join 输出类型。这一步是必要的,因为多个分支 union 以后,尤其经过 null padding,字段类型可能和原始 Join 输出略有偏差。
FULL JOIN ON A OR B
匹配部分: 多个 INNER JOIN 分支
左未匹配部分: LEFT ANTI + 右侧补 NULL
右未匹配部分: RIGHT ANTI + 左侧补 NULL
UNION ALL
Project / Cast 回原始输出类型


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

这条规则已经注册在 CoreRules 中:

  • CoreRules.JOIN_EXPAND_OR_TO_UNION_RULE

对应定义位于:

  • core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java

可以看到:

java 复制代码
public static final JoinExpandOrToUnionRule JOIN_EXPAND_OR_TO_UNION_RULE =
        JoinExpandOrToUnionRule.Config.DEFAULT.toRule();

因此,如果你在自定义 Planner / Program / RuleSet 中使用 Calcite,只需要把这条规则加进去即可。

举例来说,概念上通常会是:

java 复制代码
Programs.ofRules(
    CoreRules.JOIN_EXPAND_OR_TO_UNION_RULE
)

或者加入某个 HepPlanner / VolcanoPlanner 的规则集。


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

比较适合

  1. Join 条件确实包含多个 OR 分支
  2. 每个 OR 分支都比较像"标准等值连接键"
  3. 你希望优化器能把每个分支独立看待
  4. 直接保留 OR Join 时,物理计划选择经常不理想

不一定适合

  1. OR 分支很多,计划容易膨胀
  2. 分支里都是复杂表达式,不是标准字段对字段等值比较
  3. 原始执行器对 OR Join 已经处理得不错
  4. 外连接语义复杂,拆开后虽然等价,但代价可能更高

十五、这条规则的优点

1. 让优化器更容易识别 Join Key

直接写在 OR 里的多个等值条件,常常不容易被单独利用;拆成多个 Join 分支后,每个分支都更清晰。

2. 通过互斥分支避免重复结果

规则不是简单拆 OR,而是显式加 NOT(previous),确保 UNION ALL 仍然语义正确。

3. 覆盖多种 Join 类型

它不只处理 INNER JOIN,而且还系统处理了:

  • ANTI JOIN
  • LEFT JOIN
  • RIGHT JOIN
  • FULL JOIN

4. 工程上比较保守

不是所有 OR 分支都拆,而是筛选出更适合展开的条件。


十六、局限与边界

1. 计划可能膨胀

OR 分支越多,展开后的分支数越多。对于外连接,还要额外引入 anti join 和补 null 分支。

2. 只适合某些类型的 Join 条件

当前实现主要偏向:

  • 字段对字段
  • 跨左右输入
  • 等值或 IS NOT DISTINCT FROM

3. 子查询与 correlation 不支持

规则会直接拒绝这类条件。

4. 某些实现细节仍值得审查

比如前面提到的 RexInputRefCounter 逻辑,看起来就值得进一步验证。


十七、总结

JoinExpandOrToUnionRule 的本质,是把"一个带 OR 的复杂 Join"分解成"一组互斥的简单 Join 子问题":

  • INNER JOIN:做互斥分支展开
  • ANTI JOIN:做逐分支剔除
  • LEFT/RIGHT/FULL JOIN:把匹配部分和未匹配补 null 部分分别展开

它的关键价值在于:

  • OR Join 写法直观,但常常不利于优化;拆成多个简单分支后,优化器更容易做出更好的计划
  • NOT(previous branches) 保证 UNION ALL 语义正确,无重复
  • 对不同 Join 类型做了语义保持的细致处理
  • 已作为 CoreRules 的一部分提供,便于直接启用

如果你正在分析 Calcite 的 Join 优化规则,这条规则值得细读,因为它同时体现了关系代数等价改写、Join 语义保持,以及优化器工程中的保守性与实用性。


十八、相关阅读位置

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

  • core/src/main/java/org/apache/calcite/rel/rules/JoinExpandOrToUnionRule.java
  • core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
  • core/src/main/java/org/apache/calcite/rel/rules/JoinCommuteRule.java

其中 JoinCommuteRule.swapJoinCond(...) 在右侧 anti 展开时也会用到,值得一起看。

相关推荐
james的分享19 天前
大数据领域核心 SQL 优化框架Apache Calcite介绍
大数据·sql·apache·calcite
一只努力的微服务3 个月前
【Calcite 系列】将 INTERSECT 转换为 EXISTS
java·calcite
长路 ㅤ   7 个月前
Calcite自定义扩展SQL案例详细流程篇
代码生成·calcite·sql解析·javacc·自定义语法
张铁牛1 年前
6. Calcite添加自定义函数
db·calcite·middleware
张铁牛1 年前
5. 想在代码中验证sql的正确性?
db·calcite
张铁牛1 年前
4. 使用sql查询excel内容
db·calcite·middleware
张铁牛1 年前
3. 使用sql查询csv/json文件内容,还能关联查询?
db·calcite·middleware
张铁牛1 年前
2. 什么?你想跨数据库关联查询?
db·calcite·middleware
张铁牛1 年前
1. Calcite元数据创建
db·calcite