数据库内核:PostgreSQL 查询优化
查询评估
查询评估介绍
之前介绍的下层结构全都是为了支持这一层查询评估(Query Evaluation),它处于最顶层。
SQL中的查询声明了它需要什么样的答案(声明性),但是没有说明这些答案是如何计算得到的(程序性)。
查询评估器/处理器:
- 获取查询的声明性描述(在 SQL 中)
- 解析查询到内部表示(关系代数)
- 确定回答查询的计划(表示为 DBMS 操作)
- 通过 DBMS 引擎执行方法(生成结果元组)
一些数据库管理系统会保存查询计划以供之后使用。
以下是查询评估器/处理器的内部结构:
数据库管理系统在将一个 SQL 查询解析为一个关系代数时,往往会有多种选择。例如,有几个选择的“版本”可用,对特定类型的选择,某些“版本”会比较有效:
select * from R where id = 100; # – hashing
select * from S where age > 18 and age < 35; # – Btree index
select * from T where a = 1 and b = ’a’ and c = 1.4; # – MALH file
我们将这些特别的关系代数称为 RelOps
。查询处理器(Query Processor)的一项重要任务就是:
- 给出要评估的 RA 表达式
- 找到 RelOps 的组合来有效地做到这一点
查询优化器(Query Translator/Optimiser)会考虑如下的信息:
- 关系的相关信息,大小、主键等等
- 操作的相关信息(操作效率以及期望的结果)
RelOps 在执行时实现为通过管道或临时关系进行通信的互通节点的集合。
一些术语的变化:
-
SQL 查询的关系代数表达式 = 中间查询表示 = 逻辑查询计划
-
作为 RelOps 集合的执行计划 = 查询评估计划 = 查询执行计划 = 物理查询计划
查询翻译(Query Translation)
查询翻译的任务就是将 SQL 转化为 RA 表达式。举个例子就是将 select name from Students where id=7654321;
转化为 \(Proj_{[name]}(Sel_{[id=7654321]}(Students))\)。
在进行翻译的过程中,需要进行很多处理,比如词法分析器/解析器,映射规则,重写规则等。同时,在翻译的过程中,也会进行一定程度的优化。例子如下:select * from Students where id = 54321 and age > 50;
会被转化为 \(Sel_{[age>50]}(Sel_{[id=54321]}(Students))\) 而不是 \(Sel_{[id=54321\&age>50]}(Students)\)。
解析 SQL(Parsing SQL)
解析任务与编程语言的任务相似。其语言元素如下:
- 关键字(keywords):
create, select, from, where,...
- 标识符(identifiers):
Students, name, id, CourseCode,...
- 操作符(operators):
+,-,=,<,>,AND,OR,NOT,IN,...
- 常量(constants):
’abc’, 123, 3.1, ’01-jan-1970’,...
PostgreSQL 中的解析器是通过 lex/yacc 实现的(在 src/backend/parser
目录下),将标识符映射为小写,需要处理用户可扩展的操作符集合,广泛使用目录(在 src/backend/catalog
目录下)。
表达式重写规则
因为 RA 是一个定义明确的正式系统。RA 表达式上存在许多代数定律,这可以作为表达式重写的基础。重写的目的是为了得到等价且效率更高表达式。除此以外,之所以要基于这些规则重写表达式,是因为可以简化/提升 SQL 转化 RA 的结果,而且可以生成新的计划以对比查询效率。
关系代数定律
-
交换法和关联法
- \(R\bowtie S\leftrightarrow S\bowtie R,(R\bowtie S)\bowtie T\leftrightarrow R\bowtie(S\bowtie T)\) (自然连接)
- \(R\cup S\leftrightarrow S\cup R,(R\cup S)\cup T\leftrightarrow R\cup(S\cup T)\)
- \(R\bowtie_{Cond} S\leftrightarrow S\bowtie_{Cond} R\)
- \(\sigma_c(\sigma_d(R))\leftrightarrow\sigma_d(\sigma_c(R))\)
-
选择分解(其中 c 和 d 是选择条件)
- \(\sigma_{c\wedge d}(R)\leftrightarrow\sigma_c(\sigma_d(R))\)
- \(\sigma_{c\vee d}(R)\leftrightarrow\sigma_c(R)\cup\sigma_d(R)\)
-
选择推导
- \(\sigma_c(R\cup S)\leftrightarrow \sigma_c R\cup\sigma_cS,\sigma_c(R\cap S)\leftrightarrow \sigma_c R\cap\sigma_cS\)
- \(\sigma_c(R\bowtie S)\leftrightarrow \sigma_c(R)\bowtie S\),如果 c 是只来自关系 R 的属性
- \(\sigma_c(R\bowtie S)\leftrightarrow R\bowtie \sigma_c(S)\),如果 c 是只来自关系 S 的属性
- \(\sigma_{c'\wedge c''}(R\bowtie S)\leftrightarrow \sigma_{c'}(R)\bowtie \sigma_{c''}(S)\),c' 是只来自关系 R 的属性,c'' 是只来自关系 S 的属性。
-
投影规则的重写
- \(\pi_{L_1}(\pi_{L_2}(...\pi_{L_n}(R)))\rightarrow\pi_{L1}(R)\),除了最后一个投影,其余的都可以忽略
- \(\pi_{L}(R\bowtie_c S)\leftrightarrow\pi_L(\pi_{M}(R)\bowtie_c\pi_{N}(S))\),这里的 M 和 N 必须包含 c 所需的所有属性
查询重写
子查询(Subquery)是可以转换为 Join 的。例如:
select c.code, count(*)
from Courses c where c.id in (select cid from Enrolments)
group by c.code;
可以转换为:
select c.code, count(*)
from Courses c join Enrolments e on c.id = e.cid
group by c.code;
但不是所有的子查询都可以被转换为 Join 的。例如:
select e.sid as student_id, e.cid as course_id
from Enrolments e
where e.sid = (select max(id) from Students);
上面这个查询相对应的 RA 表达式为 \(Val=max_{[id]}(Students)\) 和 \(Res=\pi_{(sid,cid)}(\sigma_{sid=Val}(Enrolments))\)
在 PostgreSQL 中,视图(views)是通过重写规则实现的。
- 创建一个新的视图:
create view COMP9315studes as select stu,mark from Enrolments where course=’COMP9315’;
- 对于当前的视图进行一个查询:
select stu from COMP9315studes where mark >= 50;
- 此时的 COMP9315students 视图即为:\(COMP9315students=Proj_{[stu,mark]}(Sel_{[course=’COMP9315’]}(Enrolments))\)
- 而对其进行的查询即为:\(Proj_{[stu]}(Sel_{[mark>=50]}(COMP9315studes))\)
- 可以将上面两个表达式进行结合:\(Proj_{[stu]}(Sel_{[mark>=50]}(Proj_{[stu,mark]}(Sel_{[course=’COMP9315’]}(Enrolments))))\)
- 而上面这个结合后的表达式可以重写为:\(Proj_{[stu]}(Sel_{[mark>=50\&course=COMP9315]}(Enrolments))\)
练习:将下面 SQL 转化为 RA 表达式(尽可能高效)
假设 R.id
是主键,关系 R 对 id 进行了哈希,而 R.b
属性有 B 树索引。
select * from R where a > 5;
select * from R where id = 1234 and a > 5;
select R.a from R, S where R.i = S.j;
select R.a from R join S on R.i = S.j;
select * from R, S where R.i = S.j and R.a = 6;
select R.a from R, S, T where R.i = S.j and S.k = T.y;
- \(\sigma_{a>5}(R)\)
- \(\sigma_{a>5}(\sigma_{id=1234}(R))\)
- \(\pi_{R.a}(R \bowtie_{R.i=S.j} S)\)
- \(\pi_{R.a}(R \bowtie_{R.i=S.j} S)\)
- \(\sigma_{R.a=6}(R)\bowtie_{R.i=S.j}(S)\)
- \(R \bowtie_{R.i=S.j} (S \bowtie_{S.k=T.y} T)\)
查询优化
查询优化简介
在将 SQL 查询转换为 RA 表达式时,就已经通过重写进行了一定程度的优化。而查询优化器的目的就是将 RA 表达式转化为高效的评估计划。
查询优化是查询评估的关键步骤。查询优化器从 SQL 解析器中获取关系代数表达式,然后生成生成 RelOps 序列来评估表达式,查询执行计划应该提供高效的评估。对于查询优化器来说,”优化“ 这个词或许说的不够准确,因为它实际上是选择了一个好的计划,但这个计划未必是最佳的。可见的查询时间等于计划时间加上评估时间。
那为什么不直接生成 “最佳” 计划呢?这是因为寻找这个最佳计划意味着需要穷举搜索所有可能的计划,对每个可能的计划都需要评估代价,这就使得成本变得很高。因此数据库管理系统选取折中的方式,借助启发式的方法在一定范围内选择一个相对合理的高效执行计划。
优化的方法
优化的方法可以分为三类:
- 代数的:根据等价关系、重写、启发式
- 物理的:执行成本、基于搜索
- 语义的:应用属性、启发式
这些方法都是以最小化(或至少降低)“成本”为目标而驱动。真正的查询优化器是使用前两者方法的组合,第三类实现起来比较困难且成本高。
以下是一个优化转换的例子:
对于 Join,还可以考虑 sort/merge join 和 hash join。下面将整个优化算法用伪代码的形式表示出来:
translate SQL query to RAexp
for enough transformations RA of RAexp { // 给定一个关系代数表达式RAexp,通过一系列的转换和优化,生成 RA'
while (more choices for RelOps) { // 遍历不同的关系操作选择(RelOps),以构建最优的执行计划
Plan = {}; i = 0; cost = 0
for each node e of RA (recursively) { // 对 RA' 的每个节点 e 进行递归处理
ROp = select RelOp method for e // 对于每个节点 e,通过选择适合的关系操作(RelOp)方法
Plan = Plan ∪ ROp // 将该操作添加到计划中
cost += Cost(ROp) // 信息计算成本
}
if (cost < MinCost)
{ MinCost = cost; BestPlan = Plan }
}
}
评估查询的成本由以下因素决定:
- 关系的大小(数据库关系和临时关系)
- 访问机制(索引、散列、排序、Join 算法)
- 主内存缓冲区的大小和数量以及页面替换策略
成本分析涉及估算:
- 中间结果的大小
- 读写磁盘的次数
例子
输入:
- 一个 RA 操作 (\(\sigma,\pi,\bowtie\))
- 关于文件以及数据的相关信息
- 数据库引擎支持的操作组成的列表
输出:实现该 RA 操作的特定数据库管理系统操作
例如一个 RA 操作为 \(Sel_{[name='John'\wedge age>21]}(Student)\)。
已知关系 Student 是基于 name 属性的 B 树索引结构。而数据库管理系统基本都是支持 B 树操作的。那么,此时就可以使用 B 树搜索进行 \(Sel_{[name]}\),使用线性搜索进行 \(Sel_{[age]}\),在此过程中,会生成 2 个 临时文件:
- \(tmp[i]= BtreeSearch_{[name=John]}(Student)\)
- \(tmp[i+1]=LinearSearch_{[age>21]}(tmp[i])\)
在可能的情况下,使用流水线以避免将 \(tmp[i]\) 存储在磁盘上。
一些准则
-
选择 Selection 操作 \(\sigma\) 的准则:
- \(\sigma_{A=c}(R)\),R 在属性 A 上有索引,因此使用规则 \(indexSearch_{[A=c]}(R)\)
- \(\sigma_{A=c}(R)\),R 在属性 A 上有哈希,因此使用规则 \(hashSearch_{[A=c]}(R)\)
- \(\sigma_{A=c}(R)\),R 在属性 A 上有顺序,因此使用规则 \(binarySearch_{[A=c]}(R)\)
- \(\sigma_{A\geq c}(R)\),R 在属性 A 上有簇索引,因此使用规则 \(indexSearch_{[A=c]}(R)\)
- \(\sigma_{A\geq c}(R)\),R 在属性 A 上有哈希,因此使用规则 \(SlinearSearch_{[A\geq c]}(R)\)
-
选择 Join 操作 \(\bowtie\) 的准则:
- \(R\bowtie S\),关系 R 可以放入内存缓冲区,因此使用规则 \(bnlJoin(R,S)\)
- \(R\bowtie S\),关系 S 可以放入内存缓冲区,因此使用规则 \(bnlJoin(S,R)\)
- \(R\bowtie S\),关系 R 和 S 的 Join 属性都是排序了的,因此使用规则 \(smJoin(R,S)\)
- \(R\bowtie S\),关系 R 的 Join 属性有索引,因此使用规则 \(inlJoin(R,S)\)
- \(R\bowtie S\),关系 R 和 S 的 Join 属性没有索引,也没有顺序,因此使用规则 \(hashJoin(R,S)\)
注意:\(bnl\) = block nested loop,\(inl\) = index nested loop,\(sm\) = sort merge
成本评估
在不执行计划的情况下,是不知道其精确的代价的。因此,查询优化器通过以下方式估算成本:
- 执行操作的成本
- 结果的大小(它会影响下一个操作的成本)
通过关系统计信息来估计的结果大小,例如:
- \(r_S\):关系 S 的基数
- \(R_S\):关系 S 中元组的平均大小
- \(V(A,S)\):关系 S 中属性 A 的不同取值
- \(min(A,S)\):关系 S 中属性 A 的最小值
- \(max(A,S)\):关系 S 中属性 A 的最大值
估计投影结果的大小:比较直接,因为我们知道输出元组的数量 \(r_{out}=|\pi_{a,b,...}(T)|=|T|=r_T\)
估计选择结果的大小:选择性就是期望满足条件的元组的比例。
- 一般都假设属性值是均匀分布的。
- 考虑以下 SQL
select * from Parts where colour=’Red’;
,如果 \(V(colour,Parts)=4,r=1000\) 那么就可以推出 \(|\sigma_{colour=red}(Parts)|=250\)。一般来说,\(|\sigma_{A=c}(R)|\cong\frac{r_R}{V(A,R)}\)。而在 PostgreSQL中,使用的是启发式:\(|\sigma_{A=c}(R)|\cong\frac{r_R}{10}\)。 - 而对于一个不等条件选择操作:
select * from Enrolment where year > 2015;
,可以通过均匀分布的假设,\(r\) 以及最大/最小年份来进行估计。假设 \(min(year)=2010,max(year)=2019,|Enrolment|=10^5\)。那么 \(10^5\) 个元组从 2010 年到 2019 年平均分,每一年 10000 个元组。所以估计 2016 年开始有 40000 个元组。在一些启发式的系统中,对于一个不等条件选择,它的估计是 \(|\sigma_{A>c}(R)|\cong\frac{r_R}{3}\)。 - 而对于多个不等条件选择操作:
select * from Enrolment where course <> ’COMP9315’;
,可以通过均匀分布的假设,\(r\) 以及域大小来进行估计。假设 \(|V(course,Enrolment)|=2000\),那么 \(|\sigma_{A<>c}(E)|=r*\frac{1999}{2000}\)。在一些启发式的系统中,对于多个不等条件选择,它的估计是 \(|\sigma_{A<>C}(R)|\cong r\)。
- 考虑以下 SQL
- 当属性值不是服从均匀分布时:
- 此时需要收集存储于属性/关系的值的统计信息,把这些以直方图的形式存储在关系的元数据中。例如,分布可能是 \(White=35\%,Red=30\%,Blue=25\%,Silver=10\%\),此时就需要直方图存储的数据作为基础来决定选择的元组的数量。但是缺点在于存储直方图 需要额外的开销。
估计 Join 结果的大小:其分析依赖于有关数据/关系的语义知识。考虑一个普通的等式 Join \(R\bowtie_aS\)。
- 第一种情况:当 \(V(a,R)\cap V(a,S)=\left\{\right\}\) 时,\(size(R\bowtie_aS)=0\)
- 第二种情况:当 \(uniq(R.a),uniq(S.a)\) 时,\(size(R\bowtie_aS)\leq min(|R|,|S|)\)
- 第三种情况:当 \(pkey(R.a),fkey(S.a)\) 时,\(size(R\bowtie_aS)\leq |S|\)
不准确的成本估算可能导致评估计划不佳。上述方法可能会(有时)给出不准确的估计。为了获得更准确的成本估算:
- 更多的时间:选择性的复杂计算
- 更多的空间:数据值直方图的存储
无论哪种方式,优化过程的成本都更高,这需要在优化器性能和查询性能之间进行权衡。
练习
假设所有的属性是均匀分布的,属性之间是相互独立的,\(V(A,R)=10,V(B,R)=100,r=1000\)。对以下查询给出预期结果数量的公式。
select * from R where not A=k;
:\(|\sigma_{A=k}(R)|\cong\frac{r}{V(A,R)}=\frac{1000}{10}=100\)select * from R where A=k and B=j;
:\(temp=|\sigma_{A=k}(R)|\cong\frac{r}{V(A,R)}=\frac{1000}{10}=100,|\sigma_{B=j}(temp)|\cong\frac{temp}{V(B,R)}=\frac{100}{100}=1\)select * from R where A in (k,l,m,n);
:\(|\sigma_{A=k}(R)|\cong\frac{r}{V(A,R)}=\frac{1000}{10}=100\),每个常量都相同,因此为 400.
对于以下 Join 查询,输出元组的数量为多少?
select * from R, S where R.s = S.id;
,这里的S.id
是主键,R.s
是相应的外键:\(size(R\bowtie_aS)\leq |S|\)select * from R, S where R.s <> S.id;
,这里的S.id
是主键,R.s
是相应的外键:\(size(R\bowtie_aS)\leq min(|R|,|S|)\)select * from R, S where R.x = S.y;
,\(dom(R.x)\neq dom(S,y)\):\(size(R\bowtie_aS)=0\)
PostgreSQL 中的查询优化
输入:解析器返回的查询节点树
输出:查询执行者使用的计划节点树
状态信息包含在 PlannedStmt
节点中,中间数据结构是路径节点树,一个路径表示查询的一个评估顺序。所有类型的节点定义在 include/nodes/*.h
中。
查询优化分两个阶段进行(解析后):
- 重写:使用 PostgreSQL 的规则系统,查询树会被展开,例如视图定义。
- 计划和优化:通过对生成的路径进行基于成本的分析,通过两种不同的路径生成器之一,从考虑的所有路径中选择成本最低的路径,生成相应的计划。