本文围绕 JoinExpandOrToUnionRule 的源码实现,介绍 Calcite 如何把带 OR 条件的 Join 改写成多个更容易优化的 Join 分支,并通过 UNION ALL、ANTI 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)
这样做有两个直接好处:
- 每个分支都更接近"普通 Join"
- 通过
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 类型分发到不同处理分支:
INNERANTILEFTRIGHTFULL
如果展开后结果和原条件完全一样,则不做转换;否则 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
这种"左右输入各拿一个字段比对"的条件。
源码里只接受这两类操作符:
EQUALSIS_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)
那一条同时满足 A 和 B 的匹配,会在两个分支都出现,结果就错了。
所以规则实际做的是:
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(...)
逻辑非常直白:
- 调用
splitCond(join) - 为每个 OR 分支构造一个
INNER JOIN - 为后续分支补上
NOT(previous conditions) - 把所有分支
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 的处理分两部分:
- 用多个
INNER JOIN分支处理"匹配上的部分" - 用一个
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 要同时保留:
- 匹配上的结果
- 左侧未匹配结果
- 右侧未匹配结果
因此它最终会拆成三大块:
- 多个
INNER JOIN分支:处理匹配行 - 左侧
ANTI JOIN + NULL padding:处理左侧未匹配 - 右侧
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 的规则集。
十四、这条规则适合什么场景?
比较适合
- Join 条件确实包含多个 OR 分支
- 每个 OR 分支都比较像"标准等值连接键"
- 你希望优化器能把每个分支独立看待
- 直接保留 OR Join 时,物理计划选择经常不理想
不一定适合
- OR 分支很多,计划容易膨胀
- 分支里都是复杂表达式,不是标准字段对字段等值比较
- 原始执行器对 OR Join 已经处理得不错
- 外连接语义复杂,拆开后虽然等价,但代价可能更高
十五、这条规则的优点
1. 让优化器更容易识别 Join Key
直接写在 OR 里的多个等值条件,常常不容易被单独利用;拆成多个 Join 分支后,每个分支都更清晰。
2. 通过互斥分支避免重复结果
规则不是简单拆 OR,而是显式加 NOT(previous),确保 UNION ALL 仍然语义正确。
3. 覆盖多种 Join 类型
它不只处理 INNER JOIN,而且还系统处理了:
ANTI JOINLEFT JOINRIGHT JOINFULL 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.javacore/src/main/java/org/apache/calcite/rel/rules/CoreRules.javacore/src/main/java/org/apache/calcite/rel/rules/JoinCommuteRule.java
其中 JoinCommuteRule.swapJoinCond(...) 在右侧 anti 展开时也会用到,值得一起看。