3. 查询重写——rewriter
查询重写利用已有语句特征和关系代数运算来生成更高效的等价语句,在数据库优化器中扮演关键角色;尤其在复杂查询中,能够在性能上带来数量级的提升,可谓是“立竿见影”的“黑科技”。SQL语言是丰富多样的,非常灵活,不同的开发人员依据经验的不同,手写的SQL语句也是各式各样,另外还可以通过工具自动生成。同时SQL语言是一种描述性语言,数据库的使用者只是描述了想要的结果,而不关心数据的具体获取方式,输入数据库的SQL语言很难做到以最优形式表示,往往隐含了一些冗余信息,这些信息可以被挖掘生成更加高效的SQL语句。查询重写就是把用户输入的SQL语句转换为更高效的等价SQL。查询重写遵循两个基本原则。
(1) 等价性:原语句和重写后的语句,输出结果相同。
(2) 高效性:重写后的语句,比原语句在执行时间和资源使用上更高效。
介绍如下几个openGauss数据库关键的查询重写技术。
(1) 常量表达式化简:常量表达式,即用户输入SQL语句中包含运算结果为常量的表达式,分为算数表达式、逻辑运算表达式、函数表达式。查询重写可以对常量表达式预先计算以提升效率。例如“SELECT * FROM table WHERE a=1+1;”语句被重写为“SELECT * FROM table WHERE a=2”语句。
(2) 子查询提升:由于子查询表示的结构更清晰,符合人的阅读理解习惯,用户输入的SQL语句往往包含了大量的子查询,但是相关子查询往往需要使用嵌套循环的方法来实现,执行效率较低,因此将子查询优化为semi join的形式可以在优化规划时选择其他的执行方法,或能提高执行效率。例如“SELECT * FROM t1 WHERE t1.a in (SELECT t2.a FROM t2);”语句可重写为“SELECT * FROM t1 LEFT SEMI JOIN t2 ON t1.a=t2.a”语句。
(3) 谓词下推:谓词(Predicate),通常为SQL语句中的条件,例如“SELECT * FROM t1 WHERE t1.a=1;”语句中的“t1.a=1”即为谓词。等价类(equivalent-class)是指等价的属性、实体等对象的集合,例如“WHERE t1.a=t2.a”语句中,t1.a和t2.a互相等价,组成一个等价类{t1.a,t2.a}。利用等价类推理(又称作传递闭包),可以生成新的谓词条件,从而达到减小数据量和最大化利用索引的目的。举一个形象的例子来说明谓词下推的威力,假设有两个表t1、t2,它们分别包含[1,2,3,…,100]共100行数据,那么查询语句“SELECT * FROM t1 JOIN t2 ON t1.a=t2.a WHERE t1.a=1”的逻辑计划在经过查询重写前后的对比,如图1-3所示。
图1-3 查询重写前后对比
查询重写的主要工作在优化器中实现,源代码目录主要在/src/gausskernel/optimizer/prep,源码文件如表1-5所示。
表1-5 查询重写源代码文件
模块 | 源码文件 | 功能 |
prep | prepqual.cpp | 对谓词进行正则化 |
preptlist.cpp | 对投影进行重写 | |
prepunion.cpp | 处理查询中的集合操作 | |
preprownum.cpp | 对表达式中的rownum进行预处理 | |
prepjointree.cpp | 化简表达式、子查询 | |
prepnonjointree.cpp | Lazy Aggregation优化 |
除此之外,openGauss还提供了基于规则的rewrite接口,用户可以通过创建替换规则的方法对逻辑执行计划进行改写。例如视图展开功能,即通过rewrite模块中的规则进行替换,而视图展开的规则是在创建视图的过程中默认创建的。
1) rewriter源码组织
rewriter源码目录为:/src/gausskernel/optimizer/rewrite。源码文件如表1-6所示。
表1-6 rewriter源码文件
模块 | 源码文件 | 功能 |
rewrite | rewriteDefine.cpp | 定义重写规则 |
rewriteHandler.cpp | 重写主模块 | |
rewriteManip.cpp | 重写操作函数 | |
rewriteRemove.cpp | 重写规则移除函数 | |
rewriteRlsPolicy.cpp | 重写行粒度安全策略 | |
rewriteSupport.cpp | 重写辅助函数 |
2) rewriter主流程
rewriter主流程代码如下:
/* rewrite.cpp */
...
/* 查询重写主函数 */
List* QueryRewrite(Query* parsetree)
{
...
/* 应用所有non-SELECT规则获取改写查询列表 */
querylist = RewriteQuery(parsetree, NIL);
/* 对每个改写查询应用RIR规则 */
results = NIL;
foreach (l, querylist) {
Query* query = (Query*)lfirst(l);
query = fireRIRrules(query, NIL, false);
query->queryId = input_query_id;
results = lappend(results, query);
}
/* 从重写列表确定一个重写结果 */
origCmdType = parsetree->commandType;
foundOriginalQuery = false;
lastInstead = NULL;
foreach (l, results) {...}
...
return results;
}
4. 查询优化——optimizer
优化器(optimizer)的任务是创建最佳执行计划。一个给定的SQL查询以及一个查询树实际上可以以多种不同的方式执行,每种方式都会产生相同的结果集。如果在计算上可行,则查询优化器将检查这些可能的执行计划中的每一个,最终选择预期运行速度最快的执行计划。
在某些情况下,检查执行查询的每种可能方式都会占用大量时间和内存空间,特别是在执行涉及大量连接操作(join)的查询时。为了在合理的时间内确定合理的(不一定是最佳的)查询计划,当查询连接数超过阈值时,openGauss使用遗传查询优化器(genetic query optimizer),通过遗传算法来做执行计划的枚举。
优化器的查询计划(plan)搜索过程实际上与称为路径(path)的数据结构一起使用,该路径只是计划的简化表示,其中仅包含确定计划所需的关键信息。确定代价最低的路径后,将构建完整的计划树以传递给执行器。这足够详细地表示了所需的执行计划,供执行者运行。在本节的其余部分,将忽略路径和计划之间的区别。
1) 生成查询计划
首先,优化器会生成查询中使用的每个单独关系(表)的计划。候选计划由每个关系上的可用索引确定。对关系的顺序扫描是查询最基本的方法,因此总是会创建顺序扫描计划。假设在关系上定义了索引(例如B树索引),并且查询属性恰好与B树索引的键匹配,则使用B树索引创建另一个基于索引的查询计划。如果还存在其他索引并且查询中的限制恰好与索引的关键字匹配,则将考虑其他计划生成更多计划。
其次,如果查询需要连接两个或多个关系,则在找到所有可行的扫描单个关系的计划之后,将考虑连接关系的计划。连接关系有3种可用的连接策略:
(1) 嵌套循环连接:对在左关系中找到的每一行,都会扫描一次右关系。此策略易于实施,但非常耗时。(但是如果可以使用索引扫描来扫描右关系,这可能是一个不错的策略。可以将左关系的当前行中的值用作右索引扫描的键。)
(2)合并连接:在开始连接之前,对进行连接的每个关系的连接属性进行排序。然后,并行扫描进行连接的这两个关系,并组合匹配的行以形成连接行。这种连接更具吸引力,因为每个关系只需扫描一次。所需的排序可以通过明确的排序步骤来实现,也可以通过使用连接键上的索引以正确的顺序扫描关系来实现。
(3) 哈希连接:首先将正确的关系扫描并使用其连接属性作为哈希键加载到哈希表(hash table,也叫散列表)中。接下来,扫描左关系,并将找到的每一行的适当值用作哈希键,以在表中找到匹配的行。
当查询涉及两个以上的关系时,最终结果必须由构建连接树来确定。优化器检查不同的可能连接顺序以找到代价最低的连接顺序。
如果查询所使用的关系数目较少(少于启动启发式搜索阈值),那么将进行近乎穷举的搜索以找到最佳连接顺序。优化器优先考虑存在WHERE限定条件子句中的两个关系之间的连接(即存在诸如rel1.attr1 = rel2.attr2之类的限制),最后才考虑不具有join子句的连接对。优化器会对每个连接操作生成所有可能的执行计划,然后选择(估计)代价最低的那个。当连接表数目超过geqo_threshold时,所考虑的连接顺序由基因查询优化(Genetic Query Optimization,GEQO)启发式方法确定。
完成的计划树包括对基础关系的顺序或索引扫描,以及根据需要的嵌套循环、合并、哈希连接节点和其他辅助步骤,例如排序节点或聚合函数计算节点。这些计划节点类型中的大多数具有执行选择(丢弃不满足指定布尔条件的行)和投影(基于给定列值计算派生列集,即执行标量表达式的运算)的附加功能。优化器的职责之一是将WHERE子句中的选择条件附加起来,并将所需的输出表达式安排到计划树的最适当节点上。
2) 查询计划代价估计
openGauss的优化器是基于代价的优化器,对每条SQL语句生成的多个候选的计划,优化器会计算一个执行代价,最后选择代价最小的计划。
通过统计信息,代价估算系统就可以了解一个表有多少行数据、用了多少个数据页面、某个值出现的频率等,以确定约束条件过滤出的数据占总数据量的比例,即选择率。当一个约束条件确定了选择率之后,就可以确定每个计划路径所需要处理的行数,并根据行数可以推算出所需要处理的页面数。当计划路径处理页面的时候,会产生I/O代价。而当计划路径处理元组的时候(例如针对元组做表达式计算),会产生CPU代价。由于openGauss是单机数据库,无服务器节点间传输数据(元组)会产生通信的代价,因此一个计划的总体代价可以表示为:
总代价 = I/O代价 + CPU代价
openGauss把所有顺序扫描一个页面的代价定义为单位1,所有其他算子的代价都归一化到这个单位1上。比如把随机扫描一个页面的代价定义为4,即认为随机扫描一个页面所需代价是顺序扫描一个页面所需代价的4倍。又比如,把CPU处理一条元组的代价为0.01,即认为CPU处理一条元组所需代价为顺序扫描一个页面所需代价的1/100。
从另一个角度来看,openGauss将代价又分成了启动代价和执行代价,其中:
总代价 = 启动代价 + 执行代价
(1) 启动代价。
从SQL语句开始执行到此算子输出第一条元组为止,所需要的代价称为启动代价。有的算子启动代价很小,比如基表上的扫描算子,一旦开始读取数据页,就可以输出元组,因此启动代价为0。有的算子的启动代价相对较大,比如排序算子,它需要把所有下层算子的输出全部读取到,并且把这些元组排序之后,才能输出第一条元组,因此它的启动代价比较大。
(2) 执行代价。
从输出第一条算子开始,至查询结束,所需要的代价,称为执行代价。这个代价中又可以包含CPU代价、I/O代价,执行代价的大小与算子需要处理的数据量有关,也与每个算子完成的功能有关。处理的数据量越大、算子需要完成的任务越重,则执行代价越大。
(3) 总代价。
如图1-4所示示例,查询中包含两张表,分别命名为t1、t2。t1与t2进行join操作,并且对c1列做聚集。
图1-4 代价计算示例
示例中涉及的代价包括:
(1) 扫描t1的启动代价为0,总代价为13.13。13.13的意思是“总代价相当于顺序扫描13.13个页面”,t2表的扫描同理。
(2) 此计划的join方式为hash join,使用hash join时,必须先对一个子节点的所有数据建立哈希表,再依次使用另一个子节点的每一条元组尝试与hash join中的元组进行匹配。因此hash join的启动代价包括了建立哈希表的代价。
此计划中hash join的启动代价为13.29,对某个结果集建立哈希表时,必须拿到此结果集的所有数据,因此13.29比下层扫描算子的代价13.13大。
此计划中hash join的总代价为28.64。
(3) join完毕之后,需要做聚集运算,此计划中的聚集运算使用了HashAGG算子,此算子需要对join的结果集以c1列作为hash Key建立哈希表,因此它的启动代价又包含了一个建立哈希表的代价。聚集操作的启动代价为28.69,总代价为28.79。
3) optimizer源码组织
optimizer源码目录为:/src/gausskernel/optimizer。optimizer源码文件如表1-7所示。
表1-7 optimizer源码文件
模块 | 源码文件 | 功能 |
plan | analyzejoins.cpp | 初始化查询后的连接简化 |
createplan.cpp | 创建查询计划 | |
dynsmp_single.cpp | SMP自适应接口函数 | |
planner.cpp | 查询优化外部接口 | |
planrecursive_single.cpp | with_recursive递归查询的处理函数 | |
planrewrite.cpp | 基于代价的查询重写 | |
setrefs.cpp | 完成的查询计划树的后处理(修复子计划变量引用) | |
initsplan.cpp | 目标列表、限定条件和连接信息初始化 | |
pgxcplan_single.cpp | 简单查询的旁路执行器 | |
planagg.cpp | 聚集查询的特殊计划 | |
planmain.cpp | 计划主函数:单个查询的计划 | |
streamplan_single.cpp | 流计划相关函数 | |
subselect.cpp | 子选择和参数的计划函数 | |
path | allpaths.cpp | 查找所有可能查询执行路径 |
clausesel.cpp | 子句选择性计算 | |
costsize.cpp | 计算关系和路径代价 | |
pathkeys.cpp | 匹配并建立路径键的公用函数 | |
pgxcpath_single.cpp | 查找关系和代价的所有可能远程查询路径 | |
streampath_single.cpp | 并行处理的路径生成 | |
tidpath.cpp | 确定扫描关系TID(tuple identifier,元组标识符)条件并创建对应TID路径 | |
equivclass.cpp | 管理等价类 | |
indxpath.cpp | 确定可使用索引并创建对应路径 | |
joinpath.cpp | 查找执行一组join操作的所有路径 | |
joinrels.cpp | 确定需要被连接的关系 | |
orindxpath.cpp | 查找匹配OR子句集的索引路径 |
4) optimizer主流程
optimizer主流程代码如下:
/* planmain.cpp */
...
/*
*优化器主函数
*生成基本查询的路径(最简化的查询计划)
*输入参数:
*root:描述需要计划的查询
*tlist: 查询生成的目标列表
*tuple_fraction: 被抽取的元组数量比例
*limit_tuples: 抽取元组数量的数量限制
*输出参数:
*cheapest_path: 查询整体上代价最低的路径
*sorted_path: 排好序的代价最低的数个路径
*num_groups: 估计组的数量(如果查询不使用group运算返回1)
*/
void query_planner(PlannerInfo* root, List* tlist, double tuple_fraction, double limit_tuples,query_pathkeys_callback qp_callback, void *qp_extra, Path** cheapest_path, Path** sorted_path, double* num_groups, List* rollup_groupclauses, List* rollup_lists){
...
/* 空连接树简单query 快速处理 */
if (parse->jointree->fromlist == NIL) {
...
return;
}
setup_simple_rel_arrays(root); /* 获取线性版的范围表,加速读取 */
/* 为基础关系建立RelOptInfo节点 */
add_base_rels_to_query(root, (Node*)parse->jointree);
check_scan_hint_validity(root);
/* 向目标列表添加条目,占位符信息生成,最后形成连接列表 */
build_base_rel_tlists(root, tlist);
find_placeholders_in_jointree(root);
joinlist = deconstruct_jointree(root);
reconsider_outer_join_clauses(root); /* 基于等价类重新考虑外连接*/
/* 对等价类生成额外的限制性子句 */
generate_base_implied_equalities(root);
generate_base_implied_qualities(root);
(*qp_callback) (root, qp_extra); /* 将完整合并的等价类集合转换为标准型 */
fix_placeholder_input_needed_levels(root); /* 检查占位符表达式 */
joinlist = remove_useless_joins(root, joinlist); /* 移除无用外连接 */
add_placeholders_to_base_rels(root); /* 将占位符添加到基础关系 */
/* 对每个参与查询表的大小进行估计,计算total_table_pages */
total_pages = 0;
for (rti = 1; rti < (unsigned int)root->simple_rel_array_size; rti++)
{...}
root->total_table_pages = total_pages;
/* 准备开始主查询计划 */
final_rel = make_one_rel(root, joinlist);
final_rel->consider_parallel = consider_parallel;
...
/* 如果有分组子句,估计结果组数量 */
if (parse->groupClause) {...} /* 如果有分组子句,估计结果组数量 */
else if (parse->hasAggs||root->hasHavingQual||parse->groupingSets)
{...} /* 非分组聚集查询读取所有元组 */
else if (parse->distinctClause) {...} /* 非分组非聚集独立子句估计结果行数 */
else {...} /* 平凡非分组非聚集查询,计算绝对的元组比例 */
/* 计算代价整体最小路径和预排序的代价最小路径 */
cheapestpath = get_cheapest_path(root, final_rel, num_groups, has_groupby);
...
*cheapest_path = cheapestpath;
*sorted_path = sortedpath;
}
5. 查询执行——executor
执行器(executor)采用优化器创建的计划,并对其进行递归处理以提取所需的行的集合。这本质上是一种需求驱动的流水线执行机制,即每次调用一个计划节点时,它都必须再传送一行,或者报告已完成传送所有行。
图1-5 执行计划树示例
如图1-5所示的执行计划树示例,顶部节点是merge join节点。在进行任何合并操作之前,必须获取两个元组(merge join节点的两个子计划各返回1个元组)。因此,执行器以递归方式调用自身以处理其子计划(如从左子树的子计划开始)。merge join由于要做归并操作,因此它要子计划按序返回元组,从图1-5可以看出,它的子计划是一个sort节点。sort的子节点可能是seq scan节点,代表对表的实际读取。执行seq scan节点会使执行程序从表中获取一行并将其返回到调用节点。sort节点将反复调用其子节点以获得所有要排序的行。当输入完毕时(如子节点返回NULL而不是新行),sort算子对获取的元组进行排序,它每次返回1个元组,即已排序的第1行。然后不断排序并向父节点传递剩余的排好序的元组。
merge join节点类似地需要获得其右侧子计划中的第1个元组,看是否可以合并。如果是,它将向其调用方返回1个连接行。在下一次调用时,或者如果它不能连接当前输入对,则立即前进到1个表或另1个表的下一行(取决于比较的结果),然后再次检查是否匹配。最终,1个或另1个子计划用尽,并且merge join节点返回NULL,以指示无法再形成更多的连接行。
复杂的查询可能涉及多个级别的计划节点,但是一般方法是相同的:每个节点都会在每次调用时计算并返回其下一个输出行。每个节点还负责执行优化器分配给它的任何选择或投影表达式。
执行器机制用于执行所有4种基本SQL查询类型:SELECT、INSERT、UPDATE和DELETE。
(1) 对于SELECT,顶级执行程序代码仅需要将查询计划树返回的每一行发送给客户端。
(2) 对于INSERT,每个返回的行都插入INSERT指定的目标表中。这是在称为ModifyTable的特殊顶层计划节点中完成的。(1个简单的“INSERT ... VALUES”命令创建了1个简单的计划树,该树由单个Result节点组成,该节点仅计算一个结果行,并传递给ModifyTable树节点实现插入)。
(3) 对于UPDATE,优化器对每个计算的更新行附着所更新的列值,以及原始目标行的TID(tuple identifier,元组ID或行ID);此数据被馈送到ModifyTable节点,并使用该信息来创建新的更新行并标记旧行已删除。
(4) 对于DELETE,计划实际返回的唯一列是TID,而ModifyTable节点仅使用TID访问每个目标行并将其标记为已删除。
执行器的主要处理控制流程如下。
(1) 创建查询描述。
(2) 查询初始化:创建执行器状态(查询执行上下文)、执行节点初始化(创建表达式与每个元组上下文、执行表达式初始化)。
(3) 查询执行:执行处理节点(递归调用查询上下文、执行表达式,然后释放内存,重复操作)。
(4) 查询完成;执行未完成的表格修改节点。
(5) 查询结束:递归释放资源、释放查询及其子节点上下文。
(6) 释放查询描述。
1) executor源码组织
executor源码目录为:/src/gausskernel/runtime/executor。executor源码文件如表1-8所示。
表1-8 executor源码文件
模块 | 源码文件 | 功能 |
executor | execAmi.cpp | 各种执行器访问方法 |
execClusterResize.cpp | 集群大小调整 | |
execCurrent.cpp | 支持WHERE CURRENT OF | |
execGrouping.cpp | 支持分组、哈希和聚集操作 | |
execJunk.cpp | 伪列的支持 | |
execMain.cpp | 顶层执行器接口 | |
execMerge.cpp | 处理MERGE指令 | |
execParallel.cpp | 支持并行执行 | |
execProcnode.cpp | 分发函数按节点调用相关初始化等函数 | |
execQual.cpp | 评估资质和目标列表的表达式 | |
execScan.cpp | 通用的关系扫描 | |
execTuples.cpp | 元组相关的资源管理 | |
execUtils.cpp | 多种执行相关工具函数 | |
functions.cpp | 执行SQL语言函数 | |
instrument.cpp | 计划执行工具 | |
lightProxy.cpp | 轻量级执行代理 | |
node*.cpp | 处理*相关节点操作的函数 | |
opfusion.cpp | 旁路执行器:处理简单查询 | |
spi.cpp | 服务器编程接口 | |
tqueue.cpp | 并行后端之间的元组信息传输 | |
tstoreReceiver.cpp | 存储结果元组 |
2) executor主流程
executor主流程代码为:
/* execMain.cpp */
...
/* 执行器启动 */
void ExecutorStart(QueryDesc *queryDesc, int eflags)
{
gstrace_entry(GS_TRC_ID_ExecutorStart);
if (ExecutorStart_hook) {
(*ExecutorStart_hook)(queryDesc, eflags);
} else {
standard_ExecutorStart(queryDesc, eflags);
}
gstrace_exit(GS_TRC_ID_ExecutorStart);
}
/* 执行器运行 */
void ExecutorRun(QueryDesc *queryDesc, ScanDirection direction, long count)
{
...
/* SQL 自调优: 查询执行完毕时,基于运行时信息分析查询计划问题 */
if (u_sess->exec_cxt.need_track_resource && queryDesc != NULL && has_track_operator &&(IS_PGXC_COORDINATOR || IS_SINGLE_NODE)) {
List *issue_results = PlanAnalyzerOperator(queryDesc, queryDesc->planstate);
/* 如果查询问题找到,存在系统视图 gs_wlm_session_history */
if (issue_results != NIL) {
RecordQueryPlanIssues(issue_results);
}
}
/* 查询动态特征, 操作历史统计信息 */
if (can_operator_history_statistics) {
u_sess->instr_cxt.can_record_to_table = true;
ExplainNodeFinish(queryDesc->planstate, queryDesc->plannedstmt, GetCurrentTimestamp(), false);
...
}
}
/* 执行器完成 */
void ExecutorFinish(QueryDesc *queryDesc)
{
if (ExecutorFinish_hook) {
(*ExecutorFinish_hook)(queryDesc);
} else {
standard_ExecutorFinish(queryDesc);
}
}
/* 执行器结束 */
void ExecutorEnd(QueryDesc *queryDesc)
{
if (ExecutorEnd_hook) {
(*ExecutorEnd_hook)(queryDesc);
} else {
standard_ExecutorEnd(queryDesc);
}
}