- 开始日期: 2021/09/13
- 跟踪问题: https://github.com/datafuselabs/databend/issues/1217
概述
为了支持更复杂的 SQL 查询,例如包含 JOIN
和相关子查询的查询,我们需要重新设计 SQL 规划器组件。
当前实现中我们将在本 RFC 中讨论的主要问题如下:
- 不支持
JOIN
和相关子查询 - 不具备严格的语义检查能力,例如类型检查和名称解析,这给 SQL 优化和执行过程中的正确性保障带来了不必要的复杂性
- 没有通用的 SQL 优化框架
让我们从一个简单的例子开始。
在 SQL 中,允许元组中字段名称重复。在 PostgreSQL 中,结果集可以包含名称相同的不同列:
postgres=# create table t(a INT);
CREATE TABLE
postgres=# insert into t values(1),(2);
INSERT 0 2
postgres=# SELECT * from t t1 cross join t t2;
a | a
---+---
1 | 1
1 | 2
2 | 1
2 | 2
(4 rows)
我们可以看到有两个名为 a
的字段,一个来自派生表 t1
,另一个来自派生表 t2
。
如果你尝试引用重复名称 a
的列,它将返回一个错误:
postgres=# SELECT a from t t1, t t2;
ERROR: column reference "a" is ambiguous
LINE 1: SELECT a from t t1, t t2;
而你可以使用规范名称如 t.a
来引用列,因为在查询上下文中表名必须是唯一的。
目前,databend
使用 DataSchema
来表示输入和输出关系模式,这无法提供足够的信息来处理上述情况。在 DataSchema
中,每一列都用 DataField
表示,其定义如下:
pub struct DataField {
name: String,
data_type: DataType,
nullable: bool,
}
DataSchema
中的每个 DataField
都用唯一的 name
字符串标识。目前,name
仅表示列名,因此很难用这种抽象实现 JOIN
。我们将在后面的章节中详细讨论这个问题的解决方案。
第二个问题是关于语义检查。
以类型检查为例,表达式中的每个变量(例如列引用、常量值)都有自己的数据类型。每个标量表达式对其参数的数据类型有要求,例如 +
表达式要求其参数为数值类型。
为了确保查询有效且正确,我们需要在执行查询之前进行类型检查。
由于优化器和执行器都需要类型检查,最好用单一组件来解决这个问题,这样可以使其更易于维护。
最后一个问题是关于查询优化框架。
许多现代优化器都是以 Volcano/Cascades 风格实现的,这是一种高度模块化的方法。
典型的 Cascades 优化器由独立的模块组成:
- 转换规则
- 实现规则
- 探索引擎
- 成本模型
有趣的是,规则系统(转换和实现)与探索引擎和成本模型解耦,这意味着可以轻松构建启发式优化器而无需基于成本的优化(CBO)。一旦我们准备实现 CBO,规则系统可以重用。
实际上,这是实际的做法。在一些工业级 Cascades 实现(例如 SQL Server 和 CockroachDB)中,总是有一个启发式优化阶段,例如 SQL Server 中的 pre-exploration
和 CockroachDB 中的 normalization
,它们通常与探索引擎共享相同的规则系统。
总之,本 RFC 将:
- 引入一个新框架以支持规划
JOIN
和相关子查询 - 引入一个规则系统,使开发人员能够轻松编写转换规则
设计细节
架构
在当前实现中,SQL 查询将按以下方式处理:
PlanParser
将 SQL 文本解析为 AST(抽象语法树)PlanParser
还将从 AST 构建一个用PlanNode
表示的规范计划树- 构建计划树后,
Optimizer
将对计划进行一些规范优化,并生成最终的PlanNode
Interpreter
将PlanNode
作为输入,并将其解释为由Processor
组成的可执行Pipeline
- 执行器将使用特定运行时执行
Pipeline
在我们的新框架中,PlanParser
将被重构为两个组件:
Parser
:将 SQL 文本解析为统一的 AST 表示,这在 此 PR 中已经引入Binder
:将 AST 中出现的变量与数据库中的对象(例如表、列等)绑定,并执行语义检查(名称解析、类型检查)。将生成计划树的逻辑表示
此外,将引入一个新的优化器,用 规则系统
取代当前的优化器。
Binder
由于 SQL 查询中可能有许多语法上下文,我们需要一种方法来跟踪它们之间的依赖关系以及不同上下文中 name
的可见性。
这里我们提出了抽象 Metadata
,它存储了查询优化所需的所有元数据,包括来自目录的基本表、派生表(子查询和连接)和列。每个表和列都将被分配一个唯一标识符。
pub struct Metadata {
pub tables: Vec<TableEntry>,
}
pub struct TableEntry {
pub index: IndexType, // `Metadata` 中表的索引
pub columns: Vec<ColumnEntry>,
// 以及表的元数据,例如名称、数据库名称等
}
pub struct ColumnEntry {
pub index: ColumnIndex,
// 以及列的元数据,例如名称、数据类型等
}
pub type IndexType = usize;
pub struct ColumnIndex {
pub table_index: IndexType, // 此列所属表的索引
pub column_index: IndexType, // 表中列的索引
}
因此,在名称解析之后,每个变量将绑定到一个唯一的 ColumnIndex
而不是字符串,因此我们不必担心重复名称的问题。
在绑定过程中,我们需要跟踪绑定的状态。状态可能包含以下信息:
- 当前上下文中可见的列,用于处理通配符结果集(
SELECT * FROM t
) - 上下文中的列是否为分组键
- 列是否为派生列(例如
a+1 AS a
这样的投影) - 变量是否来自当前子查询,以识别相关列引用
为了维护状态,我们提出了数据结构 BindContext
(这个名字受到 CMU Peloton 的 BinderContext
启发,我认为这是一个非常恰当的名字)。
BindContext
是一个类似栈的结构,栈中的每个 BindContext
节点记录相应语法上下文的状态。SQL 绑定是一个自底向上的过程,这意味着它将递归处理 AST,并将数据源(例如表扫描、连接、子查询)产生的列添加到
简而言之,BindContext
是一组列引用。为了清楚起见,我们将使用图表来解释这个机制的工作原理。
以这个例子为例:
create table t (a int);
SELECT * from t -- 表: 上下文 1 [t.a]
cross join t t1 -- 表: 上下文 2 [t1.a]
-- 连接: 上下文 3 [t.a, t1.a]
where t.a = 1
根据 SQL 的语义,我们可以将绑定过程描述如下:
- 为表
t
创建一个空的BindContext
上下文 1,并用t
中的列填充它 - 为表
t
创建一个空的BindContext
上下文 2,用t
中的列填充它,并将表重命名为t1
- 为
t cross join t1
创建一个空的BindContext
上下文 3,并用t
和t1
中的列填充它 - 对谓词
t.a = 1
执行名称解析 - 查找上下文 3,并找到变量
t.a
对应的ColumnEntry
让我们看看 BindContext
如何处理相关子查询。
相关子查询表示子查询依赖于外部上下文中的列。
有一个规范的 Apply
操作符来执行相关子查询,它将为每个元组(类似于交叉连接)评估子查询表达式。然而,大多数相关子查询可以解相关为连接(例如半连接、反半连接等)。
以这个查询为例:
create table t (a int);
SELECT * from t -- 表: 上下文 1 [t.a]
where exists (
SELECT * from t t1 -- 表: 上下文 2 带有父上下文 1 [t1.a]
where t1.a = t.a -- t.a 是相关列,因为它来自外部查询中的 t
);
在绑定 exists
子查询之前,我们将为其创建一个新的 BindContext
,并将外部 BindContext
作为其父上下文。
当我们在子查询中绑定相关列引用 t.a
时,我们将首先查找当前 BindContext
以查看是否存在适当的列,如果不存在,我们将继续在父上下文中查找,直到找到相应的列或遍历所有父上下文。
如果在父上下文中找到列,那么我们可以确认这个子查询是相关子查询,并且绑定到父上下文的列引用是相关列。
该过程可以总结如下:
- 为表
t
创建一个空的BindContext
上下文 1,并用t
中的列填充它 - 为表
t
创建一个空的BindContext
上下文 2,并将上下文 1 作为其父上下文,用t
中的列填充它,并将表重命名为t1
- 对谓词
t1.a = t.a
执行名称解析