《postgresql指南--内幕探索》第三章查询处理

在《postgresql指南–内幕探索》一书中,感觉该部分内容并不太好理解。因此选择了参考博客《A Tour of PostgreSQL Internals》学习笔记——查询处理分析中的内容。至于《postgresql指南–内幕探索》中的内容,可参考:Query Processing

Postgresql的查询处理

下面这张图从整体上概括了Postgresql的查询处理的步骤以及牵涉到的各个模块。
《postgresql指南--内幕探索》第三章查询处理_第1张图片
其中最重要的关键的两个数据结构是查询分析树(parse tree),和查询计划树(plan tree)。

Parser(查询分析模块)

该模块通过对SQL语句进行分析生成查询树。

查询分析是查询编译的第一个模块,包括词法分析、语法分析和语义分析。它将用户输入的SQL语句进行词法分析(使用Lex工具)和语法分析(Yacc工具)生成分析树,然后进行语义分析得到查询树(parse tree)。

查询树中有几个重要的属性:

  1. commandType:查询树对应的命令类型,说明由哪类命令生成该查询树。包括CMD_SELECT、CMD_DELETE、CMD_UPDATE、CMD_INSERT和CMD_UTILITY,如果命令类型为CMD_UTILITY,则查询优化器不会对该查询树进行优化。

  2. rtable:范围表,查询中使用的表的列表。

  3. resultRelation:结果关系,是涉及数据修改的范围表,该字段只适合INSERT/UPDATE/DELETE命令。

  4. targetList:表示目标属性,用于存放查询结构属性的表达式,分四种情况:

a.SELECT语句:目标属性即为SELECT和FROM之间的表达式;

b.DELETE语句:不需要目标属性,因为DELETE语句不返回元组;

c.INSERT语句:目标属性描述插入到结果关系的元组的属性;

d.UPDATE语句:目标属性描述被更新的属性,即SET子句中的属性。

  1. jointree:连接树,查询的连接树显示了FROM子句中表的连接情况,通常还会附加上WHERE的条件表达式。

比如有如下的SQL语句:

SELECT * FROM tab1, tab2 WHERE tab1.a = tab2.f

那么该语句的查询树如下:
《postgresql指南--内幕探索》第三章查询处理_第2张图片

Rewriter(重写模块)

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

查询重写模块使用规则系统判断来进行查询树的重写,如果查询树中的某个目标被定义了转换规则,则该转换规则会被用来重写查询树。

例如:如果3.1中提到的tab2是一个视图,则该视图会被替换为一个对应的子查询。重写模块将会生成一个新的查询树。如下图所示:
《postgresql指南--内幕探索》第三章查询处理_第3张图片
查询重写的核心是规则系统。而规则系统由一系列的规则组成。系统表pg_rewrite中存储了重写规则。

根据系统表pg_rewrite的不同属性,规则可以按两种方式分类:

  • 按照规则使用的命令类型:可分成SELECT、UPDATE、INSERT和DELETE四种;
  • 按照规则执行动作的方式:可分为INSTEAD(替代)规则和ALSO规则。

在插入/更新/删除时规则需要更复杂的转换,并且可能从一个查询中产生多个查询。

Planner(查询计划模块)

本模块的主要功能是对给定的SQL查询语句,基于代价估计模型,选择最优的查询计划树。

SQL语句不同于JAVA,C语言这样,编写好之后按照固定的顺序和路径执行,相反,SQL只指明要求的查询结果,没有指定具体的查询路线。因此,在数据库管理系统中,用户的请求查询可以用不同的方案来执行。尽管执行结果是相同的,但是执行效率却存在差异。查询计划就用于选择一种代价最小的方案。因此,在数据库查询性能方面起着举足轻重的作用。

举例说明,假如有如下的SQL语句:

SELECT * FROM t WHERE f1 < 100;

我们假设在t(f1)上建立了索引。那么我们就可能有两种可能的查询计划:

1.顺序地扫描全表(Full table scan);
2.利用索引t(f1)查找 f1 < 100 的元组(Index scan);

查询计划就会计算时间代价(磁盘页的读取和CPU时间),然后选择时间代价较少的查询计划。

虽然大多数情况下,Index scan 会比 Full table scan 要快一些,但是这并非必然。这还和被检索的行数有关,这个问题在此处就不展开了。

Executor(执行模块)

执行模块的基本工作是执行一个查询计划树。

一个查询计划树的执行是一个像流水线一样对节点进行处理的网络。每次被调用时,每个节点将产生的元组放在它的输出序列中。上层节点调用下层子节点获取输入元组,利用这些输入元组计算本节点的输出元组。对于这些节点,有以下区别:

   1) 底层节点直接对物理表进行扫描,要么是全表扫描,要么是通过索引扫描(index scan);

   2) 上层节点主要是进行join(nested-loop, merge, hash join)操作的节点;

   3) 当然,也有特殊用途的节点类型,如用于排序和聚合。

听起来很绕,我们看个例子。
对于下面的SQL语句:

SELECT SUM(a1)+1 FROM a WHERE a2 < a3;

模块执行的查询计划树如下图:
《postgresql指南--内幕探索》第三章查询处理_第4张图片
再来一个两表之间join+聚集函数例子:

SELECT DISTINCT a1, b1 FROM a, b WHERE a2 = b2 AND a3 = 42;

对应的查询计划树如下图:
《postgresql指南--内幕探索》第三章查询处理_第5张图片

和join相关的查询计划

在多表(表的个数为n)查询的情形下,查询计划首先估算每个表分别采用Index scan(如果有的话) 和Full table scan的查询代价,然后建立一个join的树型结构,在树中包括了每一对两两连接的路径。这样,在树的第k层给出k个表执行join的最小代价的执行方式,那么递归地,在顶层(第n层)获得执行这n个表join操作的最佳查询方式。至此,就获得了查询n个表join操作的查询计划。

当然,我们不得不考虑的是,由于搜索最佳join方式的代价呈指数式上升,当参与join的表很多时(例如,超过10个),彻底地全局搜索最优join组合的代价会变得很大导致显著地降低系统性能。因此,我们退而求其次,使用概率搜索算法来代替全局搜索。这里,postgresql使用的是遗传算法。

non-SELECT语句的执行

什么是non-SELECT语句?简而言之,就是INSERT、UPDATE和DELETE语句。

  1. 对于INSERT语句来说,其基本过程和SELECT语句是相似的,只不过最后的查询结果行不是返回到查询端,而是插入到表中;

  2. 对于UPDATE/DELETE语句来说,查询计划模块会使用查询树的targetList项来存储选中的行,targetList项会被返回给executor模块的顶层来决定哪些行要被update/delete。

所以我们可以看出,对于查询计划模块和executor的大部分来说,所有的查询语句看起来都像是SELECT。只有顶层的executor会根据查询类型的不同有不同的操作。

小结

对于postgresql的查询处理模块评价如下:

优点:
显而易见,系统可以自己分析决定去选择一个好的查询计划,而不需要人工协助。

缺点:
我们评价一个查询计划的好坏是基于系统的代价计算模型和历史的统计数据,代价计算模型本身不可能尽善尽美,有其自身的缺陷;再者,历史数据可能有时也不那么可靠。

凡事都有代价,生成一个查询计划也是要花费时间的。因此对一个频繁的重复查询而言,生成的查询计划的时间代价就会比较大。

单表查询的代价估计

costsize.c中的函数用于估算各种操作的代价,所有被执行器执行的操作都有着相应的代价函数。例如,函数cost_seqscan()和cost_index()分别用于估算顺序扫描和索引扫描的代价。
在PostgreSQL中有三种代价:

  • 启动代价:在读取到第一条元组前花费的代价,比如索引扫描节点的启动代价就是读取标表的索引页,获取到第一个元组的代价
  • 运行代价:获取全部元组的代价
  • 总代价:前两者之和
顺序扫描

在顺序扫描中,启动代价为0。可以通过系统表pg_class获取表中的元组数与页面总数。如:

testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl';
 relpages | reltuples 
----------+-----------
       45 |     10000
testdb=# EXPLAIN SELECT * FROM tbl WHERE id < 8000;
                       QUERY PLAN                       
--------------------------------------------------------
 Seq Scan on tbl  (cost=0.00..170.00 rows=8000 width=8)
   Filter: (id < 8000)
(2 rows)

注意,顺序扫描过滤器 Filter: (id < 8000)只会在读取所有元组的时候使用,其并不会减少需要扫描的表页面数量(也就是顺序扫描会扫描整表,而和查询需要访问的列数无关)。

索引扫描

尽管PG支持很多索引方法, 比如B树,GiST,GIN和BRin,但扫描代价估计都是用一个共用代价函数cost_index()。
同样,在获取查询代价前,可以获取索引需要访问的页数和元组数:

testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl_data_idx';
 relpages | reltuples 
----------+-----------
       30 |     10000
(1 row)

索引扫描的启动代价不为0。
其运行代价,和一个参数:选择率有关。
选择率是一个0到1之间的浮点数,代表查询指定的where子句在索引中搜索范围的比例。

查询谓词的选择率是通过直方图界值与高频值估计的,这些信息都存储在系统目录pg_statistics中,并可通过pg_stats视图查询。

表中每一列的高频值都在pg_class视图中的most_common_vals和most_common_freqs中成对存储。
1、高频值:该列上最常出现的取值列表
2、高频值频率:高频值相应出现频率的列表

--查看高频值相关的
select most_common_vals,most_common_freqs from pg_stats from pg_stats
where tablename='' and attname='';
--直方图信息
select histogram_bounds from pg_stats where tablename='tb1' and attname='data';

seq_page_cost/random_page_cost
默认值分别是1.0和4.0,这意味着Postgresql假设随机扫描的进度是顺序扫描的1/4,pg默认值是基于HDD(普通硬盘)设置的。如果使用SSD时,最好将random_page_cost的值设置为1.0

排序

排序路径用于排序操作,如 order by、合并连接操作的预处理和其他函数。排序的成本是使用 cost _ sort ()函数估计的。
在排序操作中,如果所有要排序的元组都可以存储在 work_mem 中,则使用快速排序算法。否则,将创建一个临时文件并使用文件合并排序算法。
排序路径的启动代价是对目标元组排序的代价,排序路径的运行代价是读取已排序元组的代价。

创建单表查询的计划树

Postgresql中的计划器会执行三个处理步骤:

  • 执行预处理
  • 在所有可能的访问路径中,找出代价最小的访问路径
  • 按照代价最小的路径,创建计划树

访问路径是估算代价时的处理单元。比如顺序扫描、索引扫描、排序,以及各种连接操作都有其对应的路径。访问路径只在计划器创建查询计划树的时候使用。最基本的访问路径数据结构就是relation.h定义的Path结构体,相当于顺序扫描。所有其他的访问路径都基于该结构。
计划器为了处理上述步骤,会在内部创建一个PlannerInfo数据结构,该数据结构中包含查询树,可以查询锁涉及的关系信息,访问路径等。

预处理

在创建计划树之前,计划器将先对PlannerInfo中的查询树进行一些预处理:

  • 计简化目标列表,limit子句等。eg:表达式2+2 会被重写为4,由clauses.c中eval_cons_expressions()函数负责
  • 布尔表示的规范化:not(not a) 会被重写为a
  • 压平与/或表达式:SQL表准中的是而元操作符,pg内部是多元,计划器总是会假设所有嵌套AND/OR都应当被压平
找出代价最小的访问路径

计划器对所有可能的访问路径进行代价估算,然后选择代价最小的那个。具体会执行以下几个步骤:

  1. 创建一个RelOptInfo数据结构,存储访问路径及其代价
    RelOptInfo结构体是通过make_one_rel()函数创建的,并存储于PlannerInfo结构体的simple_rel_array字段中。在初始状态时,RelOptInfo持有着baserestrictinfo变量,如果存在相应的索引,还会持有indexlist变量。baserestrictinfo存储着查询的where子句,而indexlist存储着目标表上相关的索引。

  2. 估计所有可能访问路径的代价,并将访问路径添加至RelOptInfo结构中。具体细节:

    • 创建一条路径,估计改路径中顺序扫描的代价,并将其写入路径中。将该路径添加到RelOptInfo结构的pathlist变量中。
    • 如果目标表上存在相关索引,则为每个索引创建相应的索引访问路径。估计所有索引扫描的代价,并将代价写入相应的路径中。然后将索引访问路径添加到pathlist变量中。
    • 如果可以进行位图扫描,则创建一条位图扫描的访问路径,估计所有位图扫描的代价,并将代价写入到路径中,然后将位图扫描路径添加到pathlist变量中。
  3. 从RelOptInfo的pathlist中,找出代价最小的访问路径

  4. 如果有必要,估计limit,order by 和aggregate操作的代价

创建计划树

计划树的根节点定义在plannodes.h中的PlannedStmt结构,包含19个字段,其中有4个代表性字段:

  • commandType存储操作的类型,诸如select、update和insert
  • rtable存储范围表的列表(RangeTblEntry的列表)
  • relationOids存储与查询相关表的oid
  • plantree存储一颗由计划节点组成的计划树,每个计划节点对应着一种特定操作,诸如顺序扫描、排序和索引扫描

如上所述,计划树包含各式各样的计划节点。PlanNode 是所有计划节点的基类,其他计划节点都会包含PlanNode结构。比如顺序扫描节点SeqScanNode包含一个PlanNode和一个整型变量scanrelid。PlanNode包含14个字段,下面是7个代表性字段:

  • startup_cost和total_cost是该节点对应操作的预估代价。
  • rows是计划器预计扫描的行数。
  • targetlist保存了该查询树中目标项的列表。
  • qual储存了限定条件的列表。
  • lefttree和righttree用于添加子节点。
执行器如何工作

在单表查询中,执行器从计划树中取出计划节点,按照自底向上的顺序进行处理,并调用节点相应的处理函数。

每个计划节点都有用于执行各自操作的函数,它们位于 src/backend/executor/目录中。例如,执行顺序扫描(seqscan)的函数在 nodeSeqscan.c 中定义; 执行索引扫描(IndexScanNode)的函数在 nodeIndexscan.c 中定义; SortNode节点对应的排序函数在nodeset.c中定义等等。

当然,理解执行器执行方式的最佳方法是读取 EXPLAIN 命令的输出,因为 PostgreSQL 的 EXPLAIN 几乎照着计划树输出的。

尽管执行程序使用内存中分配的 work_men 和 temp_buffers 进行查询处理,但如果查询处理不能在内存中完成,则使用临时文件。
使用 ANALYZE 选项,EXPLAIN 命令实际执行查询并显示真实的行计数、真实的运行时间和实际的内存使用情况。以下是一个具体的例子:

testdb=# EXPLAIN ANALYZE SELECT id, data FROM tbl_25m ORDER BY id;
                                                         QUERY PLAN                                                        
 --------------------------------------------------------------------------------------------------------------------------  
 Sort  (cost=3944070.01..3945895.01 rows=730000 width=4104) (actualtime=885.648..1033.746 rows=730000 loops=1)    
 Sort Key: id   
 Sort Method: external sort  Disk: 10000kB   
 ->  Seq Scan on tbl_25m  (cost=0.00..10531.00 rows=730000 width=4104) (actual time=0.024..102.548 rows=730000 loops=1) 
 Planning time: 1.548 ms 
 Execution time: 1109.571 ms 
 (6 rows) 

在第6行中,EXPLAIN 命令显示执行程序使用了一个大小为10000kB 的临时文件。
临时文件是在 base/pg_tmp 子目录中临时创建的,命名方法如下所示:
{“pgsql_tmp”} + {创建文本的postgres进程PID}.{从0开始的序列号}
例如,临时文件‘ pgsql_tmp8903.5’是pid为8903的postgres进程创建的第6个临时文件。

$ ls -la /usr/local/pgsql/data/base/pgsql_tmp*
 -rw-------  1 postgres  postgres  10240000 12  4 14:18 pgsql_tmp8903.5

你可能感兴趣的:(postgresql,内幕探索,查询处理)