postgreSQL的查询编译

本文来自于机械工业出版社《PostgreSQL内核分析》这本书,作为自己的学习笔记,由于此书的PG版本为Postgresql 8.4.1,在月度的过程中发现与目前的版本有一定的差异,因此也欢迎大家补充。 

 

PostgreSQL查询编译详解

查询编译是数据库一个非常重要的组件。通过此组件,用户提交的SQL可以生成数据库中最优执行计划。本文主要记录PostgreSQL①SQL的编译,②plan tree的生成,③代价的计算,④CBO的最优执行路径的选择,⑤CBO在最优路径选择的时候可能的影响因素。

 

  • 概述

当PostgreSQL的后台backend程序Postgres接收到查询请求后,首先会将其转发给查询分析模块(词法,语法和语义分析),筛选语句的类型,如果是建表、创建用户、备份等命令,则转发给Utility功能性命令处理模块进行处理。但是对于较为复杂的处理(INSERT、UPDATE、DELETE、SELECT)则需要为其构建查询树,然后进行查询的重写,生成新的查询树。然后根据动态规划或遗传算法,根据每个算子的代价获得最优的执行计划。最后交给executor进行执行。

                         

 

                                                     图一:Postgres查询处理的完整流程

 

模块

功能说明

源码路径

查询编译

查询分析

由SQL查询语句生成查询树

src/backend/parser

查询重写

对查询树重写并生成新的查询树,以提供对规则和视图的支持

src/backend/rewrite

查询优化

生成路径:由查询树计算最优路径

src/backend/optimizer/path

生成计划:通过最优路径生成计划

src/backend/optimizer/plan

查询执行

执行计划

执行生成的计划

src/backend/executor

 

调度

将请求分配到合适的处理模块

src/backend/tcop

 

命令处理

处理建表、备份等命令

src/backend/commands

表一:查询处理各模块             表一:查询处理各模块

 

 

 

 

  • SQL的编译

(一)词法与语法分析

查询分析是查询编译的第一个模块,它包括词法分析、语法分析和语义分析三个部分。它将用户输入的SQL命令转换为查询树(Query结构)。其中词法分析和语法分析分别借助词法分析工具Lex和语法分析工具Yacc来完成各自的工作。

在PostgreSQL中源代码包含scan.l(lex)和gram.y(yacc)两个文件,并且已经为两个文件预生成了C文件分别是scan.c和gram.c。

源文件

说明

Parser.c

词法、语法分析的主入口文件,入口函数是raw_parser;对查询语句进行词法和语法分析后,返回分析树

Kwlookup.c

函数ScanKeywordLookup,查找单词表,返回当前标识符指向单词表中对应单词的指针

Scansup.c

提供词法分析时的常用函数

Scanstr

处理转义字符

Downcase_truncate_identifier

将大写英文字符转换为小写

Truncate_identifier

如果标识符长度超过规定的最大标识符长度,则将其斩断

Scanner_isspace

判断是否为空白符(空格,\t,\n,\r,\f)

Scan.l

定义词法结构,用Lex语言书写,用Lex编译后生成scan.c文件

Gram.h

定义关键字的数值编号

Gram.y

定义语法结构,用Yacc语言书写,用Yacc编译后生成gram.c文件

表二:词法和语法分析的源文件

图二:词法和语法分析的各文件生成和调用关系

 

       在语法分析过程中,涉及到两个重要的结构体:Query和ParseState。Query(用于存储查询树)是查询分析的最终输出结果。ParseState结构则用于记录语义分析的中间信息。

(时间原因不上载了,有空可以自己看源码)

注:jointree  连接树:查询的连接树显示了FROM子句中表的连接情况。对于类似于SELECT …… FROM A,B,C的简单查询,连接树只是FROM中表的简单列表,因为允许以任意顺序连接这些表。如果使用JOIN 表达式(尤其时外连接),必须按照该连接指定的顺序进行连接。此时,连接树显示了JOIN表达式的机构。JOIN子句上的约束条件(ON或者USING)作为附加在连接树节点上的条件表达式处理。通常,还会把顶层WHERE表达式作为附加在顶层连接树节点上的条件表达式来处理,因此,连接树实际上表示了SELECT 语句中的FROM和WHERE子句。

 

(二)查询重写

       在完成语义分析后,会立刻对该查询进行查询重写。查询重写模块使用规则系统系统判断来进行查询树的重写,如果查询树中某个目标被定义了转换规则,则该转换规则会被用来重写查询树。(src\Backend\rewrite)

       规则系统中,按照规则适用的命令分类,可以分为SELECT、UPDATE、INSERT和DELETE。

按照规则执行动作的方式区分可以分配为INSEAD和ALSO。

       其中我们重点介绍一下INSEAD和ALSO规则。

  • INSEAD规则

用规则中定义的动作替代原始的查询树中的对规则所在表的引用。

  • ALSO规则

ALSO规则中原始查询和规则动作都会被执行,但执行顺序根据不同的命令也有所不同:

  1. INSERT规则:原始查询在规则动作执行之前完成,这样可以抱枕规则动作能引用插入的行。
  2. UPDATE和DELETE规则:原始查询在规则动作之后完成,这样能保证规则动作可以引用将要更新或者删除的元组;否则,那些要访问旧版本元组的规则动作就无法完成。

 

举例:视图和规则系统

       PostgreSQL中的视图通过规则系统实现。在创建视图时,系统会自动按照其定义生成相应的规则,当查询涉及该视图时,查询重写模块都会用对应的规则对该查询进行重写,讲对视图的查询改写微对基本表的查询。在生成视图的规则时,规则动作时视图创建命令中SELECT语句的拷贝,并且该规则时无条件的INSTEAD规则。

 

附注:规则系统与触发器的区别

从以上的例子可以看出,用规则重写查询和触发器的功效非常相似都可以在某种命令和条件下被**,并且可以执行原始查询之外的动作。但是,两者从本质上还是有区别的。触发器对涉及的每个元组都要执行依次,二规则时对整个查询树进行修改或生成额外的查询。因此如果在一个语句中涉及多个元组,一个规则通常比触发器效率高。同时,触发器从概念上要比规则简单,触发器实现的功能可以用规则实现。但是目前某些约束不能用规则实现,特别时外键。

 

三、计划树(plan tree)的生成

(一)查询规划

       在DBMS中,用户的查询请求可以采用不同的方案来执行。尽管不同方案返回给用户的结果相同,但执行效率却存在差异,因此就必须存在一种手段来选择代价最小的执行方案。

(由于在SQL执行过程中,最消耗资源的部分为表的连接,因此PostgreSQL的优化主要对连接和子查询进行优化。)

       在查询处理流程中,整个过程可分为预处理、生成路径和生成计划三个部分。

 

第一部分:预处理

预处理的主要工作时提升子链接和子查询以及预处理表达式和HAVING子句等。预处理部分主要是对查询树Query中的范围表rtable和连接树jointree等进行处理。

 

  1. 提升子链接/子查询

一般来说我们将“SELECT …… FROM …… WHERE ”语句称为一个查询块,将一个查询块嵌套到另一个查询块的FROM子句、WHERE子句或HAVING子句中的查询称为嵌套查询。一般来说,嵌套查询的一般处理方法是由里向外处理,即每个子查询在其父查询处理之前求解。从直观上来说,子查询是出现在FROM子句中的,而子链接则出现在WHERE子句或HAVING子句中。

 

附注:部分需详解知识点:

  • 嵌套查询可以分为以下几类:1)EXISTS:声明了EXISTS的子查询  2)ALL:声明了ALL或NOT IN的子查询  3)ANY:声明ANY或IN的子查询  4)EXPR:子查询返回一个参数给外层父查询  5)MULTIEXPR:子查询返回多个参数给外层父查询,例如语句“SELECT * FROM B WHERE(b1,3,’aa’)>SELECT * from A;”中的子查询将向父查询返回多个属性值  6)ARRAY:子查询是将某些值构成数组的表达式

--------------------------------------------------------------------------

一个出现在FROM子句中的子查询如果使用了诸如聚集函数、分组、DISTINCT属性,它将被规划成一个子计划,在规划父查询的过程中会被当做一个黑盒子。但是,如果子查询仅仅只是一个简单的扫描或是连接,把子查询当作是一个黑盒子可能会产生一个较差的规划。

 

子链接提升举例:

提升前:

原始:

SELECT D.DNAME FROM DEPT D WHERE D.DEPTNO IN (SELECT E.DEPTNO FROM EMP E WHERE E.SAL=10000);

 

对语句提升为子链接:

SELECT D.NAME FROM DEPT D, (SELECT E.DEPTNO FROM EMP E WHERE E.SAL=10000) AS SUB WHERE D.DEPTNO=SUB.DEPTNO

 

提升并合并子链接:

SELECT D.DNAME FROM DEPT D,EMP E WHERE D.DEPTNO=E.DEPTNO AND E.SAL=10000;

 

 

  • 预处理表达式

其主要工作包括:1)用基本关系变量取代连接别名变量  2)进行常量表达式的简化  3)对表达式进行规范化  4)将子链接转化称为子计划。

 

  • 预处理HAVING子句

主要是在HAVING子句中不是聚集的一些判断提升至WHERE子句后。

 

第二部分:生成路径

一般对于SQL来说,其命令的处理目标是获取一个或者一系列元组,然后将这些元组返回或者以其为基础进行插入、更新、删除操作。因此,对于一个SQL来说,最重要的是告诉查询的执行模块通过什么方式可以获取到所需要的元组。

 

PS:部分概念

  • 执行计划要操作的元组可以来自于一个基本表或者一系列基本表连接而成的“连接表”。一个基本表也可以看成是由它自身构成的连接表。
  • RelOptInfo节点:是贯穿整个路径生成过程的一个数据结构,生成路径的最终结果始终存放在其中,生成和选择路径所需的许多数据也存放在其中。路径生成和选择涉及的所有操作几乎都是针对这个结构进行。

下面是结构体相关信息:

 

以下是path结构体结构:

(本来写了个文本,不上载了)

  • Baserel(基本关系):普通表、子查询或者是范围表中出现的函数。
  • Joinrel(连接关系):两个或者两个以上的baserel在一定约束条件下的简单合并。对于任何一组baserel仅有一个joinrel,即对于任何给定baserel的集合,只有一个RelOptInfo。

 

生成路径工作由函数query_planner来完成,其函数处理流程如下:

图三:执行路径的生成

   1、路径生成算法

       路径代表了对一个表或者多个表中数据的访问方式。由于单个表的访问方式(顺序访问、索引访问、TID访问)、两个表间的连接方式(嵌套循环连接、归并连接、Hash连接)以及多个标间的连接顺序(左连接、右连接和布希连接)都有多种,因此访问一个表或多个表的路径也会有多种,每个路径都可能是上述访问方式、连接方式和连接顺序的一种组合。

因此需要一种合适的算法把最优的算法找到,在PostgreSQL中有两种路径生成算法:动态规划算法和遗传算法。

      

       2、代价估计

在PostgreSQL的查询规划过程中,查询请求的不同执行方案是通过建立不同的路径(Path)来表达的。在生成了许多符合条件的路径之后,要从中选择出代价最小的路径,把它转换为一个计划,传递给执行器执行。因此,规划器的核心工作就是建立多条路径,然后从中找出最优的那一条。同一个查询请求有不同路径主要是因为:表的不同访问方式、表之间不同的连接方式、表之间不同连接顺序等因素造成的。而评价路径优劣的依据是用系统表pg_statistic中的系统统计信息估计出不同路径的代价(Cost)。

对于某个路径的代价要考虑CPU代价和磁盘存取代价两方面。磁盘代价以从磁盘顺序存取一个页面的代价为单位,所有其他形式的代价计算都是相对磁盘存取代价来计算的。用于估算代价的参数有:

  • seq_page_cost:顺寻存取页的代价,值为1.0。
  • random_page_cost:非顺序存取页的代价,值为4.0。
  • cpu_tuple_cost:典型的CPU处理一个元组的代价,值为0.01。
  • cpu_index_tuple_cost:典型的CPU处理一个索引元组的代价,值为0.005。
  • cpu_operator_cost:CPU处理一个典型的WHERE操作的代价,值为0.0025。
  • effective_cache_size:用来度量PostgreSQL和OS缓存的磁盘页的数量,值为16384

一个路径的代价由三部分组成:启动代价、总代价、执行结果的排序方式(PathKeys—结果是否有序),计算启动和总代价所用的参数如下:

    1)基本参数

  • 表元组数ntuples
  • 表磁盘块数nblocks
  • 符合选择条件的元组数nrows

    2)统计信息:查询规划器需要估计一个查询检索的元组的数目,这样才能选择正确的查询规划,统计信息的主要内容就是每个表和索引中的元组总数,以及每个表和索引占据的磁盘块数。者个信息保存在系统表pg_class的reltuples和relpages属性中。

    3)直方图信息:各个属性值出现次数的统计信息。

 

代价估计方式的基本步骤:首先根据统计信息和查询条件,估算出这次查询要进行的I/O次数以及要去除的元组个数,然后根据元组个数(分为表元组和索引元组)计算出CPU代价,最后综合考虑CPU代价和I/O次数(磁盘代价)即可得到最后的代价。不同的路径类型由不同的代价估算方式。

 

代价估算公式:P+W*T

P:执行时索要访问的页面数,反映了磁盘I/O次数;

T:标识在执行时索要访问的元组数,反映了CPU开销;

W:标识磁盘I/O代价和CPU开销间的权重因子。

 

选择度:用来定量地描述前述代价估算公式中的权重因子W。

 

单个表扫描代价:计算单个表的扫描代价时,需要将表的大小与各个约束条件所对应的选择度相乘

 

两个表的连接代价:

嵌套循环连接:Couter+Nouter*Cinner

归并连接:Couter+Csortouter+Cinner+Csortinner

Hash连接:Couter+Ccreatehash+Nouter*Chash

      

       上面公式指标说明:

  1. Couter:标识扫描外连接表的代价
  2. Cinner:标识扫描内连接表的代价
  3. Csortouter:标识间外连接表进行排序的代价(使用临时存储空间)
  4. Csortinner:标识内连接表进行排序的代价(使用临时存储空间)
  5. Ccreatehash:标识对内连接表进行Hash的代价(使用临时存储空间)
  6. Chash:标识单独hash的代价
  7. Nouter:标识外连接表的大小