合 《PostgreSQL技术内幕——原理探索》第三章 查询处理
Tags: PGPostgreSQL翻译《PostgreSQL技术内幕——原理探索》
- 3.1 概览
- 3.1.1 解析器(Parser)
- 3.1.2 分析器(Analyzer)
- 3.1.3 重写器(Rewriter)
- 视图
- 3.1.4 计划器与执行器
- pg_hint_plan
- 3.2 单表查询的代价估计
- 3.2.1 顺序扫描
- 3.2.2 索引扫描
- 3.2.2.1 启动代价
- 3.2.2.2 运行代价
- 选择率(Selectivity)
- 索引相关性(index correlation)
- 3.2.2.3 整体代价
- seq_page_cost和random_page_cost
- 3.2.3 排序
- 3.3 创建单表查询的计划树
- 3.3.1 预处理
- 3.3.2 找出代价最小的访问路径
- 3.3.2.1 例1
- 3.3.2.2 例2
- 3.3.3 创建计划树
- 3.3.3.1 例1
- 3.3.3.2 例2
- 3.4 执行器如何工作
- 临时文件
- 3.5 连接
- 3.5.1 嵌套循环连接(Nested Loop Join)
- 3.5.1.1 嵌套循环连接
- 3.5.1.2 物化嵌套循环连接
- 临时元组存储
- 3.5.1.3 索引嵌套循环连接
- 3.5.1.4 其他变体
- 3.5.2 归并连接(Merge Join)
- 3.5.2.1 归并连接
- 3.5.2.2 物化归并连接
- 3.5.2.3 其他变体
- 3.5.3 散列连接(Hash Join)
- 3.5.3.1 内存散列连接
- 3.5.3.2 带倾斜的混合散列连接
- 3.5.4 连接访问路径与连接节点
- 3.5.4.1 连接访问路径
- 3.5.4.2 连接节点
- 3.6 创建多表查询计划树
- 3.6.1 预处理
- 3.6.2 获取代价最小的路径
- 基因查询优化器
- 3.6.2.1 第一层的处理
- 3.6.2.2 第二层的处理
- 3.6.3 获取三表查询代价最小的路径
- 参考文献
查询处理是PostgreSQL中最为复杂的子系统。如PostgreSQL官方文档所述,PostgreSQL支持SQL2011标准中的大多数特性,查询处理子系统能够高效地处理这些SQL。本章概述了查询处理的流程,特别关注了查询优化的部分。
本章包括下列三个部分:
第一部分:3.1节
这一节会简单介绍PostgreSQL中查询处理的流程。
第二部分:3.2~3.4节
这一部分会描述获取单表查询上最优执行计划的步骤。3.2节讨论代价估计的过程,3.3节描述创建计划树的过程,3.4节将简要介绍执行器的工作过程。
第三部分:3.5~3.6节
这一部分会描述获取多表查询上最优执行计划的步骤。3.5节介绍了三种连接算法:嵌套循环连接(Nested Loop Join),归并连接(Merge Join) ,散列连接(Hash Join)。3.6节将介绍为多表查询创建计划树的过程。
PostgreSQL支持三种技术上很有趣,而且也很实用的功能:外部数据包装(Foreign Data Wrapper, FDW),并行查询,以及版本11即将支持的JIT编译。前两者将在第4章中描述,JIT编译超出范围本书的范围,详见官方文档。
3.1 概览
尽管PostgreSQL在9.6版本后有了基于多个后台工作进程的并行查询,但大体上来讲,还是每个连接对应一个后端进程。后端进程由五个子系统组成,如下所示:
解析器(Parser)
解析器根据SQL语句生成一颗语法解析树(parse tree) 。
分析器(Analyzer)
分析器对语法解析树进行语义分析,生成一颗查询树(query tree)。
重写器(Rewriter)
重写器按照规则系统中存在的规则,对查询树进行改写。
计划器(Planner)
计划器基于查询树,生成一颗执行效率最高的计划树(plan tree)。
执行器(Executor)
执行器按照计划树中的顺序访问表和索引,执行相应查询。
图3.1 查询处理
本节将概述这些子系统。计划器和执行器很复杂,后面的章节会对这些函数的细节进行描述。
PostgreSQL的查询处理在官方文档中有详细的描述
3.1.1 解析器(Parser)
解析器基于SQL语句的文本,生成一颗后续子系统可以理解的语法解析树。下面是一个具体的例子。
考虑以下查询:
1 | testdb=# SELECT id, data FROM tbl_a WHERE id < 300 ORDER BY data; |
语法解析树的根节点是一个定义在parsenodes.h
中的SelectStmt
数据结构。图3.2(a)展示了一个查询,而图3.2(b)则是该查询对应的语法解析树。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | typedef struct SelectStmt { NodeTag type; /* 这些字段只会在SelectStmts“叶节点”中使用 */ List *distinctClause; /* NULL, DISTINCT ON表达式列表, 或 对所有的(SELECT DISTINCT)为lcons(NIL,NIL) */ IntoClause *intoClause; /* SELECT INTO 的目标 */ List *targetList; /* 结果目标列表 (ResTarget) */ List *fromClause; /* FROM 子句 */ Node *whereClause; /* WHERE 限定条件 */ List *groupClause; /* GROUP BY 子句 */ Node *havingClause; /* HAVING 条件表达式 */ List *windowClause; /* WINDOW window_name AS (...), ... */ /* 在一个表示值列表的叶节点中,上面的字段全都为空,而这个字段会被设置。 * 注意这个子列表中的元素仅仅是表达式,没有ResTarget的修饰,还需要注意列表元素可能为 * DEFAULT (表示一个 SetToDefault 节点),而无论值列表的上下文。 * 由分析阶段决定否合法并拒绝。 */ List *valuesLists; /* 未转换的表达式列表 */ /* 这些字段会同时在SelectStmts叶节点与SelectStmts上层节点中使用 */ List *sortClause; /* 排序子句 (排序依据的列表) */ Node *limitOffset; /* 需要跳过的元组数目 */ Node *limitCount; /* 需要返回的元组数目 */ List *lockingClause; /* FOR UPDATE (锁子句的列表) */ WithClause *withClause; /* WITH 子句 */ /* 这些字段只会在上层的 SelectStmts 中出现 */ SetOperation op; /* set 操作的类型 */ bool all; /* 是否指明了 ALL 选项? */ struct SelectStmt *larg; /* 左子节点 */ struct SelectStmt *rarg; /* 右子节点 */ } SelectStmt; |
图3.2. 语法解析树的例子
SELECT
查询中的元素和语法解析树中的元素有着对应关系。比如,(1)是目标列表中的一个元素,与目标表的'id'
列相对应,(4)是一个WHERE
子句,诸如此类。
当解析器生成语法分析树时只会检查语法,只有当查询中出现语法错误时才会返回错误。解析器并不会检查输入查询的语义,举个例子,如果查询中包含一个不存在的表名,解析器并不会报错,语义检查由分析器负责。
3.1.2 分析器(Analyzer)
分析器对解析器产出的语法解析树(parse tree)进行语义分析,并产出一颗查询树(query tree)。
查询树的根节点是parsenode.h
中定义的Query
数据结构,这个结构包含着对应查询的元数据,比如命令的类型(SELECT/INSERT
等),还包括了一些叶子节点,叶子节点由列表或树组成,包含了特定子句相应的数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | /* * Query - * 解析与分析过程会将所有的语句转换为一颗查询树,供重写器与计划器用于进一步的处理。 * 功能语句(即不可优化的语句)会设置utilityStmt字段,而Query结构本身基本上是空的。 * DECLARE CURSOR 是一个特例:它的形式与SELECT类似,但原始的DeclareCursorStmt会 * 被放在 utilityStmt 字段中。 * 计划过程会将查询树转换为一颗计划树,计划树的根节点是一个PlannedStmt结构 * 执行器不会用到查询树结构 */ typedef struct Query { NodeTag type; CmdType commandType; /* select|insert|update|delete|utility */ QuerySource querySource; /* 我来自哪里? */ uint32 queryId; /* 查询标识符 (可由插件配置) */ bool canSetTag; /* 我设置了命令结果标签吗? */ Node *utilityStmt; /* 如果这是一条DECLARE CURSOR或不可优化的语句 */ int resultRelation; /* 对增删改语句而言是目标关系的索引; SELECT为0 */ bool hasAggs; /* 是否在目标列表或having表达式中指定了聚合函数 */ bool hasWindowFuncs; /* tlist是否包含窗口函数 */ bool hasSubLinks; /* 是否包含子查询SubLink */ bool hasDistinctOn; /* 是否包含来自DISTINCT ON的distinct子句 */ bool hasRecursive; /* 是否制定了WITH RECURSIVE */ bool hasModifyingCTE; /* 是否在WITH子句中包含了INSERT/UPDATE/DELETE */ bool hasForUpdate; /* 是否指定了FOR [KEY] UPDATE/SHARE*/ bool hasRowSecurity; /* 是否应用了行安全策略 */ List *cteList; /* CTE列表 */ List *rtable; /* 范围表项目列表 */ FromExpr *jointree; /* 表连接树 (FROM 与 WHERE 子句) */ List *targetList; /* 目标列表 (TargetEntry的列表) */ List *withCheckOptions; /* WithCheckOption的列表 */ OnConflictExpr *onConflict; /* ON CONFLICT DO [NOTHING | UPDATE] */ List *returningList; /* 返回值列表(TargetEntry的列表) */ List *groupClause; /* SortGroupClause的列表 */ List *groupingSets; /* 如果有,GroupingSet的列表 */ Node *havingQual; /* 分组的Having条件列表 */ List *windowClause; /* 窗口子句列表 */ List *distinctClause; /* SortGroupClause列表 */ List *sortClause; /* SortGroupClause列表 */ Node *limitOffset; /* Offset跳过元组数目 (int8 表达式) */ Node *limitCount; /* Limit返回元组数目 (int8 表达式) */ List *rowMarks; /* RowMarkClause列表 */ Node *setOperations; /* 如果是UNION/INTERSECT/EXCEPT的顶层查询, 则为集合操作列表 */ List *constraintDeps; /* 确认查询语义是否合法时,所依赖约束对象的OID列表 */ } Query; |
图3.3 查询树一例
简要介绍一下上图中的查询树:
targetlist
是查询结果中列(Column)的列表。在本例中该列表包含两列:id
和data
。如果在输入的查询树中使用了*
(星号),那么分析器会将其显式替换为所有具体的列。- 范围表
rtable
是该查询所用到关系的列表。本例中该变量包含了表tbl_a
的信息,如该表的表名与oid
。 - 连接树
jointree
存储着FROM
和WHERE
子句的相关信息。 - 排序子句
sortClause
是SortGroupClause
结构体的列表。
官方文档描述了查询树的细节。
3.1.3 重写器(Rewriter)
PostgreSQL的规则系统正是基于重写器实现的;当需要时,重写器会根据存储在pg_rules
中的规则对查询树进行转换。规则系统本身也是一个很有趣的系统,不过本章略去了关于规则系统和重写器的描述,以免内容过于冗长。
视图
在PostgreSQL中,视图是基于规则系统实现的。当使用
CREATE VIEW
命令定义一个视图时,PostgreSQL就会创建相应的规则,并存储到系统目录中。假设下面的视图已经被定义,而
pg_rule
中也存储了相应的规则。
123 sampledb=# CREATE VIEW employees_listsampledb-# AS SELECT e.id, e.name, d.name AS departmentsampledb-# FROM employees AS e, departments AS d WHERE e.department_id = d.id;当执行一个包含该视图的查询,解析器会创建一颗如图3.4(a)所示的语法解析树。
1 sampledb=# SELECT * FROM employees_list;在该阶段,重写器会基于
pg_rules
中存储的视图规则将rangetable
节点重写为一颗查询子树,与子查询相对应。图3.4 重写阶段一例
因为PostgreSQL使用这种机制实现视图,直到9.2版本,视图都是不能更新的。虽然9.3版本后可以对视图进行更新,但对视图的更新仍然存在很多限制,具体细节请参考官方文档。
3.1.4 计划器与执行器
计划器从重写器获取一颗查询树(query tree),基于查询树生成一颗能被执行器高效执行的(查询)计划树(plan tree)。
在PostgreSQL中,计划器是完全基于代价估计(cost-based)的;它不支持基于规则的优化与提示(hint)。计划器是RDBMS中最为复杂的部分,因此本章的后续内容会对计划器做一个概述。
pg_hint_plan
PostgreSQL不支持SQL中的提示(hint),并且永远也不会去支持。如果你想在查询中使用提示,可以考虑使用
pg_hint_plan
扩展,细节请参考官方站点。
与其他RDBMS类似,PostgreSQL中的EXPLAIN
命令会显示命令的计划树。下面给出了一个具体的例子。
1 2 3 4 5 6 7 8 | testdb=# EXPLAIN SELECT * FROM tbl_a WHERE id < 300 ORDER BY data; QUERY PLAN --------------------------------------------------------------- Sort (cost=182.34..183.09 rows=300 width=8) Sort Key: data -> Seq Scan on tbl_a (cost=0.00..170.00 rows=300 width=8) Filter: (id < 300) (4 rows) |
图3.5展示了结果相应的计划树。
图3.5 一个简单的计划树以及其与EXPLAIN命令的关系
计划树由许多称为计划节点(plan node)的元素组成,这些节点挂在PlannedStmt
结构对应的计划树上。这些元素的定义在plannodes.h
中,第3.3.3节与第3.5.4.2会解释相关细节。
每个计划节点都包含着执行器进行处理所必需的信息,在单表查询的场景中,执行器会按照从终端节点往根节点的顺序依次处理这些节点。
比如图3.5中的计划树就是一个列表,包含一个排序节点和一个顺序扫描节点;因而执行器会首先对表tbl_a
执行顺序扫描,并对获取的结果进行排序。
执行器会通过第8章将介绍的缓冲区管理器来访问数据库集簇的表和索引。当处理一个查询时,执行器会使用预先分配的内存空间,比如temp_buffers
和work_mem
,必要时还会创建临时文件。
图3.6 执行器,缓冲管理器,临时文件之间的关系
除此之外,当访问元组的时候,PostgreSQL还会使用并发控制机制来维护运行中事务的一致性和隔离性。第五章介绍了并发控制机制。
3.2 单表查询的代价估计
PostgreSQL的查询优化是基于代价(Cost)的。代价是一个无量纲的值,它并不是一种绝对的性能指标,但可以作为比较各种操作代价时的相对性能指标。
costsize.c
中的函数用于估算各种操作的代价。所有被执行器执行的操作都有着相应的代价函数。例如,函数cost_seqscan()
和 cost_index()
分别用于估算顺序扫描和索引扫描的代价。
在PostgreSQL中有三种代价:启动(start-up) , 运行(run)和总和(total)。总代价是启动代价和运行代价的和;因此只有启动代价和运行代价是单独估计的。
- 启动代价(start-up):在读取到第一条元组前花费的代价,比如索引扫描节点的启动代价就是读取目标表的索引页,取到第一个元组的代价
- 运行代价(run): 获取全部元组的代价
- 总代价(total):前两者之和
EXPLAIN
命令显示了每个操作的启动代价和总代价,下面是一个简单的例子:
1 2 3 4 5 | testdb=# EXPLAIN SELECT * FROM tbl; QUERY PLAN --------------------------------------------------------- Seq Scan on tbl (cost=0.00..145.00 rows=10000 width=8) (1 row) |
在第4行显示了顺序扫描的相关信息。代价部分包含了两个值:0.00和145.00。在本例中,启动代价和总代价分别为0.00和145.00。
在本节中,我们将详细介绍顺序扫描,索引扫描和排序操作的代价是如何估算的。
在接下来的内容中,我们使用下面这个表及其索引作为例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 | testdb=# CREATE TABLE tbl (id int PRIMARY KEY, data int); testdb=# CREATE INDEX tbl_data_idx ON tbl (data); testdb=# INSERT INTO tbl SELECT generate_series(1,10000),generate_series(1,10000); testdb=# ANALYZE; testdb=# \d tbl Table "public.tbl" Column | Type | Modifiers --------+---------+----------- id | integer | not null data | integer | Indexes: "tbl_pkey" PRIMARY KEY, btree (id) "tbl_data_idx" btree (data) |
3.2.1 顺序扫描
顺序扫描的代价是通过函数cost_seqscan()
估计的。本节将研究顺序扫描代价是如何估计的,以下面的查询为例:
1 | testdb=# SELECT * FROM tbl WHERE id < 8000; |
在顺序扫描中,启动代价等于0,而运行代价由以下公式定义: $$ \begin{align} \verb|run_cost| &= \verb|cpu_run_cost| + \verb|disk_run_cost | \ &= (\verb|cpu_tuple_cost| + \verb|cpu_operatorcost|) × N{\verb|tuple|} + \verb|seq_pagecost| × N{\verb|page|}, \end{align} $$ 其中seq_page_cost
,cpu_tuple_cost
和cpu_operator_cost
是在postgresql.conf 中配置的参数,默认值分别为1.0,0.01和0.0025。$N{\verb|tuple|}$ 和$N{\verb|page|}$ 分别是表中的元组总数与页面总数,这两个值可以使用以下查询获取。
1 2 3 4 5 | testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl'; relpages | reltuples ----------+----------- 45 | 10000 (1 row) |
$$ \begin{equation}\tag{1} N_{\verb|tuple|}=10000 \end{equation} $$
$$ \begin{equation}\tag{2} N_{\verb|page|}=45 \end{equation} $$
因此: $$ \begin{align} \verb|run_cost| &= (0.01 + 0.0025) × 10000 + 1.0 × 45 = 170.0. \end{align} $$
最终: $$ \verb|total_cost| = 0.0 + 170.0 = 170.0 $$
作为验证,下面是该查询的EXPLAIN
结果:
1 2 3 4 5 6 | 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) |
在第4行中可以看到,启动代价和总代价分别是0.00和170.0,且预计全表扫描返回行数为8000条(元组)。
在第5行显示了一个顺序扫描的过滤器Filter:(id < 8000)
。更精确地说,它是一个表级过滤谓词(table level filter predicate)。注意这种类型的过滤器只会在读取所有元组的时候使用,它并不会减少需要扫描的表页面数量。
从优化运行代价的角度来看,PostgreSQL假设所有的物理页都是从存储介质中获取的;即,PostgreSQL不会考虑扫 描的页面是否来自共享缓冲区。
3.2.2 索引扫描
尽管PostgreSQL支持很多索引方法,比如B树,GiST,GIN和BRIN,不过索引扫描的代价估计都使用一个共用的代价函数:cost_index()
。
本节将研究索引扫描的代价是如何估计的,以下列查询为例。
1 | testdb=# SELECT id, data FROM tbl WHERE data < 240; |
在估计该查询的代价之前,下面的查询能获取$N{\verb|index|,\verb|page|}$和$N{\verb|index|,\verb|tuple|}$的值:
1 2 3 4 5 | testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl_data_idx'; relpages | reltuples ----------+----------- 30 | 10000 (1 row) |
$$ \begin{equation}\tag{3} N_{\verb|index|,\verb|tuple|} = 10000 \end{equation} $$
$$ \begin{equation}\tag{4} N_{\verb|index|,\verb|page|} = 30 \end{equation} $$
3.2.2.1 启动代价
索引扫描的启动代价就是读取索引页以访问目标表的第一条元组的代价,由下面的公式定义: $$ \begin{equation} \verb| start-up_cost| = {\mathrm{ceil}(\log2 (N{\verb|index|,\verb|tuple|})) + (H_{\verb|index|} + 1) × 50} × \verb|cpu_operatorcost| \end{equation} $$ 其中$H{\verb|index|}$是索引树的高度。
在本例中,套用公式(3),$N{\verb|index,tuple|}$是10000;$H{\verb|index|}$是1;$\verb|cpu_operator_cost|$是0.0025(默认值)。因此 $$ \begin{equation}\tag{5} \verb|start-up_cost| = {\mathrm{ceil}(\log_2(10000)) + (1 + 1) × 50} × 0.0025 = 0.285 \end{equation} $$
3.2.2.2 运行代价
索引扫描的运行代价是表和索引的CPU代价与IO代价之和。 $$ \begin{align} \verb|run_cost| &= (\verb|index_cpu_cost| + \verb|table_cpu_cost|) + (\verb|index_io_cost| + \verb|table_io_cost|). \end{align} $$
前三个代价(即index_cpu_cost
,table_cpu_cost
和index_io_cost
)如下所示:
$$ \begin{align} \verb|index_cpucost| &= \verb|Selectivity| × N{\verb|index|,\verb|tuple|} × (\verb|cpu_index_tuple_cost| + \verb|qual_op_cost|) \ \verb|table_cpucost| &= \verb|Selectivity| × N{\verb|tuple|}× \verb|cpu_tuple_cost| \ \verb|index_iocost| &= \mathrm{ceil}(\verb|Selectivity| × N{\verb|index|,\verb|page|}) ×\verb|random_page_cost| \end{align} $$
以上公式中的cpu_index_tuple_cost
和random_page_cost
在postgresql.conf中配置(默认值分别为0.005和4.0)。$\verb|qual_op_cost|$粗略来说就是索引求值的代价,默认值是0.0025,这里不再展开。选择率(Selectivity)是一个0到1之间的浮点数,代表查询指定的MARKDOWN_HASH5105e0481cb9b1e1d0dd3e10bab1f1c0MARKDOWNHASH
子句在索引中搜索范围的比例。举个例子,$(\verb|Selectivity| × N{\verb|tuple|})$就是需要读取的表元组数量,$(\verb|Selectivity| × N_{\verb|index|,\verb|tuple|})$就是需要读取的索引元组数量,诸如此类。
选择率(Selectivity)
查询谓词的选择率是通过直方图界值(histogram_bounds)与高频值(Most Common Value, MCV)估计的,这些信息都存储在系统目录
pg_statistics
中,并可通过pg_stats
视图查询。这里通过一个具体的例子来简要介绍选择率的计算方法,细节可以参考官方文档。表中每一列的高频值都在
pg_stats
视图的most_common_vals
和most_common_freqs
中成对存储。
- 高频值(most_common_vals):该列上最常出现的取值列表
- 高频值频率(most_common_freqs):高频值相应出现频率的列表
下面是一个简单的例子。表
countries
有两列:一列country
存储国家名,一列continent
存储该国所属大洲。
123456789101112131415161718192021 testdb=# \d countriesTable "public.countries"Column | Type | Modifiers-----------+------+-----------country | text |continent | text |Indexes:"continent_idx" btree (continent)testdb=# SELECT continent, count(*) AS "number of countries",testdb-# (count(*)/(SELECT count(*) FROM countries)::real) AS "number of countries / all countries"testdb-# FROM countries GROUP BY continent ORDER BY "number of countries" DESC;continent | number of countries | number of countries / all countries---------------+---------------------+-------------------------------------Africa | 53 | 0.274611398963731Europe | 47 | 0.243523316062176Asia | 44 | 0.227979274611399North America | 23 | 0.119170984455959Oceania | 14 | 0.0725388601036269South America | 12 | 0.0621761658031088(6 rows)考虑下面的查询,该查询带有
WHERE
条件continent = 'Asia'
。
1 testdb=# SELECT * FROM countries WHERE continent = 'Asia';这时候,计划器使用
continent
列上的高频值来估计索引扫描的代价,列上的most_common_vals
与most_common_freqs
如下所示:
1234567 testdb=# \xExpanded display is on.testdb=# SELECT most_common_vals, most_common_freqs FROM pg_statstestdb-# WHERE tablename = 'countries' AND attname='continent';-[ RECORD 1 ]-----+-----------------------------------------------------------most_common_vals | {Africa,Europe,Asia,"North America",Oceania,"South America"}most_common_freqs | {0.274611,0.243523,0.227979,0.119171,0.0725389,0.0621762}与
most_common_vals
中Asia
值对应的most_common_freqs
为0.227979。因此0.227979会在估算中被用作选择率。如果高频值不可用,就会使用目标列上的直方图界值来估计代价。
- 直方图值(histogram_bounds)是一系列值,这些值将列上的取值划分为数量大致相同的若干个组。
下面是一个具体的例子。这是表
tbl
中data
列上的直方图界值;
123456789 testdb=# SELECT histogram_bounds FROM pg_stats WHERE tablename = 'tbl' AND attname = 'data';histogram_bounds------------------------------------------------------------------------------{1,100,200,300,400,500,600,700,800,900,1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000,2100,2200,2300,2400,2500,2600,2700,2800,2900,3000,3100,3200,3300,3400,3500,3600,3700,3800,3900,4000,4100,4200,4300,4400,4500,4600,4700,4800,4900,5000,5100,5200,5300,5400,5500,5600,5700,5800,5900,6000,6100,6200,6300,6400,6500,6600,6700,6800,6900,7000,7100,7200,7300,7400,7500,7600,7700,7800,7900,8000,8100,8200,8300,8400,8500,8600,8700,8800,8900,9000,9100,9200,9300,9400,9500,9600,9700,9800,9900,10000}(1 row)默认情况下,直方图界值会将列上的取值划分入100个桶。图3.7展示了这些桶及其对应的直方图界值。桶从0开始编号,每个桶保存了(大致)相同数量的元组。直方图界值就是相应桶的边界。比如,直方图界值的第0个值是1,意即这是
bucket_0
中的最小值。第1个值是100,意即bucket_1
中的最小值是100,等等。图3.7 桶和直方图界值
然后本节例子中选择率计算如下所示。假设查询带有
WHERE
子句data < 240
,而值240落在第二个桶中。在本例中可以通过线性插值推算出相应的选择率。因此查询中data
列的选择率可以套用下面的公式计算: $$ \verb|Selectivity| = \frac{2+(240-hb[2])/(hb[3]-hb[2])}{100}=\frac{2+(240-200)/(300-200)}{100}=\frac{2+40/100}{100}=0.024 \ (6) $$
因此,根据公式(1),(3),(4)和(6),有 $$ \begin{equation}\tag{7} \verb|index_cpu_cost| = 0.024× 10000 × (0.005+0.0025)=1.8 \end{equation} $$ $$ \begin{equation}\tag{8} \verb|table_cpu_cost| = 0.024 × 10000 × 0.01 = 2.4 \end{equation} $$
$$ \begin{equation}\tag{9} \verb|index_io_cost| = \mathrm{ceil}(0.024 × 30) × 4.0 = 4.0 \end{equation} $$
$\verb|table_io_cost|$ 由下面的公式定义: $$ \begin{equation} \verb|table_io_cost| = \verb|max_io_cost| + \verb|indexCorerelation|^2 × (\verb|min_io_cost|-\verb|max_io_cost|) \end{equation} $$
$\verb|max_io_cost_io_cost|$ 是最差情况下的I/O代价,即,随机扫描所有数据页的代价;这个代价由以下公式定义: $$ \begin{equation} \verb|max_iocost| = N{\verb|page|} × \verb|random_page_cost| \end{equation} $$
在本例中,由(2),$N_{\verb|page|}=45$,得 $$ \begin{equation}\tag{10} \verb|max_io_cost| = 45 × 4.0 = 180.0 \end{equation} $$
$\verb|min_io_cost|$是最优情况下的I/O代价,即,顺序扫描选定的数据页;这个代价由以下公式定义: $$ \begin{equation} \verb|min_io_cost| = 1 × \verb|random_pagecost| + (\mathrm{ceil}(\verb|Selectivity| × N{\verb|page|})-1) × \verb|seq_page_cost| \end{equation} $$ 在本例中, $$ \begin{equation} \tag{11} \verb|min_io_cost| \ = 1 × 4.0 + (\mathrm{ceil}(0.024 × 45)-1) × 1.0 \end{equation} $$
下文详细介绍$\verb|indexCorrelation|$,在本例中, $$ \begin{equation} \tag{12} \verb|indexCorrelation| = 1.0 \end{equation} $$
由(10),(11)和(12),得 $$ \begin{equation} \tag{13} \verb|table_io_cost| = 180.0+1.0^2 × (5.0-180.0)=5.0 \end{equation} $$
综上,由(7),(8),(9)和(13)得 $$ \begin{equation}\tag{14} \verb|run_cost| = (1.8+2.4)+(4.0+5.0)=13.2 \end{equation} $$
索引相关性(index correlation)
索引相关性是列值在物理上的顺序和逻辑上的顺序的统计相关性(引自官方文档)。索引相关性的取值范围从$-1$到$+1$。下面的例子有助于理解索引扫描和索引相关性的关系。
表
tbl_corr
有5个列:两个列为文本类型,三个列为整数类型。这三个整数列保存着从1到12的数字。在物理上表tbl_corr
包含三个页,每页有4条元组。每个数字列有一个名如index_col_asc
的索引。
123456789101112131415161718192021222324252627282930 testdb=# \d tbl_corrTable "public.tbl_corr"Column | Type | Modifiers----------+---------+-----------col | text |col_asc | integer |col_desc | integer |col_rand | integer |data | text |Indexes:"tbl_corr_asc_idx" btree (col_asc)"tbl_corr_desc_idx" btree (col_desc)"tbl_corr_rand_idx" btree (col_rand)testdb=# SELECT col,col_asc,col_desc,col_randtestdb-# FROM tbl_corr;col | col_asc | col_desc | col_rand----------+---------+----------+----------Tuple_1 | 1 | 12 | 3Tuple_2 | 2 | 11 | 8Tuple_3 | 3 | 10 | 5Tuple_4 | 4 | 9 | 9Tuple_5 | 5 | 8 | 7Tuple_6 | 6 | 7 | 2Tuple_7 | 7 | 6 | 10Tuple_8 | 8 | 5 | 11Tuple_9 | 9 | 4 | 4Tuple_10 | 10 | 3 | 1Tuple_11 | 11 | 2 | 12Tuple_12 | 12 | 1 | 6(12 rows)这些列的索引相关性如下:
1234567 testdb=# SELECT tablename,attname, correlation FROM pg_stats WHERE tablename = 'tbl_corr';tablename | attname | correlation-----------+----------+-------------tbl_corr | col_asc | 1tbl_corr | col_desc | -1tbl_corr | col_rand | 0.125874(3 rows)当执行下列查询时,由于所有的目标元组都在第一页中,PostgreSQL只会读取第一页,如图3.8(a)所示。
1 testdb=# SELECT * FROM tbl_corr WHERE col_asc BETWEEN 2 AND 4;而执行下列查询时则不然,PostgreSQL需要读所有的页,如图3.8(b)所示。
1 testdb=# SELECT * FROM tbl_corr WHERE col_rand BETWEEN 2 AND 4;如此可知,索引相关性是一种统计上的相关性。在索引扫描代价估计中,索引相关性体现了索引顺序和物理元组顺序扭曲程度给随机访问性能造成的影响大小。
图3.8 索引相关性
3.2.2.3 整体代价
由(3)和(14)可得 $$ \begin{equation}\tag{15} \verb|total_cost| = 0.285 + 13.2 = 13.485 \end{equation} $$
作为确认,上述SELECT
查询的EXPLAIN
结果如下所示:
1 2 3 4 5 6 | testdb=# EXPLAIN SELECT id, data FROM tbl WHERE data < 240; QUERY PLAN --------------------------------------------------------------------------- Index Scan using tbl_data_idx on tbl (cost=0.29..13.49 rows=240 width=8) Index Cond: (data < 240) (2 rows) |
在第4行可以看到启动代价和总代价分别是0.29和13.49,预估有240条元组被扫描。
在第5行显示了一个索引条件Index Cond:(data < 240)
。更准确地说,这个条件叫做访问谓词(access predicate),它表达了索引扫描的开始条件与结束条件。
根据这篇文章,PostgreSQL中的
EXPLAIN
命令不会区分访问谓词(access predicate)和索引过滤谓词(index filter predicate)。因此当分析EXPLAIN
的输出时,即使看到了“IndexCond”,也应当注意一下预估返回行数。
seq_page_cost
和random_page_cost
seq_page_cost
和random_page_cost
的默认值分别为1.0和4.0。这意味着PostgreSQL假设随机扫描比顺序扫描慢4倍;显然,PostgreSQL的默认值是基于HDD(普通硬盘)设置的。另一方面,近年来SSD得到了广泛的应用,
random_page_cost
的默认值就显得太大了。使用SSD时如果仍然采用random_page_cost
的默认值,则计划器有可能会选择低效的计划。因此当使用SSD时最好将random_page_cost
的值设为1.0。这篇文章报告了使用
random_page_cost
默认值导致的问题。
3.2.3 排序
排序路径(sort path) 会在排序操作中被使用。排序操作包括ORDER BY
,归并连接的预处理操作,以及其他函数。函数cost_sort()
用于估计排序操作的代价。
如果能在工作内存中放下所有元组,那么排序操作会选用快速排序算法。否则的话则会创建临时文件,使用文件归并排序算法。
排序路径的启动代价就是对目标表的排序代价,因此代价就是$O(N_{\verb|sort|}× \log2(N{\verb|sort|})$,这里$N{\verb|sort|}$就是待排序的元组数。排序路径的运行代价就是读取已经排好序的元组的代价,因而代价就是$O(N{sort})$。
本节将研究以下查询排序代价的估计过程。假设该查询只使用工作内存,不使用临时文件。
1 | testdb=# SELECT id, data FROM tbl WHERE data < 240 ORDER BY id; |
在本例中,启动代价由以下公式定义: $$ \begin{equation} \verb|start-up_cost| = \verb|C|+ \verb|comparisoncost| × N{\verb|sort|} × \log2(N{\verb|sort|}) \end{equation} $$
这里$C$就是上一次扫描的总代价,即上次索引扫描的总代价;由(15)可得C等于13.485;$N_{\verb|sort|}=240$;$\verb|comparison_cost|$ 定义为$2 × \verb|cpu_operator_cost|$。因此有
$$ \begin{equation} \verb|start-up_cost| = 13.485+(2×0.0025)×240.0×\log_2(240.0)=22.973 \end{equation} $$
运行代价是在内存中读取排好序的元组的代价,即: $$ \begin{equation} \verb|run_cost| = \verb|cpu_operatorcost| × N{\verb|sort|} = 0.0025 × 240 = 0.6 \end{equation} $$ 综上: $$ \begin{equation} \verb|total_cost|=22.973+0.6=23.573 \end{equation} $$ 作为确认,以上SELECT
查询的EXPLAIN
命令结果如下:
1 2 3 4 5 6 7 8 | testdb=# EXPLAIN SELECT id, data FROM tbl WHERE data < 240 ORDER BY id; QUERY PLAN --------------------------------------------------------------------------------- Sort (cost=22.97..23.57 rows=240 width=8) Sort Key: id -> Index Scan using tbl_data_idx on tbl (cost=0.29..13.49 rows=240 width=8) Index Cond: (data < 240) (4 rows) |
在第4行可以看到启动代价和运行代价分别为22.97和23.57。
3.3 创建单表查询的计划树
计划器非常复杂,故本节仅描述最简单的情况,即单表查询的计划树创建过程。更复杂的查询,换而言之即多表查询,其计划树创建过程将在第3.6节中阐述。
PostgreSQL中的计划器会执行三个处理步骤:
- 执行预处理
- 在所有可能的访问路径中,找出代价最小的访问路径
- 按照代价最小的路径,创建计划树
访问路径(access path)是估算代价时的处理单元;比如,顺序扫描,索引扫描,排序以及各种连接操作都有其对应的路径。访问路径只在计划器创建查询计划树的时候使用。最基本的访问路径数据结构就是relation.h
中定义的Path
结构体。它就相当于是顺序扫描。所有其他的访问路径都基于该结构,下面会介绍细节。
计划器为了处理上述步骤,会在内部创建一个PlannerInfo
数据结构。在该数据结构中包含着查询树,查询所涉及关系信息,访问路径等等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | typedef struct PathKey { NodeTag type; EquivalenceClass *pk_eclass; /* 值是否有序 */ Oid pk_opfamily; /* 用于定义顺序的B树操作符族 */ int pk_strategy; /* 排序方向(ASC or DESC) */ bool pk_nulls_first; /* NULL是否排序在常规值之前? */ } PathKey; typedef struct Path { NodeTag type; NodeTag pathtype; /* 标识 scan/join 方法的标签 */ RelOptInfo *parent; /* 路径所基于的关系 */ PathTarget *pathtarget; /* Vars/Exprs的列表, 代价, 宽度 */ ParamPathInfo *param_info; /* 参数化信息, 如果没有则为NULL */ bool parallel_aware; /* 涉及到并行相关的逻辑? */ bool parallel_safe; /* 是否能作为并行执行计划的一部分? */ int parallel_workers; /* 期待的并行工作进程数量; 0表示没有并行 */ /* 估计路径的尺寸或代价 (更多详情参考costsize.c) */ double rows; /* 预估结果元组数目 */ Cost startup_cost; /* 获取任何元组前需要花费的代价 */ Cost total_cost; /* 总代价 (假设获取所有元组所需代价) */ List *pathkeys; /* 路径输出的排序顺序 */ /* pathkeys 是PathKey节点的列表,PathKey定义见上面 */ } Path; typedef struct PlannerInfo { NodeTag type; Query *parse; /* 被计划的查询 */ PlannerGlobal *glob; /* 当前计划器运行时的全局信息 */ Index query_level; /* 最外层查询为1 */ struct PlannerInfo *parent_root; /* 最外层查询为NULL */ /* plan_params包含着当前计划中的查询层次需要对低层查询暴露的表达式。 * outer_params包含着PARAM_EXEC参数中的paramId列表,这些参数是外 * 部查询层次对当前查询层次所暴露的。*/ List *plan_params; /* PlannerParamItems的列表, 见下 */ Bitmapset *outer_params; /* simple_rel_array 持有着指向“基础关系”与“其他关系”的指针 (详情参考 * RelOptInfo的注释)。它由rangetable index所索引(因此第0项总是废值)。 * 当RTE并不与基础关系相对应,譬如连接的RTE,或未引用的视图RTE,或该 * RelOptInfo还没有产生时,里面的项目可能为NULL。*/ struct RelOptInfo **simple_rel_array; /* 所有单个关系的RelOptInfos */ int simple_rel_array_size; /* 数组分配的大小 */ /* simple_rte_array 与simple_rel_array 长度相同,且持有指向关联范围表项的指针。 * 这使得我们能避免执行rt_fetch(), 当需要展开很大的继承集时会很慢。 */ RangeTblEntry **simple_rte_array; /* rangetable的数组 */ /* all_baserels是所有查询所涉及基本关系的关系ID列表(但不含“其他关系”的ID) * 也就是说,最终连接时,所需构建的关系标识符。该字段是由make_one_rel计算的。 * 计算发生于计算Paths之前。*/ Relids all_baserels; /* nullable_baserels 是在进行外连接的jointree中那些可空的基础关系的ID集合。 * 这些关系可能在WHERE子句,SELECT目标列表或其他地方产生空值。该字段由函数 * deconstruct_jointree负责计算。*/ Relids nullable_baserels; /* join_rel_list是一个列表,在计划过程中连接关系的RelOptInfos都放在这里。 * 对于比较小的问题,我们只是简单的扫过这个列表来完成查找。但当连接很多关系时, * 我们会使用散列表来加速查询。散列表当且仅当join_rel_hash不为空时存在且 * 有效。注意即使用散列表查找时,我们依然会维护列表,这会简化GEQO的相关问题。*/ List *join_rel_list; /* 连接关系的RelOptInfos */ struct HTAB *join_rel_hash; /* 连接关系的散列表,可选 */ /* 当使用动态规划进行连接搜索时,join_rel_level[k]是第k层的连接关系RelOptInfos列表。 * 新的连接关系RelOptInfos会自动添加到join_rel_level[join_cur_level]中, * 而join_cur_level为当前层级。如果没用到动态规划,join_rel_level则为空。*/ List **join_rel_level; /* 连接关系RelOptInfo的列表 */ int join_cur_level; /* 待追加列表的序号 */ List *init_plans; /* 查询的初始SubPlans */ List *cte_plan_ids; /* 子计划的ID列表,每个CTE对应一个 */ List *multiexpr_params; /* MULTIEXPR子查询输出用到的双层嵌套参数列表 */ List *eq_classes; /* 活跃的EquivalenceClasses列表 */ List *canon_pathkeys; /* "标准" PathKeys 的列表 */ List *left_join_clauses; /* RestrictInfos列表,用于左连接子句 */ List *right_join_clauses; /* RestrictInfos列表,用于右连接子句 */ List *full_join_clauses; /* RestrictInfos列表,用于完全连接子句 */ List *join_info_list; /* SpecialJoinInfos 的列表 */ List *append_rel_list; /* AppendRelInfos 的列表 */ List *rowMarks; /* PlanRowMarks 的列表 */ List *placeholder_list; /* PlaceHolderInfos 的列表 */ List *fkey_list; /* ForeignKeyOptInfos 的列表 */ List *query_pathkeys; /* query_planner()期望的pathkeys */ List *group_pathkeys; /* groupClause的pathkeys, 如果有的话 */ List *window_pathkeys; /* 底部窗口的pathkeys, 如果有的话 */ List *distinct_pathkeys; /* distinctClause的pathkeys, 如果有的话 */ List *sort_pathkeys; /* sortClause的pathkeys, 如果有的话 */ List *initial_rels; /* 我们现在正在尝试连接的RelOptInfos */ /* 使用fetch_upper_rel()来获取任意特定的上层关系 */ List *upper_rels[UPPERREL_FINAL + 1]; /* upper-rel RelOptInfos */ /* grouping_planner针对上层处理过程选择的目标列表 */ struct PathTarget *upper_targets[UPPERREL_FINAL + 1]; /* grouping_planner会将最终处理过后的targetlist回传至此。在最终计划最顶层的目标列表中会用到 */ List *processed_tlist; /* create_plan()期间填充的字段,定义于setrefs.c */ AttrNumber *grouping_map; /* 针对GroupingFunc的修补 */ List *minmax_aggs; /* MinMaxAggInfos列表 */ MemoryContext planner_cxt; /* 持有PlannerInfo的上下文 */ double total_table_pages; /* 查询涉及到所有表的页面总数 */ double tuple_fraction; /* 传递给查询计划器的tuple_fraction */ double limit_tuples; /* 传递给查询计划器的limit_tuples */ bool hasInheritedTarget; /* 若parse->resultRelation为继承的子关系则为真 */ bool hasJoinRTEs; /* 如果任意RTEs为RTE_JOIN类别则为真 */ bool hasLateralRTEs; /* 如果任意RTEs被标记为LATERAL则为真 */ bool hasDeletedRTEs; /* 如果任意RTEs从连接树中被删除则为真 */ bool hasHavingQual; /* 如果havingQual非空则为真 */ bool hasPseudoConstantQuals; /* 如果任意RestrictInfo包含 pseudoconstant = true则为真 */ bool hasRecursion; /* 如果计划中包含递归WITH项则为真 */ /* 当hasRecursion为真时,会使用以下字段: */ int wt_param_id; /* 工作表上PARAM_EXEC的ID */ struct Path *non_recursive_path; /* 非递归项的路径 */ /* 这些字段是createplan.c的工作变量 */ Relids curOuterRels; /* 当前节点外部的关系 */ List *curOuterParams; /* 尚未赋值的NestLoopParams */ /* 可选的join_search_hook私有数据, 例如, GEQO */ void *join_search_private; } PlannerInfo; |
本节会通过一个具体的例子,来描述如何基于查询树创建计划树。
3.3.1 预处理
在创建计划树之前,计划器对先PlannerInfo
中的查询树进行一些预处理。
预处理有很多步骤,本节只讨论和单表查询处理相关的主要步骤。其他预处理操作将在3.6节中描述。
简化目标列表(target list),
LIMIT
子句等;例如,表达式
2+2
会被重写为4
,这是由clauses.c
中eval_const_expressions()
函数负责的。布尔表达式的规范化
例如,
NOT(NOT a)
会被重写为a
压平与/或表达式
SQL标准中的AND/OR是二元操作符;但在PostgreSQL内部它们是多元操作符。而计划器总是会假设所有的嵌套AND/OR都应当被压平。
这里有一个具体的例子。考虑这样一个布尔表达式
(id = 1) OR (id = 2) OR (id = 3)
,图3.9(a) 展示了使用二元表达式时的查询树,预处理会将这些二元算子简化压平为一个三元算子,如图3.9(b)所示。图3.9. 压平布尔表达式的例子
3.3.2 找出代价最小的访问路径
计划器对所有可能的访问路径进行代价估计,然后选择代价最小的那个。具体来说,计划器会执行以下几个步骤:
创建一个
RelOptInfo
数据结构,存储访问路径及其代价。RelOptInfo
结构体是通过make_one_rel()
函数创建的,并存储于PlannerInfo
结构体的simple_rel_array
字段中,如图3.10所示。在初始状态时RelOptInfo
持有着baserestrictinfo
变量,如果存在相应索引,还会持有indexlist
变量。baserestrictinfo
存储着查询的WHERE子
句,而indexlist
存储着目标表上相关的索引。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273typedef enum RelOptKind{RELOPT_BASEREL,RELOPT_JOINREL,RELOPT_OTHER_MEMBER_REL,RELOPT_UPPER_REL,RELOPT_DEADREL} RelOptKind;typedef struct RelOptInfo{NodeTag type;RelOptKind reloptkind;/* 本RelOptInfo包含的所有关系 */Relids relids; /* 基本关系的ID集合 (范围表索引) *//* 由计划器生成的预估尺寸 */double rows; /* 预估结果元组数目 *//* 计划器标记位,每个关系一份 */bool consider_startup; /* 保留启动代价最低的路径? */bool consider_param_startup; /* 同上, 针对参数化路径? */bool consider_parallel; /* 考虑并行路径? *//* 扫描当前关系的默认结果目标列表 */struct PathTarget *reltarget; /* Vars/Exprs, 代价, 宽度的列表 *//* 物化相关信息 */List *pathlist; /* Path 结构体列表 */List *ppilist; /* pathlist中使用的ParamPathInfos */List *partial_pathlist; /* 部分路径 */struct Path *cheapest_startup_path;struct Path *cheapest_total_path;struct Path *cheapest_unique_path;List *cheapest_parameterized_paths;/* 基础关系与连接关系都需要的 参数化信息 *//* (参见 lateral_vars 与 lateral_referencers) */Relids direct_lateral_relids; /* 直接以LATERAL方式引用的关系 */Relids lateral_relids;/* 关于基础关系的信息 (连接关系不会设置这些字段!) */Index relid;Oid reltablespace; /* 表空间 */RTEKind rtekind; /* RELATION, SUBQUERY, 或 FUNCTION */AttrNumber min_attr; /* 关系属性的最小值 (通常<0) */AttrNumber max_attr; /* 关系属性的最大值 */Relids *attr_needed; /* 被索引的数组 [min_attr .. max_attr] */int32 *attr_widths; /* 被索引的数组 [min_attr .. max_attr] */List *lateral_vars; /* 关系所引用的LATERAL Vars 与 PHVs */Relids lateral_referencers;/* 侧面引用本表的关系 */List *indexlist; /* IndexOptInfo列表 */BlockNumber pages; /* 来自pg_class的页面数估计值 */double tuples;double allvisfrac;PlannerInfo *subroot; /* 如有子查询 */List *subplan_params; /* 如有子查询 */int rel_parallel_workers; /* 期望的并行工作进程数量 *//* 有关外部表与外部表连接的相关信息 */Oid serverid; /* 外部表与外部表连接相应的服务器ID */Oid userid; /* 用于检查访问权限的用户标识 */bool useridiscurrent;/* 当前用户是否能合法进行JOIN */struct FdwRoutine *fdwroutine;void *fdw_private;/* 被各种扫描与连接所使用 */List *baserestrictinfo; /* RestrictInfo结构体列表 (如果存在基础关系) */QualCost baserestrictcost; /* 求值上述限制条件的代价 */List *joininfo; /* RestrictInfo 结构体列表,涉及到本表的连接会用到 */bool has_eclass_joins; /* T 意味着joininfo不完整 */} RelOptInfo;估计所有可能访问路径的代价,并将访问路径添加至
RelOptInfo
结构中。这一处理过程的细节如下:
- 创建一条路径,估计该路径中顺序扫描的代价,并将其写入路径中。将该路径添加到
RelOptInfo
结构的pathlist
变量中。 - 如果目标表上存在相关的索引,则为每个索引创建相应的索引访问路径。估计所有索引扫描的代价,并将代价写入相应路径中。然后将索引访问路径添加到
pathlist
变量中。 - 如果可以进行位图扫描,则创建一条位图扫描访问路径。估计所有的位图扫描的代价,并将代价写入到路径中。然后将位图扫描路径添加到
pathlist
变量中。
- 创建一条路径,估计该路径中顺序扫描的代价,并将其写入路径中。将该路径添加到
从
RelOptInfo
的pathlist
中,找出代价最小的访问路径。如有必要,估计
LIMIT
,ORDER BY
和AGGREGATE
操作的代价。
为了更加清晰的理解计划器的执行过程,下面给出了两个具体的例子。
3.3.2.1 例1
首先来研究一个不带索引的简单单表查询;该查询同时包含WHERE
和ORDER BY
子句。
1 2 3 4 5 6 7 8 | testdb=# \d tbl_1 Table "public.tbl_1" Column | Type | Modifiers --------+---------+----------- id | integer | data | integer | testdb=# SELECT * FROM tbl_1 WHERE id < 300 ORDER BY data; |
图3.10和图3.11展示了本例中计划器的处理过程。
图3.10 如何得到例1中代价最小的路径
创建一个
RelOptInfo
结构,将其保存在PlannerInfo
结构的simple_rel_array
字段中。在
RelOptInfo
结构的baserestrictinfo
字段中,添加一条WHERE
子句。WHERE
子句id<300
会经由initsplan.c
中定义的distribute_restrictinfo_to_rels()
函数,添加至列表变量baserestrictinfo
中。另外由于目标表上没有相关索引,RelOptInfo
的indexlist
字段为空。为了满足排序要求,
planner.c
中的standard_qp_callback()
函数会在PlannerInfo
的sor_pathkeys
字段中添加一个pathkey
。Pathkey
是表示路径排序顺序的数据结构。本例因为查询包含一条ORDER BY
子句,且该子句中的列为data
,故data
会被包装为pathkey
,放入列表变量sort_pathkeys
中。创建一个
Path
结构,并使用cost_seqscan
函数估计顺序扫描的代价,并将代价写入Path
中。然后使用pathnode.c
中定义的add_path()
函数,将该路径添加至RelOptInfo
中。如之前所提到过的,
Path
中同时包含启动代价和总代价,都是由cost_seqscan
函数所估计的。
在本例中,因为目标表上没有索引,计划器只估计了顺序扫描的代价,因此最小代价是自动确定的。
图3.11 如何得到例1中代价最小的路径(接图3.10)
创建一个新的
RelOptInfo
结构,用于处理ORDER BY
子句。注意新的
RelOptInfo
没有baserestrictinfo
字段,该信息已经被WHERE
子句所持有。创建一个排序路径,并添加到新的
RelOptInfo
中;然后让SortPath
的subpath
字段指向顺序扫描的路径。12345typedef struct SortPath{Path path;Path *subpath; /* 代表输入来源的子路径 */} SortPath;SortPath
结构包含两个Path
结构:path
与subpath
;path
中存储了排序算子本身的相关信息,而subpath
则指向之前得到的代价最小的路径。注意顺序扫描路径中
parent
字段,该字段指向之前的RelOptInfo
结构体(也就是在baserestrictinfo
中存储着WHERE
子句的那个RelOptInfo
)。因此在下一步创建计划树的过程中,尽管新的RelOptInfo
结构并未包含baserestrictinfo
,但计划器可以创建一个包含Filter
的顺序扫描节点,将WHERE
子句作为过滤条件。
这里已经获得了代价最小的访问路径,然后就可以基于此生成一颗计划树。3.3.3节描述了相关的细节。
3.3.2.2 例2
下面我们将研究另一个单表查询的例子,这一次表上有两个索引,而查询带有一个WHERE
子句。
1 2 3 4 5 6 7 8 9 10 11 | testdb=# \d tbl_2 Table "public.tbl_2" Column | Type | Modifiers --------+---------+----------- id | integer | not null data | integer | Indexes: "tbl_2_pkey" PRIMARY KEY, btree (id) "tbl_2_data_idx" btree (data) testdb=# SELECT * FROM tbl_2 WHERE id < 240; |
图3.12到3.14展示了本例中计划器的处理过程。
创建一个
RelOptInfo
结构体在
baserestrictinfo
中添加一个WHERE
子句;并将目标表上的索引(们)添加到indexlist
中。在本例中,
WHERE
子句'id <240'
会被添加至baserestrictinfo
中,而两个索引:tbl_2_pkey
和tbl_2_data_idx
会被添加至RelOptInfo
的列表变量indexlist
中。创建一条路径,估计其顺序扫描代价,并添加到
RelOptInfo
的pathlist
中。
图3.12 如何得到例2中代价最小的路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | typedef struct IndexPath { Path path; IndexOptInfo *indexinfo; List *indexclauses; List *indexquals; List *indexqualcols; List *indexorderbys; List *indexorderbycols; ScanDirection indexscandir; Cost indextotalcost; Selectivity indexselectivity; } IndexPath; /* * IndexOptInfo * 用于计划/优化的信息,每个索引对应一个。 * * indexkeys[], indexcollations[], opfamily[], 以及 opcintype[] * 每个字段都有 ncolumns 个项. * * sortopfamily[], reverse_sort[], 以及 nulls_first[] 类似,也都有 * ncolumns 个项, 当且仅当该索引是有序的,否则这些指针都为空。 * * indexkeys[] 数组中的零值表示该索引列是一个表达式,而每个这种列在indexprs * 中都会有一个对应的元素。 * * 对于有序索引,reverse_sort[] 以及 nulls_first[] 描述了正向索引扫描时 * 索引的排序顺序。当进行反向索引扫描时,就会产生相反的顺序。 * * indexprs 与 indpred 会通过prepqual.c 中的 eval_const_expressions() * 用于简单地与WHERE子句匹配,indpred使用简单的合取范式。 * * indextlist 是 TargetEntry的列表,标识着索引建在哪些列上。对于简单的列, * 它会提供基本关系中与之对应的Var,对于表达式列,它还会指向indexprs对应元素。 * 相对应的Var。 * * 当IndexOptInfo创建时,这里大多数字段都会被填充 (plancat.c),但indrestrictinfo * 与predOK会在稍后通过check_index_predicates()设置。 */ typedef struct IndexOptInfo { NodeTag type; Oid indexoid; /* 索引关系的OID */ Oid reltablespace; /* 索引所属的表空间 (不是表) */ RelOptInfo *rel; /* 索引对应的表,反向链接 */ /* 索引尺寸的统计 (来自pg_class和其他地方) */ BlockNumber pages; /* 索引中的磁盘页面数 */ double tuples; /* 索引中的元组数量 */ int tree_height; /* 索引树的高度,未知则为 -1 */ /* 索引描述符信息 */ int ncolumns; /* 索引中列的数量 */ int *indexkeys; /* 索引中列的序号,或者0 */ Oid *indexcollations; /* 索引列上排序规则的OID */ Oid *opfamily; /* 列上运算符族的OID */ Oid *opcintype; /* 运算符族输入数据类型的OID */ Oid *sortopfamily; /* 如果这些列有序,B树算子族的OID */ bool *reverse_sort; /* 排序顺序是反向降序的吗? */ bool *nulls_first; /* 排序顺序中,空值是排在最前面的吗? */ bool *canreturn; /* 在仅索引扫描中,哪些索引列可以被返回? */ Oid relam; /* 访问方法的OID (在 pg_am 中) */ List *indexprs; /* 非平凡的索引列,即表达式 */ List *indpred; /* 如果是部分索引,则为谓词,否则为空 */ List *indextlist; /* 表示索引列的目标列表 */ List *indrestrictinfo; /* 父关系的baserestrictinfo列表 */ bool predOK; /* 如果查询与索引谓词匹配则为真 */ bool unique; /* 唯一索引则为真 */ bool immediate; /* 唯一约束是否是立即强制实施的? */ bool hypothetical; /* 如果索引并非真实存在则为真。 */ /* 剩下这些字段是从索引访问方法的API结构里复制过来的 */ bool amcanorderbyop; /* 访问方法是否支持按算子结果排序? */ bool amoptionalkey; /* 查询是否可以忽略第一列中的键? */ bool amsearcharray; /* 访问方法是否能处理ScalarArrayOpExpr限定条件? */ bool amsearchnulls; /* 访问方法是否能搜索空项或非空项? */ bool amhasgettuple; /* 访问方法是否有amgettuple接口? */ bool amhasgetbitmap; /* 访问方法是否有amgetbitmap接口? */ /* 相比include amapi.h,我们直接在这里用这种方式声明 amcostestimate */ void (*amcostestimate) (); /* 访问方法的代价估计器 */ } IndexOptInfo; |
创建一个
IndexPath
,估计索引扫描的代价,并通过add_path()
函数将IndexPath
添加到RelOptInfo
的pathlist
中。在本例中有两个索引:
tbl_2_pkey
与tbl_2_data_index
,这些索引会按先后顺序依次处理。一条针对
tbl_2_pkey
的IndexPath
会先被创建出来,并进行启动代价与总代价的评估。在本例中,tbl_2_pkey
是id
列上的索引,而WHERE
子句也包含该id
列;因此WHERE
子句会被存储在IndexPath
的indexclauses
字段中。创建另一个
IndexPath
,估计另一种索引扫描的代价,并将该IndexPath
添加到RelOptInfo
的pathlist
中。接下来,与
tbl_2_data_idx
相应的IndexPath
会被创建出来,并进行代价估计。本例中tbl_2_data_idx
并没有相关的WHERE
子句;因此其indexclauses
为空。
注意
add_path()
函数并不总是真的会将路径添加到路径列表中。这一操作相当复杂,故这里就省去了具体描述。详细细节可以参考add_path()
函数的注释。
图3.13 如何得到例2中代价最小的路径(接图3.12)
创建一个新的
RelOptInfo
结构将代价最小的路径,添加到新
RelOptInfo
的pathlist
中。本例中代价最小的路径是使用
tbl_2_pkey
的索引路径;故将该路径添加到新的RelOptInfo
中。
图3.14 如何得到例2中代价最小的路径(接图3.13)