本文围绕 core/src/main/java/org/apache/calcite/rel/metadata/RelMdFunctionalDependency.java 这份实现,系统介绍 Calcite 是如何在关系代数树上推导函数依赖(Functional Dependency, FD)。相关 PR:CALCITE-5913、CALCITE-7218、CALCITE-7219
一、什么是函数依赖
在关系模型里,如果一组列 X 能唯一确定另一组列 Y,就称存在函数依赖:
text
X -> Y
比如在员工表里,如果 empid 是主键,那么通常有:
text
empid -> ename, deptno, hiredate, salary
函数依赖在优化器里非常有价值,因为它可以帮助回答这些问题:
- 哪些列其实是冗余的?
- 某个表达式是否可以由更少的列唯一确定?
- 聚合之后哪些列依然保持唯一性或决定性?
- Join 条件里的等值关系能否增强已有约束?
二、RelMdFunctionalDependency 的职责
这个类是 Calcite 内置元数据 BuiltInMetadata.FunctionalDependency 的默认处理器。它并不直接面向 SQL 用户,而是供优化器、规则和元数据查询框架使用。
它做的事情可以概括成 4 类:
- 判断某列或列集能否决定另一列或列集
- 计算给定列集的函数闭包
- 求某些列的最小决定集
- 针对不同的
RelNode推导出该节点上的 FD 集合
核心对外方法包括:
determinesdeterminesSetdependentsdeterminantsgetFDs
三、底层表示:Arrow 与 ArrowSet
Calcite 在这里没有用"字符串形式"的 X -> Y 来存函数依赖,而是使用:
Arrow:一条函数依赖ArrowSet:一组函数依赖
其中列使用 ImmutableBitSet 表示,也就是按字段 ordinal(位置)来描述。
例如:
text
{0} -> {1,2,3}
{0,1} -> {4}
这样的设计非常适合关系代数树,因为经过 Project、Join、Aggregate 之后,字段名可能变化,但 ordinal 体系更加稳定。
四、入口方法:getFDs(RelNode rel, RelMetadataQuery mq)
这是真正的总入口。它先做一层 rel.stripped(),然后根据不同的 RelNode 类型分发到具体实现。
当前实现覆盖:
TableScanProjectAggregateJoinCalcFilter
而对于:
SetOpCorrelate
目前直接返回空集,属于保守策略。
如果一个节点没有专门逻辑,代码会回退到 getFD(List<RelNode> inputs, mq):
- 单输入节点:直接继承输入 FD
- 多输入节点:返回空
这体现了很典型的优化器元数据风格:宁可少推,不要错推。
getFDs(rel, mq)
RelNode 类型
TableScan
从 table keys 构造基础 FD
Project / Calc
映射输入 FD
- 表达式推导
Aggregate
保留 group 内 FD
- group 决定聚合列
Join
左右 FD 合并
-
右侧偏移
-
等值增强
Filter
继承输入 FD
- 等值谓词增强
SetOp / Correlate
当前保守返回空
其他单输入节点
默认继承输入 FD
五、TableScan:函数依赖的源头
TableScan 的 FD 推导最自然,因为它直接来自表的 key 信息:
java
List<ImmutableBitSet> keys = table.getKeys();
对于每个 key,代码生成:
text
key -> allColumns - key
例子
如果表结构是:
text
[0:id, 1:name, 2:age, 3:dept]
且 id 是 key,那么得到:
text
{0} -> {1,2,3}
如果还有复合 key:
text
{2,3}
那么还会生成:
text
{2,3} -> {0,1}
这一步相当于为整个 FD 推导系统提供了"底层事实"。
TableScan EMP
keys = {a}
生成 ArrowSet
a -> b,c,d
六、Project:最值得细看的推导逻辑
Project 是这份实现里最有技术含量的一段,因为它不仅要"继承输入 FD",还要考虑:
- 列重排
- 列裁剪
- 常量投影
- 相同表达式投影
- 一般表达式(如
a + b) - 非确定性表达式
6.1 先拿输入 FD
java
ArrowSet inputFdSet = mq.getFDs(input);
6.2 建立输入列到输出列的映射
java
Mappings.TargetMapping inputToOutputMap =
RelOptUtil.permutation(projections, input.getRowType()).inverse();
这一步主要处理"直接列引用"的映射关系。
比如输入:
text
[a, b, c]
投影:
text
[b, a, 1, a+b]
那么简单映射大致是:
text
input 0 -> output 1
input 1 -> output 0
input 2 -> 无直接映射
6.3 把输入 FD 映射到输出
代码调用 mapInputFDs(...),策略非常关键:
- 决定列必须全部可映射,否则这条 FD 作废
- 被决定列只要部分还能映射,就尽量保留
例如输入有:
text
{0,1} -> {2,3}
而输出只保留了输入 0、1、2,那么结果会变成:
text
{mapped(0), mapped(1)} -> {mapped(2)}
不会保留 3,但不会因此把整条依赖都丢掉。
6.4 处理相同表达式
如果同一个表达式在投影列表中出现多次,例如:
sql
SELECT a+b AS x, a+b AS y
代码会加入:
text
x <-> y
也就是双向依赖。因为两个输出列其实永远相等。
6.5 处理常量列
如果某个输出是字面量,比如:
sql
SELECT empno, 1 AS tag
则常量列本质上是固定值。当前实现不是显式写成:
text
{} -> tag
而是采用一种更工程化的方式:让非字面量输出列去决定它,例如:
text
empno -> tag
虽然不是最理论化的表达,但在已有 ArrowSet 体系下很实用。
6.6 处理表达式依赖输入列
这是最精彩的一步。
代码会用:
java
RelOptUtil.InputFinder.bits(expr)
找出某个表达式依赖了哪些输入列。
例如:
a+b依赖{a,b}b+1依赖{b}CASE WHEN a>0 THEN b ELSE c END依赖{a,b,c}
然后再看输入 FD 是否足以说明:
text
某个输入列 ref -> expr 所依赖的全部输入列
如果成立,就说明该输入列对应的输出列也能决定这个表达式输出列。
例子
如果输入已有:
text
a -> b
投影是:
sql
SELECT a, b + 1 AS x
那么 x 依赖 b,由于 a -> b,可推出:
text
a -> x
如果输入已有:
text
a -> b,c
投影是:
sql
SELECT a, b + c AS x
则有:
text
a -> x
因为 a 已经足够决定 x 所需要的全部输入列。
input RelNode
mq.getFDs(input)
inputFdSet
建立 input 到 output 映射
mapInputFDs
继承可映射的输入 FD
扫描 projections
跳过非确定性表达式
识别相同表达式
识别常量列
记录输入引用列
计算 expr 依赖的输入列集
加入 prev <-> cur
记录 literal 索引
记录 refToIndex
若输入 FD 可推出
expr 所需输入列则
ref 输出列 -> expr 输出列
合并结果
Project / Calc 的 ArrowSet
七、Aggregate:分组后哪些依赖还能保留?
聚合之后,原始行被压缩,很多依赖会失效,但也会产生新的依赖。
7.1 保留 group 内的输入 FD
当 Aggregate.isSimple(rel) 成立时,代码会保留那些决定列和被决定列都完全落在 groupSet 里的输入 FD。
例如输入有:
text
a -> b
而聚合是:
sql
GROUP BY a, b
那么聚合后仍然可以保留:
text
a -> b
因为 a 和 b 都还在输出里,而且它们是 group 字段。
7.2 计算 group 内的传递闭包
代码对每个 group 列都尝试计算:
text
该列在输入 FD 下能推出哪些 group 列
然后把这些结果再补回聚合节点。
这相当于在 group 维度里再做一次 closure 的"截断保留"。
7.3 group key 决定聚合列
这是聚合最重要的一条:
text
groupSet -> aggCols
因为一旦 group key 确定,这一组的聚合结果就是唯一确定的。
例如:
sql
SELECT deptno, COUNT(*) AS cnt
FROM emp
GROUP BY deptno
显然:
text
deptno -> cnt
这是聚合后最典型的新依赖。
是
是
否
input FD
Aggregate.isSimple?
保留 determinants 和
dependents 都落在
groupSet 的输入 FD
对每个 group 列
计算在 group 内的传递闭包
跳过输入 FD 保留逻辑
groupSet -> aggCols
Aggregate 的 ArrowSet
八、Filter:谓词中的等值关系会增强 FD
Filter 本身不改 schema,所以最基础的行为是继承输入 FD。
但它还有额外收益:
过滤条件中的等值关系会新增双向函数依赖。
代码会识别:
a = ba IS NOT DISTINCT FROM bAND递归组合
例如条件:
sql
WHERE a = b AND c = d
则新增:
text
a <-> b
c <-> d
再与输入 FD 做并集。
为什么是双向?因为在经过该过滤条件之后,这些列在结果集中恒等:
- 已知
a,就知道b - 已知
b,也知道a
a = b
c = d
AND
Filter 条件
是否为
IS NOT DISTINCT FROM
或 AND
加入 a <-> b
加入 c <-> d
递归处理每个子条件
与输入 FD 做 union
九、Join:左右 FD 的合并、偏移与增强
Join 是另一个非常核心的节点。
9.1 左右输入的 FD 合并
先分别取左右输入的 FD:
java
ArrowSet leftFdSet = mq.getFDs(rel.getLeft());
ArrowSet rightFdSet = mq.getFDs(rel.getRight());
左表字段的索引在 Join 输出中保持不变。
右表字段则要做整体偏移:
java
shiftFdSet(rightFdSet, leftFieldCount)
比如左表有 3 列,那么右表原来的:
text
0,1,2
进入 Join 输出后就变成:
text
3,4,5
9.2 Join 条件也会产生双向依赖
比如:
sql
... JOIN ... ON left.a = right.b
则在输出字段索引上会产生:
text
left.a <-> shifted(right.b)
9.3 不同 Join 类型的处理
当前实现:
INNER/LEFT/RIGHT:合并左右输入 FD,并补充等值条件 FDSEMI/ANTI:只保留左侧 FD- 其它(比如
FULL):返回空集
FULL JOIN 被保守处理的原因很合理:它会引入 null 扩展,原始依赖关系很容易失真。
leftFdSet
Join 输出 FD
rightFdSet
shiftFdSet(rightFdSet, leftFieldCount)
join condition
addFDsFromEqualityCondition
INNER / LEFT / RIGHT
合并左右 FD + 等值增强
SEMI / ANTI
仅保留左侧 FD
FULL 等其它场景
保守返回空
十、Calc:本质上复用 Project
Calc 这里的实现很直接:
- 把 program 里的投影展开
- 调用
getProjectionFD(...)
所以当前版本中,Calc 的 FD 推导基本可以看成 Project 逻辑的复用。
十一、一个完整例子:从底表到 Join 的 FD 演化
假设底表 EMP(a,b,c),其中:
text
a 是 key
那么最初:
text
a -> b,c
步骤 1:Project
sql
SELECT a, b + 1 AS x, 1 AS k FROM EMP
得到:
a -> x(因为a -> b)a -> k(因为k是常量)x -> k(表达式列也可决定常量列)
步骤 2:Filter
sql
WHERE a = x
新增:
text
a <-> x
步骤 3:Aggregate
sql
SELECT a, COUNT(*) AS cnt GROUP BY a
新增:
text
a -> cnt
步骤 4:Join
再与 D(did, name) 做 Join:
sql
... JOIN D ON a = did
若右表有:
text
did -> name
那么通过 Join 的等值关系,可以进一步推出:
text
a -> name
这正体现了 FD 在 RelNode 树中不断传播和增强的过程。
TableScan EMP
a 是 key
得到: a -> b,c
Project
SELECT a, b+1 AS x, 1 AS k
得到: a -> x, a -> k
Filter
WHERE a = x
新增: a <-> x
Aggregate
GROUP BY a, COUNT(*) AS cnt
得到: a -> cnt
Join D
ON a = did
若 did -> name
则可推出 a -> name
十二、如何在代码里使用
在规则或元数据查询上下文里,如果你有:
RelNode relRelMetadataQuery mq
可以这样用。
获取整个 FD 集合
java
ArrowSet fdSet = mq.getFDs(rel);
判断单列依赖
java
Boolean ok = mq.getFunctionalDependency(rel)
.determines(rel, mq, 0, 2);
判断集合依赖
java
Boolean ok = mq.getFunctionalDependency(rel)
.determinesSet(rel, mq,
ImmutableBitSet.of(0, 1),
ImmutableBitSet.of(3));
计算闭包
java
ImmutableBitSet closure = mq.getFunctionalDependency(rel)
.dependents(rel, mq, ImmutableBitSet.of(0));
求最小决定集
java
Set<ImmutableBitSet> dets = mq.getFunctionalDependency(rel)
.determinants(rel, mq, ImmutableBitSet.of(3));
十三、这份实现的优点与边界
优点
- 结构清晰:每类
RelNode都有独立推导逻辑 - 保守可靠:不确定的场景宁可不推
- 足够实用:已覆盖优化主干节点
- 表达式感知:不仅处理列映射,也能感知表达式与常量
局限
SetOp尚未实现Correlate尚未实现FULL JOIN过于保守- 等值识别只覆盖有限谓词
- 表达式等价分析还不够强,比如
a+b与b+a
十四、总结
RelMdFunctionalDependency 的价值,不只是"判断某列能不能决定另一列"。
更重要的是,把函数依赖这个经典关系理论概念,真正嵌进了 Calcite 的关系代数优化流程里:
- 在
TableScan里从键出发建立事实 - 在
Project/Calc里做映射和表达式推导 - 在
Filter/Join里吸收等值谓词信息 - 在
Aggregate里生成新的 group key 依赖