postgresql 技术内幕学习笔记

本文原文地址(读了两篇文章的重点笔记):
PostgreSQL查询优化器详解之逻辑优化篇
PostgreSQL查询优化器详解之物理优化篇

第一章 概述

1.1 查询优化的简介

一个查询优化器它的输入是查询树,输出是查询执行计划 。

通常数据库的查询优化分为两个层次:

  • 基于规则的查询优化(逻辑优化:rule based optimization)
  • 基于代价的查询优化 (物理优化,cost based optimization)

查询树就是查询优化器的输入,经过逻辑优化和物理优化,最终产生一颗最优的计划树


1.2 逻辑优化篇

PostgreSQL在逻辑优化阶段有这么几个重要的优化:

子查询&子连接提升、表达式预处理、外连接消除、谓词下推、连接顺序交换、等价类推理

PostgreSQL的子查询和子连接
出现在FROM关键字后的子句是子查询语句,出现在WHERE/ON等约束条件中或投影中的子句是子连接语句

SELECT * FROM STUDENT, (SELECT * FROM SCORE) as sc;

SELECT (SELECT AVG(degree) FROM SCORE), sname FROM STUDENT;

SELECT * FROM STUDENT WHERE EXISTS (SELECT A FROM SCORE WHERE SCORE.sno = STUDENT.sno);

1是子查询,2和3是子连接,语句1里的子句出现在FROM后面,它是以‘表’的形式存在的,是子查询,2和3的子句出现在投影和约束条件中,是以表达式的形式存在的,是子连接。

子查询 还可以分为相关子连接和非相关子连接,相关子连接是指在子查询语句中引用了外层表的列属性,这就导致外层表每获得一个元组,子查询就需要重新执行一次

而非相关子查询是指在子查询语句是独立的,和外层的表没有直接的关联,子查询可以单独执行一次,外层表可以重复利用子查询的执行结果

PostgreSQL提升了两种类型的子连接,一种是ANY类型的子连接,一种是EXISTS类型的子连接,对于ANY类型的子连接,只提升非相关子连接,而对于EXISTS类型的子连接,则只提升相关子连接

虽然PostgreSQL对于ANY类型只提升非相关的子连接但它仍然是只提升产生嵌套循环的那种子连接,看这个例子

SELECT * FROM STUDENT WHERE sno > ANY (SELECT sno from STUDENT);

这是一个ANY类型的非相关子连接,但请注意,在>前面的sno实际上产生了一个天然的相关性,这个天然的相关性就会产生嵌套循环,因此是需要提升的。

-- 把>前面的sno换成了一个常量
SELECT * FROM STUDENT WHERE 10 > ANY (SELECT sno from STUDENT);

这个SQL语句中的子连接就不会提升了,因为我们把sno换成了常量,父子之间的相关性被打破


子连接是否提升取决于相关性,而这个相关性不只是体现在子句里,也体现在表达式里;只要能产生嵌套循环,那就有提升的必要。 ANY类型的相关子连接也会产生嵌套循环,却不提升。

PostgreSQL提升ANY类型的子连接的方式和EXISTS类型的子连接的方式不同,他提升EXISTS类型的子连接的时候,是直接把子句中的表提上来做,形成一个SemiJoin,可是提升ANY类型的子连接时,是把整个子句提上来,和父语句中的表做SemiJoin,这时候这个子句就变成了一个子查询

SELECT * FROM TEST_A WHERE a > ANY (SELECT a FROM TEST_B WHERE TEST_A.b = TEST_B.b)SELECT * FROM TEST_A, (SELECT a FROM TEST_B WHERE TEST_A.b = TEST_B.b) b WHERE TEST_A.a > b.a;

SELECT * FROM TEST_A, LATERAL (SELECT a FROM TEST_B WHERE TEST_A.b = TEST_B.b) b WHERE TEST_A.a > b.a;

如果按照目前ANY类型子连接先提升成子查询的方式,第1个语句提升之后会变成等价于第2个语句,而第2个语句本身是无法执行的,在比较新版本的PostgreSQL上支持了LATERAL之后,只要在第2个语句上加上LATERAL,也就是变成第3个语句就能执行了


1.3 关于选择下推与等价类

选择下推是为了尽早地过滤数据,这样就能在上层结点降低计算量


二、PostgreSQL查询优化器详解之物理优化

2.1 关于统计信息与选择率

PostgreSQL的物理优化需要计算各种物理路径的代价,而代价估算的过程严重依赖于数据库的统计信息,统计信息是否能准确地描述表中的数据分布情况是决定代价准确性的重要条件之一

-- 这两个语句可以用同样的物理算子来完成,但是他们的计算量一样吗?
SELECT A+B FROM TEST_A WHERE A > 1;
SELECT A+B FROM TEST_A WHERE A > 100000000;

A > 1和A > 1000000000都是过滤条件,经过过滤之后,他们产生的数据量就不同了,这样投影中的A+B的计算次数就不同了;要知道A > 1之后还剩下多少数据或者A > 1000000000之后还剩下多少数据,如果我们提前对表上的数据内容做了统计,剩下多少数据就不难计算了,所以必须要有统计信息。

通过统计信息,代价估算系统就可以了解一个表有多少行数据、用了多少个数据页面、某个值出现的频率等等,然后就能根据这些信息计算出一个约束条件能过滤掉多少数据,这种约束条件过滤出的数据占总数据量的比例称之为‘选择率’,所谓选择率就是一个比例
在这里插入图片描述
当实际上比上面的更复杂。


2.2 关于物理路径

  • 扫描路径有顺序扫描路径、索引扫描路径、位图扫描路径等等;
  • 而连接路径通常有嵌套循环连接路径、哈希连接路径、归并连接路径,另外还有一些其他的路径,比如排序路径、物化路径等等

2.2.1 顺序扫描

如果要获得一个表中的数据,最基础的方法就是将表中的所有的数据都遍历一遍,从中挑选出符合条件的数据,这种方式就是顺序扫描路径

顺序扫描路径的优点是具有广泛的适用性,各种表都可以用这种方法,缺点自然是代价通常比较高,因为要把所有的数据都遍历一遍

在这里插入图片描述


2.2.2 索引扫描

如果将数据做一些预处理,比如建立一个索引,如果要想获得一个表的数据,可以通过扫描索引获得所需数据的‘地址’,然后通过地址将需要的数据获取出来。尤其是在选择操作带有约束条件的情况下,在索引和约束条件共同的作用下,表中有些数据就不用再遍历了,因为通过索引就很容易知道这些数据是不符合约束条件的,更有甚者,因为索引上也保存了数据,它的数据和关系中的数据是一致的,因此如果索引上的数据就能满足要求,只需要扫描索引就可以获得所需数据了,也就是说在扫描路径中还可以有索引扫描路径和快速索引扫描路径两种方式

在这里插入图片描述

这个索引扫描有随机读的问题,这个问题能否解决掉呢?也就是说即利用了索引,还避免了随机读的问题,有这样的办法吗

索引扫描路径确实带来随机读的问题,因为索引中记录的是数据元组的地址,索引扫描是通过扫描索引获得元组地址,然后通过元组地址访问数据,索引中保存的“有序”的地址,到数据中就可能是随机的了。位图扫描就能解决这个问题,它通过位图将地址保存起来,把地址收集起来之后,然后让地址变得有序,这样就通过中间的位图把随机读消解掉了

在这里插入图片描述

扫描过程中还会结合一些特殊的情况有一些非常高效的扫描路径,比如TID扫描路径,TID实际上是元组在磁盘上的存储地址,我们能够根据TID直接就获得元组,这样查询效率就非常高了


2.2.3 Nestlooped Join

扫描路径通常是执行计划中的叶子结点,也就是在最底层对表进行扫描的结点,扫描路径就是为连接路径做准备的,扫描出来的数据就可以给连接路径来实现连接操作了

要对两个关系做连接,受笛卡尔积的启发,可以用一个算法复杂度是O(mn)的方法来实现,我们叫它Nestlooped Join方法。这种方法虽然复杂度比较高,但是和顺序扫描一样,胜在具有普适性

嵌套循环连接这种方法的时间复杂度比较高,看上去没什么意义,但是如果Nestlooped Join内表的路径是一个索引扫描路径,那么算法的复杂度就会降下来。索引扫描的算法复杂度是O(logn),因此如果Nestlooped Join的内表是一个索引扫描,它的整体的算法复杂度就变成了O(mlogn),看上去这样也是可以接受的

在这里插入图片描述


2.2.3 Hash Join

假设Hash表有N个桶,内表数据均匀的分布在各个桶中,那么Hash Join的时间复杂度就是O(m * n /N),当然,这里我们没有考虑上建立Hash表的代价;Hash连接通常只能用来做等值判断

在这里插入图片描述


2.2.4 Merge Join

如果将两个表先排序,那么就可以引入第三种连接方式,Merge Join。这种连接方式的代价主要浪费在排序上,如果两个关系的数据量都比较小,那么排序的代价是可控的,MergeJoin就是适用的。另外如果关系上有有序的索引,那就可以不用单独排序了,这样也比较适用于MergeJoin

如下图:外表是需要排序的,而内表则借用了原有的索引的顺序,消除了排序的时间,降低了物理路径的代价
在这里插入图片描述
这些路径属于SPJ路径,在PostgreSQL的优化器中,通常会先生成SPJ的路径,然后在这基础上再叠加Non-SPJ的路径,比如说聚集操作、排序操作、limit操作、分组操作


2.3 关于代价的计算

虽然在代价估算的过程中,我们无法获得‘绝对真实’的代价,但是‘绝对真实’的代价也是不必要的。因为我们只是想从多个路径(Path)中找到一个代价最小的路径,只要这些路径的代价是可以‘相互比较’的就可以了。因此可以设定一个‘相对’的代价的单位1,同一个查询中所有的物理路径都基于这个“相对”的单位1来计算的代价,这样计算出来的代价就是可以比较的,也就能用来对路径进行挑选了

PostgreSQL采用顺序读写一个页面的IO代价作为单位1,而把随机IO定为了顺序IO的4倍

目前的存储介质很大部分仍然是机械硬盘,机械硬盘的磁头在获得数据库的时候需要付出寻道时间。如果要读写的是一串在磁盘上连续的数据,就可以节省寻道时间,提高IO性能。而如果随机读写磁盘上任意扇区的数据,那么会有大量的时间浪费在寻道上。其次,大部分磁盘本身带有缓存,这就形成了主存→磁盘缓存→磁盘的三级结构。在将磁盘的内容加载到内存的时候,考虑到磁盘的IO性能,磁盘会进行数据的预读,把预读到的数据保存在磁盘的缓存中。也就是说如果用户只打算从磁盘读取100个字节的数据,那么磁盘可能会连续地读取磁盘中的512字节(不同的磁盘预读的数量可能不同)并将其保存到磁盘缓存。如果下一次是顺序读取100个字节之后的内容,那么预读的512字节的数据就会发挥作用,性能会大大的增加。而如果读取的内容超出了512字节的范围,那么预读的数据就没有发挥作用,磁盘的IO性能就会下降

目前PostgreSQL的查询优化大量的考虑了随机IO和顺序IO所带来的性能差别,在这方面做了不少优化。但是现在的磁盘技术越来越发达了,以后随机IO和顺序IO是不是还差这么多,就值得商榷了


2.3.1 基准代价

在实际应用中,数据库用户的配置硬件环境千差万别,CPU的频率、主存的大小和磁盘介质的性质都会影响执行计划在实际执行时的效率


基于磁盘IO的代价单位当然就是和Page有关的了,也就是说我们刚才说的顺序IO和随机IO都属于IO方面的基准代价

CPU方面的基准单位有哪些呢?比如说我们通过IO把磁盘页面读到了缓存,但我们要处理的是元组啊,所以还需要把元组从页面里解出来,还要处理元组,这部分主要消耗的是CPU,所以会有一个元组 处理的代价基准单位 。另外,我们在投影、约束条件里有大量的表达式,这些表达式求解也主要消耗CPU资源,所以还有一个 表达式代价的基准单位

现在PostgreSQL增加了很多并行路径,因此它也产生了通信代价,这个也需要计算的

总代价 = CPU代价 + IO代价 + 通信代价


通过EXPLAIN还查看过PostgreSQL的执行计划,从执行计划中还看到有启动代价和总代价

这是从另一个角度来计算代价,启动代价是指从语句开始执行到查询引擎返回第一条元组的代价(另一种说法是准备好去获得第一条元组的代价),总代价是SQL语句从开始执行到结束的所有代价

总代价 = 启动代价 + 执行代价


2.3.2 为什么要区分启动代价和执行代价

SELECT * FROM TEST_A WHERE a > 1 ORDER BY a LIMIT 1;

假设这个在TEST_A(a)上有一个B树索引,那这个语句可能会形成什么样的执行计划呢

执行路径1:LIMIT 1
		    	-> SORT(a)
		             -> SeqScan WHERE A > 1;
执行路径2:LIMIT 1
              	 -> IndexScan WHERE A > 1; 
                  	注:B树索引有序,不用再排序了)

PostgreSQL采用动态规划的方法来实现路径的搜索,它是一种自底向上的方法,也就是说会先建立筛选扫描路径,然后用筛选后的扫描路径再去形成连接路径。那么在我们筛选扫描路径的时候,是不知道它的上层有没有LIMIT的,这时候如果单独看SeqScan + SORTIndexScan哪个好呢

A > 1的选择率高的话会选择顺序扫描,而A > 1的选择率低的情况下,会选择索引扫描。这是因为索引扫描会产生随机IO,也就是说在选择率高的情况下,有可能SeqScan + SORT会优于IndexScan。虽然SeqScan + SORT会有排序,但是IndexScan的随机IO实在是太可观

假设选择率比较高,这时选择了SeqScan + SORT,是因为它不知道再上层是LIMIT 1。如果上面是LIMIT 1,就会导致索引扫描不用全部扫完,只要扫一丢丢就可以了。这时随机IO就很小了,但是SeqScan + SORT就还必须全部执行完才能获取到LIMIT 1,也就是说SeqScan + SORT、或者说SORT要获取第一条元组的启动代价是比较高的。如果上面有LIMIT 1这样的子句,那么启动代价高的路径可能就没有优势了,这就是启动代价的作用

SORT要全部做完才能获取第一条元组,它的启动代价大,但是总代价小。而索引扫描呢,因为本身有序,它的启动代价是小的,但是由于有随机IO,所以它的总代价是大的


2.4 关于最优路径

例如在扫描路径中,我们就可以有顺序扫描、索引扫描和位图扫描。假如一个表上有多个索引,就可能产生多个不同的索引扫描,那么哪个索引扫描路径好呢?还有索引扫描和顺序扫描、位图扫描相比,哪个好呢

数据库路径的搜索方法通常有3种类型:自底向上方法、自顶向下方法、随机方法,而PostgreSQL采用了其中的两种方法。

自底向上和随机方法,其中自底向上的方法是采用动态规划方法,而随机方法采用的是遗传算法;
Pivotal公司的开源优化器ORCA用的就是自顶向下的方法


参考

PostgreSQL查询优化器详解之逻辑优化篇
PostgreSQL查询优化器详解之物理优化篇

猜你喜欢

转载自blog.csdn.net/qq_31156277/article/details/83065907