PostgreSQL源码学习笔记(6)-查询编译

文章目录

  • 引言(Introduction)
  • 概述
  • 查询分析
    • Lex与Yacc
      • Lex
      • Yacc
    • 词法和语法分析
    • 语义分析
  • 查询重写
    • 规则系统
    • SELECT/INSERT/UPDATE/DELETE
      • SELECT
      • INSERT/UPDATE/DELETE
    • INSTEAD/ALSO
    • 规则与触发器
    • 规则系统的操作
  • 查询规划
    • 总体处理流程
    • 预处理
      • 提升子链接与子查询
      • 预处理MIN/MAX聚集函数
      • 预处理表达式
      • 预处理HAVING子句
      • 删除冗余信息
    • 生成路径
        • 路径生成算法
        • 路径生成流程
    • 计划生成
    • 整理计划树
  • 代价估算
  • 总结
  • 参考资料(References)

引言(Introduction)

查询模块是数据库与用户进行交互的模块,允许用户使用结构化查询语言(SQL)或其它高级语言在高层次上表达查询任务,并将用户的查询命令转化成数据库上的操作序列并执行。这里将查询处理分为查询编译与查询执行两个阶段:

  • 查询编译:根据用户的查询语句生成数据库内部的最优执行计划;
  • 查询执行:根据生成的最优执行计划执行查询过程。

概述

当PG的后台进程Postgres接收到查询命令后,需要先将其传递到查询分析模块,进行词法,语法与语义分析。对于用户的查询命令,比如SELECT,CREATE TABLE以及一些简写的命令如"\dt",“\d”(这些简单命令会首先被转化为SELECT的语句),PG需要为其构建一颗原始解析树,然后交给查询重写模块。查询重写模块根据解析树以及一些指定参数执行解析分析以及规则重写得到查询树,最后将查询树输入到计划模块得到计划树。

整个查询编译的函数调用流程如下:

exec_simple_query
    ->pg_parse_query
    	->raw_parser
    ->pg_analyze_and_rewrite
    	->parse_analyze
    	->pg_rewrite_query
    ->pg_plan_queries

查询分析

查询分析是查询编译的第一个模块,包括词法分析,语法分析以及语义分析三部分。PG分别使用Lex与Yacc来完成词法分析与语法分析两个功能。用户输入的SQL命令在函数pg_parse_query中经过词法分析与语法分析得到一颗解析树。出于与用户查询交互的考虑,PG将查询分析的三个阶段分为两个函数进行,其中pg_parse_query函数中仅负责实现词法分析与语法分析两个功能(raw_parse),而语义分析以及重写部分则由函数pg_analyze_and_rewrite负责。

之所以将语义分析放在另外一个函数进行处理是因为语义分析需要查询系统表,而这个操作是在事务中执行的。我们不希望在输入语句的时候立即执行一个事务,并且在raw_parse阶段已经足够识别事务的控制语句,如BEGIN,COMMIT等,这些命令并不需要开启一个新事务。

Lex与Yacc

Lex与Yacc是词法与语法分析工具,两者相互配合可以生成用于词法分析与语法分析的C语言源代码。Lex的工作是负责识别原始查询语句中出现的模式,比如数字,字符串以及特殊符号,然后将其输出传给Yacc。Yacc则负责识别这些模式的组合。下面将简要介绍这两个工具的原理。

Lex

Lex通过采用正则表达式解析的方法来识别字符串中出现的各种模式,是常用的词法分析工具。使用Lex工具可以将定义了正则表达式匹配规则的Lex文件(后缀名为".l")转化为C语言源代码文件。一个Lex文件分为三段,各段之间使用"%%"分隔:

  1. 定义段:包含任意的C语言头文件、符号说明等,这部分会被直接拷贝到生成文件当中;
  2. 规则段:正则表达式的匹配规则,每当成功匹配一个模式,就对应其后"{}"中的代码;
  3. 代码段:可以是任意的C语言代码,但是必须要调用Lex提供的函数,因为这里是Lex的入口函数,完成实际的分析功能。
%{
// 简单例程,用来识别一个整数
#include 				// 定义段
%}
%%
[ \n \t];						// 规则段
-?[0-9]+{printf("num = %d\n", atoi(yytext));}
%%
main(){							// 代码段
yylex();
}

Yacc

语法分析需要找出输入序列中符合某一给定模式序列的语法结构,比如“主谓宾”是一个句子的模式,语法分析则是找出"他一周打一次乒乓球"中的"他,打,乒乓球"三个元素。Yacc与Lex的工作方式相似,需要将语法的定义以及一些必要的C语言代码写在Yacc文件中(后缀名为".y"),并使用Yacc工具将其转化为C语言源代码。一个Yacc文件同样分为三段,隔断之间使用"%%"分隔:

  1. 定义段:可以是C代码,包含头文件以及函数声明,同时也可以定义Yacc的内部标志等;
  2. 规则段:语法规则,每当成功匹配一个语法后,就对应其后面"{}“中的代码。其中”$$“标识语法表达式中左边结构的值(类似左值),而”$1"表示语法表达式右边结构第一个标识符对应的值,以此类推;
  3. 代码段:包含C代码,同样地也必须包含一些Yacc函数和Lex传递给Yacc的变量。
%{ // 定义段
#include
#include
#include
%}
%token DIGIT // 数字标识符
%% // 规则段,定义匹配加减乘法的语法规则
line    :expr'\n' { printf("%d\n",$1);return;}
        ;
expr    :expr'+'term { $$=$1+$3;} // 加减表达式,支持递归写法
		|expr'-'term { $$=$1-$3;}
		|term
		;
term	:term'*'factor {$$=$1*$3;}
		|factor
		;
factor	:'('expr')' {$$=$2;} // 识别括号或数字
        |DIGIT
        ;
%%
main(){
    return yyparse(); // 解析输入序列
}
 
int yylex(){
    int c;
    while ((c=getchar())==' ');
    if(isdigit(c)){
        yylval=c-'0';
        return DIGIT;
    }
    return c;
}
int yyerror(char *s){
    fprintf(stderr,"%s\n",s);
    return 1;

词法和语法分析

PG中的词法分析与语法分析分别由Lex与Yacc配合完成,其中Lex使用到的源代码文件为scan.l,而Yacc使用到的源代码则为gram.y,其生成的C语言文件分别为scan.c与gram.c。这些解析器存放在目录src/backend/parser中。一些关键的文件如下表所示:

Source Files Description
parser.c 词法、语法分析的入口文件,提供raw_parser()接口函数
scansup.c 提供词法分析中需要的文件,包括转义字符处理,大写字符转换为小写字符等函数
scan.l 定义词法结构,用于实现对输入语句关键字的识别。使用Lex编译之后生成scan.c文件
gram.h 定义关键字的数值编号
gram.y 定义语法结构,用于实现对数据语句语法的识别。使用Yacc编译后生成gram.c文件

parser提供raw_parser()接口给上层调用,该函数返回List结构存储的解析树。下面将以SELECT语句为例介绍PG是如何对查询语句进行语法分析并生成分析树的。SELECT语句在文件gram.y的定义为:

SelectStmt: select_no_parens			%prec UMINUS
			| select_with_parens		%prec UMINUS
		;
		
select_with_parens:
			'(' select_no_parens ')'				{ $$ = $2; }
			| '(' select_with_parens ')'			{ $$ = $2; }
		;

可以看到,SelectStmt定义为不带括号(select_no_parens)与带括号(select_with_parens)的SELECT语句,并且带括号的SELECT语句也被定义为不带括号的SELECT语句。因此SELECT最终要处理的是不带括号的SELECT语句,其处理函数在gram.y定义为:

select_no_parens:
			simple_select						{ $$ = $1; } // 简单查询语句
			| select_clause sort_clause	{...} // 排序从句
			|...
		;
		
simple_select:
			SELECT opt_all_clause opt_target_list
			into_clause from_clause where_clause
			group_clause having_clause window_clause {...}
			| SELECT distinct_clause target_list
			into_clause from_clause where_clause
			group_clause having_clause window_clause {...}
			| values_clause							{ $$ = $1; }
			| TABLE relation_expr {...}
			| select_clause UNION set_quantifier select_clause {...}
			| select_clause INTERSECT set_quantifier select_clause {...}
			| select_clause EXCEPT set_quantifier select_clause {...}
		;

在不带括号的SELECT语句被定义为一条简单的SELECT语句(simple_select),也可以在简单的SELECT语句后面加一些特定的谓语,比如说排序从句sort_clause。SELECT解析中最重要的是simple_select,包含了SELECT语句中可能出现的各种关键字,比如distinct,from,where等。每当成功匹配simple_select中设置的语法规则时,会创建一个SelectStmt结构体,并将句子中各个关键字对应的值赋予结构体当中的相应字段。SelectStmt在文件src/include/nodes/parsenodes.h中定义:

typedef struct SelectStmt{
    Node type;
    /*
	 * These fields are used only in "leaf" SelectStmts.
	 */
	List	   *distinctClause; /* NULL, list of DISTINCT ON exprs, or lcons(NIL,NIL) for all (SELECT DISTINCT) */
	IntoClause *intoClause;		/* target for SELECT INTO, like create table as */
	List	   *targetList;		/* the target list (of ResTarget) */
	List	   *fromClause;		/* the FROM clause */
	Node	   *whereClause;	/* WHERE qualification */
	List	   *groupClause;	/* GROUP BY clauses */
	bool		groupDistinct;	/* Is this GROUP BY DISTINCT? */
	Node	   *havingClause;	/* HAVING conditional-expression */
	List	   *windowClause;	/* WINDOW window_name AS (...), ... */
    
	/*
	 * In a "leaf" node representing a VALUES list, the above fields are all
	 * null, and instead this field is set.  Note that the elements of the
	 * sublists are just expressions, without ResTarget decoration. Also note
	 * that a list element can be DEFAULT (represented as a SetToDefault
	 * node), regardless of the context of the VALUES list. It's up to parse
	 * analysis to reject that where not valid.
	 */
	List	   *valuesLists;	/* untransformed list of expression lists */

	/*
	 * These fields are used in both "leaf" SelectStmts and upper-level
	 * SelectStmts.
	 */
	List	   *sortClause;		/* sort clause (a list of SortBy's) */
	Node	   *limitOffset;	/* # of result tuples to skip */
	Node	   *limitCount;		/* # of result tuples to return */
	LimitOption limitOption;	/* limit type */
	List	   *lockingClause;	/* FOR UPDATE (list of LockingClause's) */
	WithClause *withClause;		/* WITH clause */

	/*
	 * These fields are used only in upper-level SelectStmts.
	 */
	SetOperation op;			/* type of set op */
	bool		all;			/* ALL specified? */
	struct SelectStmt *larg;	/* left child */
	struct SelectStmt *rarg;	/* right child */
}SelectStmt;

在SelectStmt中定义了包含存储各种从句的数据,当查询句子匹配时,SelectStmt中的一些字段会被赋值。下表是SELECT语句中出现的关键字以及其对应的语法结构:

  1. Distinct: opt_distinct_clause

    opt_distinct_clause:
    			distinct_clause							{ $$ = $1; }
    			| opt_all_clause						{ $$ = NIL; }
    		;
    
  2. TargetList: opt_target_list(select中指定的查询字段,可以是*字符)

    opt_target_list: target_list						{ $$ = $1; }
    			| /* EMPTY */							{ $$ = NIL; }
    		;
    
  3. From: from_list

    from_clause:
    			FROM from_list							{ $$ = $2; }
    			| /*EMPTY*/								{ $$ = NIL; }
    		;
    
  4. Where: where_clause

    where_clause:
    			WHERE a_expr							{ $$ = $2; }
    			| /*EMPTY*/								{ $$ = NULL; }
    		;
    

为什么raw_parser会返回一个List?

因为用户一次输入的命令中可能包含多个SQL语句,因此需要为每个SQL命令都返回一个解析树。

语义分析

语义分析阶段会检查命令中是否存在不符合语义规定的元素,比如说访问的表或字段是否存在,聚集函数(比如说求平均值或者计数)是否能够使用。因此语义分析需要访问到数据库中的系统表,从而获得查询表的OID以及查询字段的属性等。

PG中执行语义分析的入口函数是pg_analyze_and_rewrite,其将词法分析与语法分析处理后得到的parsetree_list中的每棵树都进行语义分析与重写。其中负责语义分析的函数为parse_analyze,该函数对parse_tree进行语法分析并转换为一颗查询树(以Query结点的形式存在)。parse_tree函数中涉及的两个重要的结构体分别为Query和ParseState,其中Query用于存储查询树而ParseState则用于存储语义分析的中间信息,比如是否是子查询,查询涉及的表等。

struct ParseState
{
	ParseState *parentParseState;	/* stack link */
	const char *p_sourcetext;	/* source text, or NULL if not available */
	List	   *p_rtable;		/* range table so far */
	List	   *p_joinexprs;	/* JoinExprs for RTE_JOIN p_rtable entries */
	List	   *p_joinlist;		/* join items so far (will become FromExpr */
    ...
};
typedef struct Query
{
	NodeTag		type;
	CmdType		commandType;	/* select|insert|update|delete|utility */
	QuerySource querySource;	/* where did I come from? */
    ...
} Query;

PG中很多结构体的第一个元素都是NodeType类型的,因此可以通过转换指针为NodeTag*传递参数,从而实现统一的函数操作处理。同时PG中定义了NodeTag的枚举类型,因此可以直接判断转换后指针的取值判断传递参数的类型。多态也是基于这种设计实现的。

parse_tree函数会先生成一个存储中间信息的ParseState结构体,然后调用transformTopLevelStmt函数完成语义分析过程。在transformTopLevelStmt函数中实际完成语义分析的函数是transformStmt,该函数中存在多个以transform为前缀的操作函数,这些函数完成语义分析的实际操作(将解析树转换为查询树Query*)。目前tansformStmt支持多种转换操作,并且一些其中Insert,Delete,Select,Update等的操作都是可以优化的,而一些特殊的操作例如Explain,CreateTable这些则不会被优化。

parse_analyze
    ->transformTopLevelStmt
    	->transformOptionalSelectInto
    		->transformStmt
    			switch(nodeTag(parseTree)){
                        // Optimizable statements
                        case T_InsertStmt: transformInsertStmt(pstate, (InsertStmt *) parseTree); break;
                        case T_DeleteStmt: result = transformDeleteStmt(pstate, (DeleteStmt *) parseTree); break;
                        case T_UpdateStmt: result = transformUpdateStmt(pstate, (UpdateStmt *) parseTree); break;
                        case T_SelectStmt: ... result = transformSelectStmt(pstate, n); break;
                        case T_ReturnStmt: result = transformReturnStmt(pstate, (ReturnStmt *) parseTree); break;
					  case T_PLAssignStmt: result = transformPLAssignStmt(pstate, (PLAssignStmt *) parseTree); break;
                        // Special cases
                        case T_DeclareCursorStmt: result = transformDeclareCursorStmt(pstate, (DeclareCursorStmt *) parseTree); break;
					  case T_ExplainStmt: result = transformExplainStmt(pstate, (ExplainStmt *) parseTree); break;
					  case T_CreateTableAsStmt: result = transformCreateTableAsStmt(pstate, (CreateTableAsStmt *) parseTree); break;
 					  case T_CallStmt: result = transformCallStmt(pstate, (CallStmt *) parseTree); break;
                  }

其中transformSelectStmt()能够将一个SelectStmt结构(在语法分析阶段获得)生成一颗查询树,其主要流程如下:

transformSelectStmt
    ->transformWithClause // handle with clause
    ->transformFromClause // process the from clause
    ->transformTargetList // transform targetlist
    ->markTargetListOrigins // mark column origins
    ->transformWhereClause // transform Where and having clauses
    ->transformSortClause
    ->transformGroupClause
    ...
    ->assign_query_collations // mark all expressions in the given query
    ->parseCheckAggregates	// check aggregate function

可以看到transformSelect函数会逐个分析可能存在的谓语从句,并将分析结果填充到Query结点当中。

查询重写

在完成语义分析得到查询树后,会对查询命令进行重写,比如前面介绍的增删查改,而功能性命令则不会被重写。PG中重写查询重写模块存放在文件夹src/backend/rewrite中。

规则系统

查询重写的核心是重写规则系统,该系统由一系列重写的规则组成。PG将重写规则系统存储在系统表pg_rewrite当中:

Column Description
oid 规则的oid
rulename 规则名称
ev_class 适用于该规则的表的名称
ev_type 规则适用的事件类型:1为SELECT,2为UPDATE,3为INSERT,4为DELETE
ev_enabled 规则在哪个session_replication_role模块中触发:O为在’origin’与’local’,D为规则被禁用,R为在’replica’,A为总是触发
is_instead 若为true,则该规则是INSTEAD规则
ev_qual 规则动作的条件表达式
ev_action 规则动作的查询树

从上表中可以看出规则与触发器相似,其含有规则触发条件,规则触发场所以及规则触发的动作。实际上,一条规则(pg_rewrite中的一个元组)可以理解为在目标表(ev_class)上执行符合条件(ev_qual)的特定动作(ev_type)时,将用规则动作(ev_action)代替原始的动作或者将规则的动作附加在原始命令之前或之后。

根据pg_rewrite中的字段,可以将规则分为两类:

  1. ev_type:可以分为SELECT,UPDATE,INSERT,DELETE;
  2. is_instead:INSTEAD(true)或ALSO(false)。

创建规则的SQL语句格式如下:

CREATE [ OR REPLACE ] RULE name AS ON event										## 创建或替换规则name,在对表格table_name触发事件event时
    TO table_name [ WHERE condition ]										    ## 执行Do后面的语句
    DO [ ALSO | INSTEAD ] { NOTHING | command | ( command ; command ... ) }

SELECT/INSERT/UPDATE/DELETE

SELECT

SELECT规则中只能有一个动作,而且是不带条件的INSTEAD规则。其执行效果与视图类似,这是因为PG中的视图也是通过规则系统实现的。比如说创建一个视图与创建一个表格和规则的动作是等价的:

CREATE VIEW myview AS SELECT * FROM mytab;
#########################################
CREATE TABLE myview (same column list as mytab);
CREATE RULE "_RETURN" AS ON SELECT TO myview DO INSTEAD								# 对视图的操作实际上转换为对实际表格的操作
    SELECT * FROM mytab;

由于SELECT操作并不会修改数据库中的任何数据,并且如果在pg_rewrite系统表中找到与SELECT语句一致的失手,说明存在一个视图(物化视图或临时视图)可以直接用于生成查询结果。

比如下面一个例子:

  1. 创建两个表格与一个视图:

    create table test_A(name text, class int);
    create table test_B(class int, num int);
    create view test_view as select A.name, A.class, B.num from test_A A, test_B B where test_A.class = test_B.class;
    
  2. 对视图执行select操作:

    select * from test_view;
    
  3. 那么该select操作会被词法分析与语法分析解析为:

    select test_view.name, test_view.class, test_view.num from test_view;
    
  4. 该解析树经过规则系统被重写后变为:

    select A.name, A.class, B.num from test_A A, test_B B where test_A.class = test_B.class;
    

可以看到,SELECT规则的中原查询树已经被替换成新的查询树。

INSERT/UPDATE/DELETE

这三个规则具有以下特性:

  • 可以拥有零个或多个动作;
  • 可以是INSTEAD(true)或ALSO规则(缺省);
  • 可以使用伪关系NEW或OLD;
  • 可以使用规则条件;
  • 不会对原查询树进行修改,而会创建零个或多个查询树。

INSTEAD/ALSO

INSTEAD与ALSO规则通过字段is_instead的取值区分:

  • INSTEAD:is_instead为true。用规则定义的动作代替原来查询树中的事件;
  • ALSO:is_instead为false。原始查询树与规则动作都会被执行,只不过执行命令的先后不同。当原命令为INSERT时,原始动作在规定动作之前,保证规定动作可以看到原始动作修改之后的状态;当原命令为UPDATE或DELETE时,原始动作在规定动作之后,保证规定动作可以看到原始动作修改之前的状态。

规则与触发器

从例子中可以看到,规则与触发器的工作方式相似,都可以在特定条件下执行原始查询之外的动作,但是二者从本质上还是有所区别。比如触发器是在查询执行的时候执行,而规则则是对查询树进行修改或者生成额外的查询树。另外,规则无法实现外键约束,但是触发器则可以。

规则系统的操作

PG的规则系统提供定义规则,删除规则以及利用规则进行查询重写三个操作:

  1. 定义规则:在使用规则系统之前需要先定义规则,规则的定义通过命令CREATE RULE命令来完成,"CREATE RULE"被词法解析与语法解析之后,该规则的相关信息会存储在一个RuleStmt结构当中,最后会调用DefineRule函数完成规则的创建;
  2. 删除重写规则:在PG14.0中,删除规则由函数RemoveRewriteRuleById实现,其步骤主要如下:1. 打开pg_rewrite系统表;2. 搜索目标元组;3. 对元组进行上锁;4. 删除目标元组;5. 结束扫描并关闭系统表;
  3. 查询树重写:查询树的重写会通过调用函数QueryRewrite来完成,其步骤主要如下:1. 按照规则将原查询树中非SELECT的查询进行重写,得到一个或多个修改的查询树;2. 对得到的查询树分别使用RIR规则进行重写,这部分主要是完成对SELECT规则的重写。

查询规划

在DBMS中,可以通过多种查询途径完成用户的查询命令。虽然这些查询途径产生的查询结果相同,但是不同的查询途径其查询效率是不同的,因此查询规划需要找到其中代价最小的执行方案。

在数据库查询中,最耗时的操作是表连接,因此查询优化的核心思想是"尽可能地先做选择操作,最后再执行表连接操作",比如说谓语下滑以及WHERE语句合并。PG将需要进行连接操作的表提升到同一个查询层次之后,根据动态规划以及遗传算法选择其中代价最小的连接方案。

BTW,虽然表连接很耗时,但是宁愿进行表连接也不愿进行整张表的遍历,比如说or关键字会被优化成join

总体处理流程

查询规划的总体过程可以分为预处理,生成路径和生成计划三个阶段:

  1. 预处理:对查询树进一步改造,在这个过程中最重要的是提升子链接或子查询;
  2. 生成路径:根据改进的查询树,使用动态规划或遗传算法生成最优的连接路径和候选的路径链表;
  3. 生成计划:根据最优路径,先生成基本计划树(SELECT … FROM … WHERE),然后添加GROUP BY,HAVING和ORDER BY等子句对应的计划结点形成完成的计划树。

查询规划的入口函数是pg_plan_queries,其负责将查询树链表变成执行计划链表。其调用pg_plan_query对每个查询树都进行处理,并将生成的PlannedStmt结构体组织成链表并返回。PlannedStmt包含了查询一些信息,包括命令类型,是否拥有Returning语句等。

typedef struct PlannedStmt
{
	NodeTag		type;
	CmdType		commandType;	/* select|insert|update|delete|utility */
	uint64		queryId;		/* query identifier (copied from Query) */
	bool		hasReturning;	/* is it insert|update|delete RETURNING? */
	bool		hasModifyingCTE;	/* has insert|update|delete in WITH? */
	...
} PlannedStmt;

在pg_plan_query中负责生成计划的是planner函数,该函数会调用standard_planner进入标准查询规划阶段。这些函数的调用关系如下:

exec_simple_query
    ->pg_plan_queries
    	->pg_plan_query
    		->planner
    			->standard_planner
    				->subquery_planner
    					// 预处理
    					->preprocess_xxx
    					->pull_up_sublinks
    					->pull_up_subqueries
    					// 生成计划树
    					->grouping_planner
    						->query_planner
    			->SS_finalize_plan

查询优化中使用到的几个主要函数以及其作用如下标所示。

Function Description
planner 优化器的入口函数,输入为经过重写后的查询树,输出最优的计划树
standard_planner 标准的优化器入口
subquery_planner 优化处理的主体函数,可以递归使用
grouping_planner 执行grouping,aggregation相关的规划步骤
query_planner 为查询生成路径
set_plan_reference 完成生成执行计划后的清理工作

预处理

预处理阶段时主要负责消除冗余条件,减少递归层数(通过提升子链接与子查询实现)以及简化路径生成等。

提升子链接与子查询

子查询指在FROM子句中存在的SELECT查询语句,而子链接则是出现在WHERE或HAVING修饰的表达式。PG支持嵌套查询的SQL写法,即FROM子句中可以包含一个SELECT查询语句。原始执行下,会先执行子查询(内部),再执行父查询。但将子查询提升后,可以与父查询共同优化,从而提高查询的效率。

比如有一个原始查询的SQL语句如下:

select d.name from dept d where d.deptno in (select e.deptno from emp e where e.sal = 1000);

如果按照原始SQL语句生成的计划树进行执行的话,后面子链接会生成一张临时表,然后夫查询遍历每个数据元组的时候都会遍历该临时表,这样查询的效率是十分低下的。因此需要对原始查询的子链接进行提升为子链接:

select d.name from dept d (select e.deptno from emp e where e.sal = 1000) as sub where d.deptno = sub.deptno;

然后提升子查询:

select d.name from dept d, emp e where d.deptno = e.deptno and e.sal = 1000;

可以看到,子链接与子查询的提升是为了将其上升到与父查询拥有相同的优化等级。但是当表格的数量达到一定程度的时候,就不会对子查询或子链接进行优化,因为搜索的表越多,优化搜索的事件也越长。

提升子链接的入口函数是pull_up_sublinks,其内部会调用pull_up_sublinks_jointree_recurse函数递归地处理jointree,然后调用pull_up_sublinks_qual_recurse处理约束条件。

提升子查询的入口函数是pull_up_subqueries,其内部会调用pull_up_subqueries_recurse函数递归地处理子查询。提升子查询分为三种情况处理:

  1. 范围表存在子查询。如果是简单的子查询,那么调用函数pull_up_simple_subquery直接提升,而如果是简单的UNION ALL子查询,那么调用pull_up_simple_union_all直接提升;
  2. FROM表达式存在子查询。调用pull_up_subqueries_recurse进行递归处理;
  3. 连接表达式中的子查询。调用pull_up_subqueries_recurse进行递归处理。

预处理MIN/MAX聚集函数

在路径生成之前,优化器会先检查查询中是否包含MIN/MAX的聚集函数。如果存在MIN/MAX聚集函数,并且聚集函数中的目标字段存在索引,那么会生成通过索引扫描获得最大值或最小值的路径。该聚集函数必须发生在路径生成之前,因为这阶段会改变解析树,改变的解析树会被路径生成阶段使用。

MIN/MAX聚集函数的预处理发生在preprocess_minmax_aggregates函数中。

预处理表达式

表达式可以是一个目标链表,一个WHERE语句,一个HAVING谓语或者一些其它的东西。在PG中表达式的预处理由函数preprocess_expression完成,其主要完成的工作包括:

  1. 用基本关系变量取代连接别名;
  2. 对常量表达式进行简化;
  3. 对表达式进行规范化;
  4. 将子链接转化为子计划,该转换通过函数make_subplan实现。

预处理HAVING子句

对于HAVING子句,如果不含有聚集(交集),那么将其提升到WHERE条件中,否则将其放到Query的HavingQual字段中。

删除冗余信息

经过前面的预处理后,可能发现存在冗余的关系,比如group关系:

select d.name from dept d where d.deptno in (select e.deptno from emp e where e.sal = 1000 group by e.deptno) group by e.deptno;

生成路径

用户的执行或插入都要从基本表或连接表中获取,连接表可以由多个基本表连接而成,在PG中连接表可以被组成成基本表的二叉树形式,因此路径规划中生成的路径即是找到从一组基本表到最终连接表的方式,并选取其中效率最高的路径(一张基本表也可以构成路径)。生成路径的入口函数是query_planner:

query_planner
    ->setup_simple_rel_arrays // Set up arrays for accessing base relations and AppendRelInfos.
    if single relation // just one relation
        ->build_simple_rel
    ->add_base_rels_to_query
    ->build_base_rel_tlists
    ...
    ->make_one_rel

可以看到,当只有一个基本表时query_planner会直接返回该路径,而当存在多个基本表时,则需要处理基本表的目标字段以及基本表之间的连接关系等。在路径生成中,使用到的关键数据结构是RelOptInfo:

typedef struct RelOptInfo
{
	NodeTag		type;
	RelOptKind	reloptkind;
	/* all relations included in this RelOptInfo */
	Relids		relids;			/* set of base relids (rangetable indexes) */
	/* size estimates generated by planner */
	double		rows;			/* estimated number of result tuples */
	/* default result targetlist for Paths scanning this relation */
	struct PathTarget *reltarget;	/* list of Vars/Exprs, cost, width */
	/* materialization information */
	List	   *pathlist;		/* Path structures */
	List	   *ppilist;		/* ParamPathInfos used in pathlist */
	List	   *partial_pathlist;	/* partial Paths */
	struct Path *cheapest_startup_path;
	struct Path *cheapest_total_path;
	struct Path *cheapest_unique_path;
	List	   *cheapest_parameterized_paths;
	...
	List	   *joininfo;		/* RestrictInfo structures for join clauses
								 * involving this rel */
    ...
} RelOptInfo;

可以看到RelOptInfo中包含所有的基本表,估计生成的元组数量,生成的路径以及最具效率的路径等信息。PG中生成的路径被组织成Path的形式:

typedef struct Path
{
	NodeTag		type;
	NodeTag		pathtype;		/* tag identifying scan/join method */

	RelOptInfo *parent;			/* the relation this path can build */
	PathTarget *pathtarget;		/* list of Vars/Exprs, cost, width */
	...
	double		rows;			/* estimated number of result tuples */
	Cost		startup_cost;	/* cost expended before fetching any tuples */
	Cost		total_cost;		/* total cost (assuming all tuples fetched) */

	List	   *pathkeys;		/* sort ordering of path's output */
	/* pathkeys is a List of PathKey nodes; see above */
} Path;

可以看到,一个Path结构体当中还包含了该路径涉及到的元组数量,以及执行该路径可能消耗的启动代价(startup_cost)总代价(total_cost)。另外,如果有搜索字段存在索引的话,使用索引扫描比顺序扫描会更快。

路径生成算法

由于单个表的访问方式(属于顺序访问、索引访问、TID访问)、两个表的连接方式(循环嵌套连接、归并连接、Hash连接)以及多个表间的连接顺序(左连接,右连接和全连接)都有多种,因此即使是相同的两张基本表,但访问一个最终表的路径都会存在很多种。因此优化器需要考虑所有可能的路径,并选择其中最优的路径来生成执行计划。PG中生成执行计划的算法有动态规划与遗传算法:

  • 动态规划:在PG中,通常是使用动态规划来获得最优路径的,其步骤主要分为三步:1. 初始化,为每个基本表生成访问路径;2. 状态传递,从基本表开始向前生成连接表与计算该连接表路径需要的代价,并保留其中代价评估最优的路径;3. 传递到最后表时,选出其中最优的路径。

    连接顺序的不同会导致生成的连接表大小不同,从而导致需要的内存以及CPU时间的不同;连接方式的不同则直接导致了不同的CPU时间。因此在状态传递过程中需要估算每条路径需要的代价,并尽可能地保留其中最优的路径。保留的路径需要满足以下三个条件中的一个:

    1. 路径的启动代价最小;
    2. 路径的总执行代价最小;
    3. 路径的输出排序键。

    这分别对应着RelOptInfo中的cheapest_startup_path,cheapest_total_path与cheapest_unique_path。动态规划需要消耗的时间随着连接表的增长而指数增长,因为其需要检查所有可能的路径。

  • 遗传算法:当表格的数量过多时,遍历所有的表需要消耗大量的时间和内存空间。因此,PG提供了遗传算法来减少需要遍历的路径,从而提高查找路径的效率。不过遗传算法只能找到一个准最优的路径。

路径生成流程

路径生成的入口函数是make_one_rel,其会找到所有的路径,然后返回一个代表所有连接关系的RelOptInfo结构,其生成的路径会存储在RelOptInfo结构体当中的pathlist中。make_one_rel的内部调用如下:

make_one_rel
    ->set_base_rel_consider_startup
    ->set_base_rel_sizes
    ->set_base_rel_pathlists // 生成所有基本表的访问路径
    ->make_rel_from_joinlist

PG在生成结果表的访问顺序之前,需要先生成所有基本表的访问路径。而这需要检查基本表是否存在索引,如果存在索引则需要根据索引生成路径;而如果不存在索引,则只能使用TID扫描路径(TID表示元组的物理地址)。

计划生成

在得到最优路径后,规划器会根据该路径生成对应的计划。PG中生成计划的源文件是"src/backend/optimizer/plan/createplan.c",其提供的入口函数是create_plan,里面包含了顺序扫描,采样扫描,索引扫描,TID扫描等计划的生成。

整理计划树

在计划生成后,还需要对计划树做最后的细节调整,以方便执行器的执行。这部分内容由函数set_plan_references实现,其功能包括:

  1. 将各种子查询的范围表折叠成一个链表,同时清除无用的范围表;
  2. 调整扫描结点中的值,以适应折叠后的范围表;
  3. 将子计划的查询结果输出到父计划节点中;
  4. 调整需要部分聚集的聚集函数的计划结点;
  5. 使用PARAM_EXEC参数代替PARAM_MULTIEXPR;
  6. 使用候选子计划代理一个可选的子计划表达式;
  7. 找到每个操作对应的OID;
  8. 创建一些可能需要使用的变量,存储在执行过程中的缓冲区中;
  9. 赋予每个树节点一个特定的ID。

代价估算

路径的效率与其在执行过程中需要的CPU时间以及磁盘存取非常相关,因此PG在文件"src/backend/optimizer/path/costsize.c"中定义了一些关于磁盘I/O以及CPU的估算代价:

  • seq_page_cost(1.0):顺序获取一个磁盘页面的代价
  • random_page_cost(4.0):随机获取一个磁盘页面的代价
  • cpu_tuple_cost(0.01):CPU处理一个元组的代价
  • cpu_index_tuple_cost(0.005):CPU处理一个索引元组的代价
  • cput_operator_cost(0.0025):CPU执行一个算子的代价
  • parallel_tuple_cost(0.1):CPU将元组从worker传递到后端leader的代价
  • parallel_setup_cost(1000.0):CPU设置平行操作中共享内存的代价

一条路径的代价与磁盘中存储的元组数量及元组占用的页数相关,其估计路径代价的步骤主要如下:1. 根据统计信息与查询条件估算本次查询需要的I/O次数以及获取的元组个数,并得到估算的磁盘代价;2. 根据元组数量计算需要的CPU代价;3. 综合考虑磁盘代价与CPU代价

总结

在DBMS中,查询编译是用户与数据库进行交互的桥梁,而查询规划产生的计划效率直接影响数据库的性能。

PG在接收用户的SQL命令时需要经过词法分析,语法分析,然后将生成的解析树交由语义分析以及重写模块(规则系统)进行重写,然后为修改后的查询树生成一个最优的计划树。PG是通过尽可能地遍历所有路径,然后根据其中最好的路径并生成计划树。这部分功能由优化器负责。PG中小规模的优化器使用的是动态规划算法,而大规模的优化则是使用遗传算法。

参考资料(References)

parser-stage

跟我一起读postgresql源码(三)——Rewrite(查询重写模块)

规则系统

跟我一起读postgresql源码(四)——Planer(查询规划模块)(上)

你可能感兴趣的:(postgresql,学习,数据库)