【Calcite 系列】深入解析 Apache Calcite 的函数依赖实现 RelMdFunctionalDependency

本文围绕 core/src/main/java/org/apache/calcite/rel/metadata/RelMdFunctionalDependency.java 这份实现,系统介绍 Calcite 是如何在关系代数树上推导函数依赖(Functional Dependency, FD)。相关 PR:CALCITE-5913CALCITE-7218CALCITE-7219


一、什么是函数依赖

在关系模型里,如果一组列 X 能唯一确定另一组列 Y,就称存在函数依赖:

text 复制代码
X -> Y

比如在员工表里,如果 empid 是主键,那么通常有:

text 复制代码
empid -> ename, deptno, hiredate, salary

函数依赖在优化器里非常有价值,因为它可以帮助回答这些问题:

  • 哪些列其实是冗余的?
  • 某个表达式是否可以由更少的列唯一确定?
  • 聚合之后哪些列依然保持唯一性或决定性?
  • Join 条件里的等值关系能否增强已有约束?

二、RelMdFunctionalDependency 的职责

这个类是 Calcite 内置元数据 BuiltInMetadata.FunctionalDependency 的默认处理器。它并不直接面向 SQL 用户,而是供优化器、规则和元数据查询框架使用。

它做的事情可以概括成 4 类:

  1. 判断某列或列集能否决定另一列或列集
  2. 计算给定列集的函数闭包
  3. 求某些列的最小决定集
  4. 针对不同的 RelNode 推导出该节点上的 FD 集合

核心对外方法包括:

  • determines
  • determinesSet
  • dependents
  • determinants
  • getFDs

三、底层表示:ArrowArrowSet

Calcite 在这里没有用"字符串形式"的 X -> Y 来存函数依赖,而是使用:

  • Arrow:一条函数依赖
  • ArrowSet:一组函数依赖

其中列使用 ImmutableBitSet 表示,也就是按字段 ordinal(位置)来描述。

例如:

text 复制代码
{0} -> {1,2,3}
{0,1} -> {4}

这样的设计非常适合关系代数树,因为经过 ProjectJoinAggregate 之后,字段名可能变化,但 ordinal 体系更加稳定。


四、入口方法:getFDs(RelNode rel, RelMetadataQuery mq)

这是真正的总入口。它先做一层 rel.stripped(),然后根据不同的 RelNode 类型分发到具体实现。

当前实现覆盖:

  • TableScan
  • Project
  • Aggregate
  • Join
  • Calc
  • Filter

而对于:

  • SetOp
  • Correlate

目前直接返回空集,属于保守策略。

如果一个节点没有专门逻辑,代码会回退到 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

因为 ab 都还在输出里,而且它们是 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 = b
  • a IS NOT DISTINCT FROM b
  • AND 递归组合

例如条件:

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,并补充等值条件 FD
  • SEMI / 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 这里的实现很直接:

  1. 把 program 里的投影展开
  2. 调用 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 rel
  • RelMetadataQuery 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));

十三、这份实现的优点与边界

优点

  1. 结构清晰:每类 RelNode 都有独立推导逻辑
  2. 保守可靠:不确定的场景宁可不推
  3. 足够实用:已覆盖优化主干节点
  4. 表达式感知:不仅处理列映射,也能感知表达式与常量

局限

  1. SetOp 尚未实现
  2. Correlate 尚未实现
  3. FULL JOIN 过于保守
  4. 等值识别只覆盖有限谓词
  5. 表达式等价分析还不够强,比如 a+bb+a

十四、总结

RelMdFunctionalDependency 的价值,不只是"判断某列能不能决定另一列"。

更重要的是,把函数依赖这个经典关系理论概念,真正嵌进了 Calcite 的关系代数优化流程里:

  • TableScan 里从键出发建立事实
  • Project/Calc 里做映射和表达式推导
  • Filter/Join 里吸收等值谓词信息
  • Aggregate 里生成新的 group key 依赖
相关推荐
一只努力的微服务5 小时前
【Calcite 系列】深入理解 Calcite 的 JoinExpandOrToUnionRule
calcite·优化规则
james的分享19 天前
大数据领域核心 SQL 优化框架Apache Calcite介绍
大数据·sql·apache·calcite
Gain_chance2 个月前
17-学习笔记尚硅谷数仓搭建-ER模型和维度模型的概念以及数据仓库为什么选择维度模型
数据仓库·笔记·学习·er模型·维度模型·函数依赖
一只努力的微服务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