当我谈查询优化器时,我谈些什么 (1)—— IR 设计
作者:雷宇
Databend 优化器负责人
https://github.com/leiysky
这几天和迟先生 (github@skyzh) 聊天时偶然聊到他最近在 CMU 做的 optd 项目(一个基于 Cascades 框架设计的查询优化器库),一起吐槽了各种数据库优化器的设计与实现。这时我突然意识到有些技术上的东西聊起来还是挺有意思的,值得记录下来。
因此我决定开个坑,聊聊查询优化器相关的一切——从算法基础到工程实践,从技术演进到项目落地,甚至是一些行业八卦。
今天先将“IR 设计”相关的内容作为开篇,聊一聊优化器中常见的设计模式,并且讨论其中的设计考量。
什么是查询优化器
在正式开始聊之前,我想先明确一下查询优化器的定义。
在一般语境中,查询优化器特指对查询的执行计划进行优化的一个数据库组件。但是由于各个数据库实现上的不同,查询的优化方式也变得五花八门,比如在 AST 上直接进行 Rewriting,在 AST Lowering 的时候做些转换,在查询执行的时候进行动态的 Rewriting 等等。
为了统一概念,我将从 SQL 解析器 (Parser) 到执行器 (Executor) 之间的所有部分统称为查询优化器 (Query Optimizer)。
什么是 IR
熟悉编译技术的朋友的应该很熟悉 IR 这个词。IR 全称为中间语言 (Intermediate Representation) ,常见于各种编程语言的编译器中,比如 Rust 的 HIR & MIR,LLVM 中的 LLVM IR。IR 被用于结构化表示编程语言,方便编译器进行各种分析与优化。
如果将 SQL 也看成一种编程语言的话,关系型数据库就是运行 SQL 程序的虚拟机,正如 JVM 之于 Java。而查询优化器在其中负责的就是绝大多数的编译工作,将 SQL 语句 (Java code) 翻译成执行计划 (Java bytecode) 交给执行器 (Java runtime) 执行。因此在设计查询优化器时也离不开设计 SQL 的各种 IR。
SQL IR 都长啥样
一般的数据库项目会分为几个模块:解析器 (Parser) ,分析器 (Analyzer/Binder) ,优化器 (Optimizer) ,执行器 (Executor)。SQL 语句会依次经由各组件的处理,最后转化成查询结果。在我们的语境里,优化器囊括了上面提到的分析器和优化器两个模块。
SQL 本身是一种模仿自然语言语法设计的声明式语言,基于关系代数,可以描述集合上的各种运算,并将其映射为对表格数据 (Table) 的查询。
AST
为了方便处理,我们会像绝大多数编译器那样,首先将 SQL 语言解析为 AST(抽象语法树,即 Abstract Syntax Tree)。一个 SQL AST 一般如下图所示:
在 SQL AST 中,我们一般将 node 分为两种:Statement(Stmt) 和 Expression(Expr)。每个 SQL AST 的 root node 总是一个 Statement,其中可能包含一些子句 (Clause) 以及 Expression。Expression 是一种递归的结构,其中包含了各种运算符和函数调用,甚至还有嵌套的 Statement (Subqueries)。
在 SQL 中比较有意思的一点是,对于 SELECT Statement 来说 Statement 与 Expression 的界限会比较模糊。因为 SELECT Statement 本身也是递归的,甚至还需要处理运算符优先级的问题 (UNION/EXCEPT/INTERSECT)。与此同时,在 Statement 中也仅有 SELECT Statement 可以与 Expression 互相递归,因此在设计 SQL AST 时需要注意这点。
关系代数
SQL 语言的理论基础是关系代数,每一条查询语句都有对应的关系代数表示,比如:
由于关系代数的表达式也是一个递归的树状结构,因此很多系统也很自然地将 SQL AST 转换成了类似下图的执行计划,我们将每个 node 称作算子 (Operator),将整个算子树成为查询计划 (Query plan)。
当然众多系统中也有异类,比如身为祖师爷的 IBM 在 Starburst 系统中引入了 Query Graph Model 这种表示方式。这种表示方式相当的抽象,将许多 property 给 hardcode 在了 QGM 中,导致理解起来异常困难,其宣称的可扩展性也令人怀疑。篇幅原因就不在这里展开讲解,有兴趣的话可以去阅读相关论文《Extensible Query Processing in Starburst》以及《Extensible/Rule Based Query Rewrite Optimization in Starburst》。
目前主流的数据库基本都采用了关系代数的表示方式(比如 IBM 的 System R 和 DB2,Oracle 的各种数据库产品,微软的 SQL Server 系列,开源的 PostgreSQL 和 MySQL 8.0),也在此基础上衍生出了丰富的优化框架和执行框架,因此在设计 SQL IR 时使用关系代数的抽象是一种不会错的选择。
利用关系型代数的各种公理和定理我们可以对 SQL IR 进行各种转换,在保证正确性的同时达到优化的效果。关于具体的优化规则和算法会在后续的文章中进行讨论。
最佳工程实践(待定)
有一种认知偏差叫做“知识的诅咒”,意指在与他人交流时预设了对方拥有与自己同等的知识背景。
在软件开发领域这种现象非常常见。写过某类代码的人和没写过某类代码的人在交流时总会变成鸡同鸭讲,即便双方已经具备了相同的理论基础(算法,编程语言,甚至是领域知识)依然无法避免。原因在于软件工程具有相当的灵活性,同样的功能可以有许多种实现方式,而不同的实现方式又会有各自的问题。
为了扫除这种沟通上的障碍,各种技术领域都会发展出自己的一系列 idiom 或者 design pattern,新的项目基于这些实践构建可以省去很多不必要的麻烦。数据库领域也是如此,但是由于比较小众,且商业化程度较高,导致坊间流传的知识非常稀少,工程实践也散落在各种开源项目中。
在这篇文章里我会根据我自己的最佳实践从零构建一套 SQL IR,方便渐进式地分享一些设计考量。
因为个人习惯,我会用 Rust 语言来编写代码。不熟悉 Rust 语言的朋友也不用担心,只需要有一定的 C/C++ 基础就可以看懂 Rust 的代码逻辑。
Hello, world!
当我们学习一门新的编程语言时,第一个接触的程序一般都是 hello world。
因此我们也先从 SQL 的 hello world 开始构建我们的 IR。
这条 SQL 语句翻译成关系代数非常简单,我们将其记为 Get(t),意为返回集合 t 中的所有数据。要表示这样的查询我们可以定义一个简单的 struct 类型。
这样简单的一个 SQL IR 就完成了,有了 Get 之后我们就能表示所有类似 select * from xxx 的查询了,是不是很简单?
Select & Project
接下来我们可以为这个 IR 添加更多的功能,支持更多的 SQL 子句。比如:
这条 SQL 查询翻译成关系代数可以记为 Project(Select(Get(t), a = 1), a),Select 算子可以根据提供的谓词对数据进行过滤,Project 算子可以对集合进行裁剪以获得需要的 attribute。为了表示这样的查询我们需要添加更多的 struct 定义。
来到这里我们就面临了几个问题:按照关系代数的定理,Project 是不是可以作为 Select 的 child?Select 对于一条 SQL 查询来说是可选的,代码上该如何体现这点?
为了解决这些问题,我们可以引入一些动态分发的特性。在 C++/Java 中一般会使用继承来表示 Opeartor,比如:
在 Rust 中,我们有一个更方便的选择,可以同时享受静态类型和动态分发的优点,那就是 enum。Rust 的 enum 是一种 ADT(Algebraic Data Type),也被称作 tagged union,可以非常方便地表示我们的 operators:
由此一来我们就可以自由表示各种形状的算子树了,IR 的设计开始步入正轨。
Scalar expression
虽然我们已经引入了 Select 和 Project 的算子,但是对于 Select predicate 和 Project expression 还是以字符串的形式存在的,不能满足分析和优化的需求。因此我们需要为这些 expression 也设计一种 IR。
回想一下,SQL 字符串在经过 Parser 处理后就被转换成了 AST,而其中的表达式会变成 Expr node,大概长这样:
expression 本身是一种递归的结构,AST 的 Expr node 也是一种递归结构,我们是否可以偷懒直接使用 Expr node 作为我们的 SQL IR 的一部分呢?我们可以先试试。
使用 Expr 替换 String 后,我们可以得到:
接下来给定一条 SQL,让我们来试试常用的一些分析,看看好不好使:
Q: Project 中的 Expr 依赖了哪些 table 的哪些 columns?A: 使用了一个叫做 a 的 column,但我不知道它是哪个 table 的,或许根本就不存在这个 column
Q: Project 中的 Expr 的返回类型是什么?A: 不知道,Expr 中没有包含任何类型信息
Q: Select 中的 subquery 是 correlated subquery 吗?A: 不知道,Expr 中的 subquery 只是一个未处理过的 AST
Ok,看起来 Expr 并没有我们想象中的那么好用。为了进行上面的这些分析,我们需要设计一套信息更加丰富的 IR。为了与 Expr 进行区分,我们将其命名为 ScalarExpr。
将以上的分析归纳一下,我们对 ScalarExpr 的要求是:
所有的 identifier 都要 resolve 成 fully qualified name
类型信息需要被注入,并且需要经过 type check
所有的 subquery 都要被转换成 SQL IR 的形式
结合以上需求,再加上一些 desugar,ScalarExpr 大概长这样:
如此一来,表达式的 IR 设计也成型了,让我们把整套 SQL IR 整合起来吧。
The IR
经过以上的设计,我们拥有了:
能够灵活表达各种 SQL 查询的算子树结构 Operator
能够提供丰富语义信息的 ScalarExpr
尽管还缺少一些关键的算子,比如 Join, Union, Aggregate 等。但是由于整体框架已经十分清晰,我们可以照葫芦画瓢地把它们也加上。
整合之后我们就有了一套相当完美的 SQL IR:
因为过于完美,我决定给这个 IR 起个霸气的名字——The IR。
Property derivation
当我们想要对 IR 进行分析和优化时,我们总是需要获取一些 IR 的 property。我们可以通过编写 analyzer 遍历整个 IR 来计算出这些 property,但是这样需要耗费大量精力维护 IR 所处上下文的状态。
幸运的是 SQL 作为一个声明式的查询语言数据流相当简单,我们可以利用其特性计算 property。
The IR 中的数据流向和 operator 之间的父子关系紧密相关,整体呈现为一个有向无环图 (DAG),所有的数据都从子节点流向父节点。
在这种特性下,计算某个 The IR 节点的 property 要做的事情很简单,只需递归计算其每个子节点的 property,再根据这些 property 计算出其本身的 property,我们称这个过程为 property derivation。
在 SQL 优化中,常用的 property 可以分为两类,分别是描述数据集特征的 relational/logical property 和描述数据物理特征的 physical property。
常见的 relational property 有:
数据集中包含的 attributes/columns 信息
数据集的基数 (cardinality),表示数据集中的 record 数量
统计信息 (statistics),表示 attributes 的数据分布
数据约束 (constraints),表示 attributes 的约束,比如 NOT NULL
函数依赖 (functional dependency),表示 attributes 之间的函数依赖关系
常见的 physical property 有:
有序性 (order)
并行度 (DOP)
数据分布 (distribution)
数据分区 (partition)
结合关系代数的性质,我们可以描述 property 种类之间的区别。
假设有关系 和 : 的 relational property 为 ,physical property 为 ; 的 relational property 为 ,physical property 为 。
我们可以得到:
不难看出两个 relation 的等价关系可以决定 relational property 的等价关系,但是 physical property 的等价关系却不受 relation 的等价关系影响。
关于 property 与具体的查询优化算法结合的内容将在后续的文章里展开讨论。
有了 property derivation 之后,我们就能利用关系代数的定理在保证正确性的情况下对 The IR 进行优化了。
那么接下来的问题就是,property 该长啥样?
Relational properties
在 relational property 中最重要的部分莫过于 attributes 的表示方式。朴素的关系代数中,每一个 relation 都是由 tuples 组成的 set ,tuple 中的每个 attribute 都有自己的 unique name,我们很自然地可以想到直接将 tuple schema 作为 attributes 的表示方式。
我们先回忆一下一个 table 是如何创建的。
在 SQL 中我们使用 DDL(Data Definition Language) 来创建和管理各种 table。在创建 table 时我们需要为其指定 table schema,里面包含了 table 中每个 column 的具体定义,对应了关系代数中的 attributes。table schema 的结构大概会长这样:
既然 ColumnDefinition 与 attribute 是一一对应的关系,我们可不可以直接用 ColumnDefinition 来表示 attribute 的 property?
我们可以先来试试,在 The IR 中加上对于 attributes 的支持。
我们首先需要对 The IR 做一些修改,为 Get 算子加上 table schema 信息。
然后我们为 Operator 实现 attributes derivation。
大部分的算子实现还是很顺利的,但是可以看到 Project 和 Aggregate 被标成了 todo。我们这时会发现,Project 和 Aggregate 没法直接利用 children attributes 生成他们自己的 attributes。再回到关系代数上,Project 的作用是裁剪 tuple 的形状,抑或是修改 attribute 的 name, SELECT a + 1 AS b FROM t
这种 SQL 根本无法表达为朴素的 Project;至于 Aggregate,朴素的关系代数中根本没有这个运算,这是对关系代数的扩展内容。
关系代数理论不存在了!
但是尽管如此,工程还是得继续进行,我们需要引入一些“村规”来扩展关系代数的定义。我们在此给出 The IR 中的 Project 和 Aggregate 的形式化定义:
表示将关系 中的 attributes 作为输入,输出 到 的 n 个函数映射组成的 tuple
表示将关系 R 中的 tuples 按照 到 的 m 个 attributes 进行分组,并且对每个分组执行 到 的 n 个函数映射,最终输出分组后的 tuples
这个村规最大的改变是引入了 derived column。对于 SQL 中直接来自于 table 的 column,我们称之为 base table column;对于通过 Project/Aggregate 计算出的 column,我们称之为 derived column。在引入 derived column 概念之前我们可以保证所有的数据来源最终都会指向 Get 算子,但是引入之后这个约定就被打破了,出现了类似编程语言中作用域 (scope) 的概念,我们在进行优化时需要更加的注意。
有了村规后我们就可以给 Project 和 Aggregate 也实现 attributes derivation,但与此同时我们也需要对 The IR 的结构做一些修改:
这样一来对于所有的算子我们都可以计算 attributes property 了,赶紧来试用一下吧。
先来看看 SQL 中最常见也最有效的优化——谓词下推。这个优化可以通过将 Select 算子下推到其他算子内以减少其他算子的计算量,同时还可以保证整个查询的结果不会改变,非常的简洁优雅。
我们来尝试在 The IR 上实现这个优化。思路上非常简单,根据关系代数定理直接调换 Select 与 Project 的位置即可。但是由于我们引入了 derived column,我们必须检查 Select 中的 predicate 是否依赖了 Project 生成的 column。
看起来已经基本可用了,可喜可贺。我们再来试试更复杂的例子,比如试试有 Join 的 SQL:
因为 Join 不像 Project 那样会产生额外的 derived column,因此检查的逻辑会相对简单一些。我们先实现一个尝试将 Select 下推到 Join 的 left child 的优化:
一切看起来都很美好,但是魔鬼往往藏在细节里。我们来看这个例子在 PostgreSQL 中的输出:
最后返回的结果有两个叫做 a 的 attributes。在 The IR 目前的实现中,我们无法知道这个 Select 该下推到哪边。因为当我们检查依赖了 a 的 predicate 能被下推到哪一侧时,我们会发现 Join 的两侧都可以满足。虽然同一个 table 中不允许存在多个具有相同 name 的 columns,但是不同 table 之间并没有这样的限制。
PostreSQL 作为对 ANSI SQL 支持度最高的开源数据库产品,自然也能很好地处理这种问题。通过 EXPLAIN 语句我们可以看到它将 Select 下推到了正确的地方:
The IR 作为完美的 SQL IR,也必须有自己的解决方案。我们仔细观察这条查询的话,会发现 Select 的谓词是用 qualified name 表示的,假如使用 unqualified name 的话 PostgreSQL 会抛出这样的报错:
因为在当前的上下文中,a 是存在歧义的,但是 t.a 就不存在歧义。我们来试试用 qualified name 表示 attribute property 来解决这个问题,为此我们需要改动一些代码:
这样一来上面的问题就解决了,我们有了处理复杂 attribute 引用的能力,但是距离一劳永逸仍有很大的距离。我们再来看一个例子:
虽然 SQL 中不允许在同一个 FROM clause 中使用多个同样的 table name,但是我们可以使用 inlined view 或者 CTE 绕过这点。按照我们现在的实现,在处理 t.a = 1 时我们拿到的 attributes 里面有两个 t1.a 而没有 t.a,这是因为我们没有处理 inlined view 的 alias。为此,我们需要增加一个 Project 专门用于给 attributes 做 renaming。
那么问题又来了,由于我们仅仅是为一些 columns 做了 renaming 就将它们当成了 derived columns 来处理,为我们的 Select 下推徒增了很多负担。为此我们必须修改 The IR 的定义和各种相关代码,服务于 name 的 mapping:
这些问题通过稍微多写点代码还能解决,但是看看接下来的这个例子,我相信大部分人会像我一样直接抓狂:
我们当然可以为代码中再加上各种奇奇怪怪的限制,开各种难以维护的洞来维护这种 property,并且在进行优化的同时保证这些 property 的正确性。但是对于懒惰的程序员们来说,寻找一种更简单的设计才是更好的选择。
欢迎来到深水区。
The IR made simple
最开始的 The IR 是非常简洁而优雅的,但是为了实现更多的功能,支持更复杂的需求,我们为其添加了许多我们不想关注的信息。总的来说,理想状态的 The IR 应该是:
拥有简洁的代数结构
Operator 节点之间完全独立
不用处理 names(仅用于 debug 和展示)
让我们在回过头来思考一下,The IR 真的离不开 name 吗?我们最开始使用 name 来表示 attribute 主要是出于直觉,复用了 table schema。但是 name 中融入了许多无用的信息,对我们的优化毫无帮助,就像编程语言中的各种 symbol name 一样,到了程序运行时都会变成内存地址和寄存器编号。
没有 name 便无法区分 attributes,name 岂是如此不便之物?
归根结底,我们需要的是为每个 attribute 附上一个 unique id,无论是 integer 还是 string,总之我们的唯一目的就是用 id 来区分和引用 attribute。所有的 name resolution 统统丢进 AST lowering,我只想要 attribute id!
在进行了重新设计之后我们改变了 attribute 的表示方式,同时也更改了一些 The IR 的定义。默认情况下,我们使用 int64 类型作为 attribute id。
id 的设计一般离不开对应的上下文 (context),在 SQL IR 中 attribute id 的常见设计方式主要可以分为两类:
一种是基于我们之前用到的 tuple attribute 的抽象,将 attribute 在 tuple 中的 index 作为 attribute id,我们将这种 id 称为 local id。这种设计的特点是逻辑上的同一个 attribute 的 id 会随着其所处的 operator 的不同而发生改变。这种 id 的好处是可以从 operator tree 中推理出来,不需要靠外部的状态进行维护。但是缺点就是在对 operator 进行转换时需要频繁的对 id 进行 remapping。
另一种是通过维护一个全局的 id generator,给 SQL IR 中的所有 attribute 赋予一个 unique id,我们将这种 id 称为 global id。这种设计的优点是将 attribute 与 tuple schema 进行了解耦,可以使用
HashMap<Id, Attribute>
这种无序集合结构表示 attributes。同时也可以利用集合运算帮助 property derivation,降低维护复杂度。但是缺点是使用 global id 的 operator tree 需要依赖外部状态,无法独自存在。
使用这两种不同的设计会对优化器的具体实现造成非常大的影响。
比如说对于这个优化:
在有合适的索引可用时,通过这个优化可以避免 full table scan 从而提升性能。
如果使用 local id 的设计,实现这个优化会非常简单,只需要将整个 operator tree 复制一份,最后使用 UnionAll 连接起来即可。
但是如果使用 global id 的设计,这就是一个 non-trivial 的操作,甚至可以说是十分痛苦。为了区分不同的 attribute,我们必须在复制 operator tree 的同时为所有的 attribute 生成新的 id,再将所有引用这些 attribute 的地方替换成新的 id,这在查询较为复杂时会造成很多麻烦。
再比如说进行 join order 优化时:
根据 Join 算子的交换律,我们可以合法交换 Join 的左右 child。使用 global id 的设计时,因为 attributes 可以表示为无序集合,因此这个操作对于 property derivation 毫无影响。但是使用 local id 的设计时这个操作就会让人痛苦不堪。
除去优化相关的部分,在对于 correlated subquery 的表示上他们的差异也非常巨大。correlated subquery 是一种特殊的 subquery,它可以访问自己的 scope 以外的 attribute,对于这类特殊的 attribute 访问我们称之为 outer reference。
许多编程语言中也支持类似的操作,可以从函数中访问定义在函数内没有定义的变量,通过与特定环境 (environment) 进行绑定 (bind) 后才能执行。这种特殊的函数叫做闭包 (Closure)。
使用 global id 的设计可以通过 attribute property 计算出 subquery 是否为 correlated。但是使用 local id 的设计时我们一般会在 scalar expression 的 ColumnRef
中额外维护一个 scope id,实现起来非常麻烦。
correlated subquery 是一个非常大的话题,我们也许会在后续的文章中聊到。
由此可见两种设计各有优缺点,在工程实践中我们要结合自己的需求选择适合自己的设计。就我个人而言,global id 是一种更好的设计,因为它在绝大多数的情况下都能很轻松地解决问题。
使用 global id 进行改造后,The IR 的代码可以得到大幅的简化:
在把复杂度转嫁给 AST lowerer 之后,我们可以自信地说,The IR 已经是个 production ready 的 SQL IR 了。它可以支持所有的 SQL 运算和常用的优化,拥有易用的 API,同时也非常易于理解。更重要的是,没有人比这篇文章的读者更懂 The IR,任何读者都可以根据自己的需求轻松扩展 The IR。
后记
终于到了这篇文章的尾声。
作为系列的开篇,我在这篇文章里只是简单地聊了聊 SQL IR 设计中的一些关注点,并没有深入讨论各种算法的细节。
但是分享 IR 的设计过程是一件很有意思的事情。很多 IR 就像路边的一棵长得歪歪扭扭的树,第一次路过的人都不知道它为什么会长成那样,只有从小住在这里的人才知道在这棵树很小的时候人们总是喜欢在它的树枝上挂腊肉。这件小事是导致最终结果的重要原因,但是它过于微不足道,导致知道的人从来不会主动分享——当然现实中往往也没人关心背后的原因。
数据库开发是一个小众领域,同时又有很多工程化的实践经验。这些经验在坊间少有流传,我不希望它像美国的登月技术一样随着时代的变化而消失,这也是我想到写这个系列文章的初衷。
在下一篇文章里我会分享关于优化器架构的相关内容,敬请期待。
关于 Databend
Databend 是一款开源、弹性、低成本,基于对象存储也可以做实时分析的新式数仓。期待您的关注,一起探索云原生数仓解决方案,打造新一代开源 Data Cloud。
👨💻 Databend Cloud:https://databend.cn
📖 Databend 文档:https://docs.databend.cn/
💻 Wechat:Databend
版权声明: 本文为 InfoQ 作者【Databend】的原创文章。
原文链接:【http://xie.infoq.cn/article/22f059b03d131127de8721a04】。文章转载请联系作者。
评论