目录结构
注:提前言明 本文借鉴了以下博主、书籍或网站的内容,其列表如下:
1、参考书籍:《PostgreSQL数据库内核分析》
2、参考书籍:《数据库事务处理的艺术:事务管理与并发控制》
3、PostgreSQL数据库仓库链接,点击前往
4、日本著名PostgreSQL数据库专家 铃木启修 网站主页,点击前往
5、参考书籍:《PostgreSQL中文手册》
6、参考书籍:《PostgreSQL指南:内幕探索》,点击前往
7、PostgreSQL planner development and debugging,点击前往
1、本文内容全部来源于开源社区 GitHub和以上博主的贡献,本文也免费开源(可能会存在问题,评论区等待大佬们的指正)
2、本文目的:开源共享 抛砖引玉 一起学习
3、本文不提供任何资源 不存在任何交易 与任何组织和机构无关
4、大家可以根据需要自行 复制粘贴以及作为其他个人用途,但是不允许转载 不允许商用 (写作不易,还请见谅 💖)
5、本文内容基于PostgreSQL master源码开发而成
深入理解PostgreSQL数据库之PostgreSQL 规划器开发与调试
![](https://i-blog.csdnimg.cn/blog_migrate/fbd3c1eee2df26c8f9dde4d8caeb3be3.gif#pic_center)
文章快速说明索引
学习目标:
做数据库内核开发久了就会有一种 少年得志,年少轻狂 的错觉,然鹅细细一品觉得自己其实不算特别优秀 远远没有达到自己想要的。也许光鲜的表面掩盖了空洞的内在,每每想到于此,皆有夜半临渊如履薄冰之感。为了睡上几个踏实觉,即日起 暂缓其他基于PostgreSQL数据库的兼容功能开发,近段时间 将着重于学习分享Postgres的基础知识和实践内幕。
学习内容:(详见目录)
1、PostgreSQL 规划器开发与调试(翻译)
学习时间:
2025年02月09日 19:03:52
学习产出:
1、PostgreSQL数据库基础知识回顾 1个
2、CSDN 技术博客 1篇
3、PostgreSQL数据库内核深入学习
注:下面我们所有的学习环境是Centos8+PostgreSQL master+Oracle19C+MySQL8.0
postgres=# select version();
version
---------------------------------------------------------------------------------
PostgreSQL 18devel on x86_64-pc-linux-gnu, compiled by gcc (GCC) 13.1.0, 64-bit
(1 row)
postgres=#
#-----------------------------------------------------------------------------#
SQL> select * from v$version;
BANNER Oracle Database 19c EE Extreme Perf Release 19.0.0.0.0 - Production
BANNER_FULL Oracle Database 19c EE Extreme Perf Release 19.0.0.0.0 - Production Version 19.17.0.0.0
BANNER_LEGACY Oracle Database 19c EE Extreme Perf Release 19.0.0.0.0 - Production
CON_ID 0
#-----------------------------------------------------------------------------#
mysql> select version();
+-----------+
| version() |
+-----------+
| 8.0.27 |
+-----------+
1 row in set (0.06 sec)
mysql>
注:原文链接 PostgreSQL planner development and debugging,点击前往
这是我在 PGBootCamp 2024 会议上发表的报告“调试 PostgreSQL 规划器”的翻译。
您可以在此处找到包含源代码和其他工作人员的存储库。
在这篇文章中,我们将研究 PostgreSQL 规划器的工作原理,但在代码级别(函数和数据结构)以及如何破解它的规划器。
- 回顾规划器使用的主要功能、主要管道
- 熟悉类型系统:
Node
及其子节点 - 查询如何在代码中表示以及表示其部分的不同数据结构
在了解一些理论之后,我们将实现并在规划器中添加一个小功能。
设置
工作将在之前给出的文件夹链接中完成(它是 cwd)
如果您想复制此帖子的某些部分,则需要设置存储库。
您需要做的就是运行 init.sh
。此脚本:
- 下载 PostgreSQL 16.4
- 应用补丁
- 复制开发脚本
- 如果安装了 VS Code:
-
- 将配置文件复制到
.vscode
文件夹
- 将配置文件复制到
-
- 安装所需的扩展
要下载源代码,您还需要
wget
或curl
和tar
来解压。如果缺少它们,请手动安装它们或使用此链接手动下载存档并将其存储在与init.sh
脚本相同的目录中。
为了构建和调试 PostgreSQL,您还需要安装以下库/可执行文件:
- libreadline
- bison
- flex
- make
- gcc
- gdb (or another debugger)
- CPAN (for PERL)
您可以按照以下方式安装它们:
# Debian based
sudo apt update
sudo apt install build-essential gdb bison flex libreadline-dev git
# RPM based
sudo yum update
sudo yum install gcc gdb bison flex make readline-devel perl-CPAN git
因此,整个设置流程如下:
# Clone repository and go to directory with meetup files
git clone https://github.com/TantorLabs/meetups
cd "meetups/2024-09-17_Kazan/Sergey Solovev - Debugging PostgreSQL planner"
# Run initialization script: files downloading, applying patches, etc...
./init.sh
# Go to PostgreSQL source code directory
cd postgresql
# Run build using dev script (parallel with 8 threads)
./dev/build.sh -j 8
# Initialize DB and setup schema for tests
./dev/run.sh --init-db --run-db --psql --script=../schema_constrexcl.sql --stop-db
# Run VS Code (if you have) and open main work file
code . --goto src/backend/optimizer/util/constrexcl.c
此后,您可以使用 psql 运行 queries_constrexcl.sql
中的脚本。
我们将处理以下文件:
- src/backend/optimizer/util/constrexcl.c
- src/backend/optimizer/util/clauses.c
- src/backend/optimizer/plan/planmain.c
为了进行全面的调试,您应该安装额外的依赖项:
- icu-i18n
- zstd
- zlib
- pkg-config
- 以及 PERL 包
IPC::Run
译者注:本文仅涉及原文的翻译,关于上面仓库里面的patch 后面我会另起文档进行详解,因此大家先行理解原文中的知识!
高级规划器架构
现在开始介绍理论。我将解释一些不太深入的部分。但如果您想了解难点,欢迎阅读 README - 您可以在 src/backend/optimizer 中的 PostgreSQL 源代码存储库中找到许多 README。这些 README 解释了规划器工作流程的多个方面。
查询处理算法
让我们从查询处理管道开始。它可以表示为 4 个不同的阶段:
- 解析
- 重写
- 规划
- 执行
正如您所猜测的,我们将讨论 3 个阶段 - 规划。它也可以分为 4 个阶段:
- 预处理
- 优化
- 寻找可能的路径
- 计划创建
1 和 2 阶段执行一些优化。主要区别在于预处理Preprocessing
使用查询树Query tree
并进行“简单simple
”优化,即常量折叠(计算常量表达式的结果)。但优化Optimization
会进行更难的优化。此类优化使用“整个查询”知识:JOIN、常量、分区、表等。
在第 3 阶段,我们找到执行此查询的所有可能方法(不包括那些已知肯定效率低下的方法)。即表有索引,在我们执行一些优化后,可能会发现我们可能不使用显式排序,因为仅索引扫描Index only scan
提供已排序的数据。
在最后一个阶段,我们找到创建执行计划的最佳路径。执行器将使用此计划来实际运行查询。
源代码组织
在源代码中,这些阶段以这种方式组织。我们有"main"
功能 - "main"
活动的入口点:
query_planner
- 创建表的访问路径(即SeqScan
、IndexScan
等),并创建和初始化主要数据结构以供进一步使用。grouping_planner
-query_planner
的包装器,在我们从表中检索数据后添加后处理逻辑:排序、分组等…subquery_planner
- 规划单个子查询的入口点。每次遇到新的子查询时都会调用它(递归)。standard_planner
- 整个规划器的入口点。
从示意图上看,这可以这样表示:
最上面是 standard_planner
。它准备环境并调用 subquery_planner
进行顶部查询(顶部查询也是子查询,但没有父查询)。
subquery_planner
预处理查询树query tree
并调用 grouping_planner
运行主规划逻辑。
grouping_planner
运行 query_planner
,然后为查询计划添加一些后处理节点:排序、分组、窗口函数等…
query_planner
初始化规划器的状态并执行优化。优化后,它调用 make_one_rel
来实际找到最佳访问路径。
make_one_rel
为整个关系找到最佳访问路径(包括 JOIN 搜索策略)。
standard_join_search
确定最佳 JOIN 策略。它使用与 GEQO(遗传查询优化器)相反的动态规划算法,当单个 JOIN 中的表数达到 geqo_threshold
时调用该算法。
standard_planner()
{
/* Initialize global planner state */
// 初始化全局规划器状态
subquery_planner()
{
/* Query tree preprocessing */
// 查询树预处理
grouping_planner()
{
/* Process GROUP operations: SORT, GROUP BY, WINDOW, SET ops */
// 处理 GROUP 操作:SORT、GROUP BY、WINDOW、SET 操作
query_planner()
{
/* Planner state initialization */ // 规划器状态初始化
/* Optimizations */ // 优化
/* Find access paths */ // 查找访问路径
make_one_rel()
{
/* JOIN strategy */ // JOIN 策略
standard_join_search()
}
}
/* Add postprocessing nodes (group, sort, etc...) */
// 添加后处理节点(分组、排序等...)
}
/* Best path choose */
// 最佳路径选择
}
/* Generating plan for best path */
// 生成最佳路径计划
}
数据结构
现在我们来谈谈类型系统。
节点和树
PostgreSQL 有自己的基于 C 风格继承的类型系统。
Node
- 是所有节点的基础结构。它具有单个成员,类型为 NodeTag
- 类型鉴别器。
这是一个简单的枚举enum
,创建为 T_
前缀 + 结构名称。
所有可能的节点都已为人所知,它们的值在 src/include/nodes/node.h
头文件中定义,或者从 16 版开始,使用 src/include/nodes/nodetags.h
中的 code-gen 生成。
这些节点的实现存储在 src/include/nodes
中的头文件中。此类文件以 *nodes.h
结尾:
- src/include/nodes/primnodes.h
- src/include/nodes/pathnodes.h
- src/include/nodes/plannodes.h
- src/include/nodes/execnodes.h
- src/include/nodes/memnodes.h
- src/include/nodes/miscnodes.h
- src/include/nodes/replnodes.h
- src/include/nodes/parsenodes.h
- src/include/nodes/supportnodes.h
节点示例
此图显示了按用途分组的几种节点类型的树(在我看来)。总共有大约 500 种类型,因此请查看最常用的类型。
List
- 是一个动态数组。它可以存储 void *
、int
、Oid
或 TransactionId
(仅一种特定类型)。使用它的标签,我们可以定义存储在其中的数据:
Type | NodeTag |
---|---|
void * |
T_List |
int |
T_IntList |
Oid |
T_OidList |
TransactionId |
T_XidList |
注意:List
的独特之处在于单个结构有 4 个不同的标签。
Expr
- 是节点的基本结构,可以是执行(表达式)。您可以在 SELECT
或 WHERE
约束的属性列表中遇到它们。示例:
Structure | Description | Evaluation result |
---|---|---|
Var |
Table column | 指定表的元组中指定列的值 |
Const |
Constant | Constant value |
OpExpr |
Operator | 使用指定参数的运算符调用 |
FuncExpr |
Function | 使用指定参数的函数调用 |
BoolExpr |
Boolean expression | 布尔表达式的求值 (NOT , AND , OR ) |
SubPlan |
Sub-SELECT | SELECT 查询执行的结果 |
Node
和Expr
是抽象的,它们没有自己的NodeTag
值。它们作为标记:Node
——PostgreSQL类型系统的普通对象,Expr
——表达式。
还有一组是规划器节点。它们由规划器使用,包含方便访问所需的数据,即 JOIN 中的表以集合形式存储,而不是以树格式存储。
查询树表示
首先,看看查询在代码中是如何表示的。此架构显示了查询的各个部分以及相应的数据结构(节点)。
Query
表示单个查询的查询树。如果我们找到子查询,我们将创建另一个Query
。
接下来,表表示。有一个重要的抽象 - Range Table
。
严格来说,Range table
- 它是可以包含在 FROM
子句、数据源中的所有内容。每个这样的元素称为Range Table Entry
(rte):
- Table
- Function
- Subquery
JOIN
's- CTE
VALUES (), ()
RangeTblEntry
- 是一种表示范围表条目的结构。使用 RTEKind
枚举来区分不同类型的 RTE。
对于示例中的查询,我们有:
tbl1
-RTE_RELATION
tbl2
-RTE_RELATION
tbl1 JOIN tbl2
-RTE_JOIN
generate_series
-RTE_FUNCTION
tbl1 JOIN tbl2 LEFT OUTER JOIN generate_series
-RTE_JOIN
(SELECT MAX(id) ... )
-RTE_SUBQUERY
请注意,每个查询都有自己的范围表,因此 tbl3
- RTE 用于子查询,并且它不在上面的列表中。
规划器中的查询表示
现在,规划器如何看待相同的查询。
主要部分:
PlannerInfo
- 有关单个查询的信息(就像Query
一样)。
RelOptInfo
- 有关 1 个关系的规划器信息:表、函数、子查询、CTE、JOIN 等(就像 RangeTblEntry
一样)。为了区分不同类型的关系,使用 RelOptKind
(另一个枚举)。
在上一节中,您可以注意到一些 RTEKind
是彩色的。让我们将 RelOptKind
与 RTEKind
进行比较。
RELOPT_BASEREL
对应于 RTE_RELATION
(表)、RTE_FUNCTION
(函数)和 RTE_SUBQUERY
(子查询)。基本上,我们不需要如此精确的信息,我们只需要两件事:名称(能够寻址)和返回的属性列表。
此类关系称为基础关系 base relations。规划器喜欢使用它们,而关系来源的知识并不那么重要。
RELOPT_JOINREL
是为 RTE_JOIN
创建的。在这里我们可以看到与查询树的另一个区别。在查询树中,我们有许多 RTE_JOIN
(每个 JOIN 一个),但只有一个。其原因隐藏在存储格式中 - 规划器将连接的表存储为集合。这简化了最佳 JOIN 搜索所需的工作。
RelOptKind
的其他值未涵盖。但它们也很有用,即支持表继承或UNION ALL
。
额外结构
在其工作规划器中使用多个辅助数据结构。现在我们仅介绍其中两个:RestrictInfo
和 EquivalenceClass
。
RestrictInfo
是应用于查询的限制(约束)。即它可以是来自 WHERE
或 JOIN
的条件。在模式的查询中有 3 个限制:
t1.id = t2.id
-JOIN
conditiont1.value = t2.value
- first conditionAND
inWHERE
1.value = 0
- second conditionAND
inWHERE
任何称为
qualifications
(或 quals)的条件/限制conditions/restrictions
等价类EquivalenceClass
是一组已知彼此相等的值。在同一个查询中,我们有 2 个等价类:
t1.id
andt2.id
t1.value
,t2.value
and0
您可以注意到使用 RestrictInfo
创建的 EquivalenceClass
。它们两个 (ds) 都很重要,因为它们帮助规划器找到更优化的扫描路径(示例):
RestrictInfo
- 使用哪个索引。如果我们在t1.value
上有索引,那么我们可以使用它,因为t1.value = 0
限定EquivalenceClass
- 查找所用数据之间的依赖关系。如果在前面的例子中,我们在t2.value
(不是t1.value
)上有索引,那么我们不能使用t1.value = 0
限定,但是t1.value
和t2.value
属于同一个EquivalenceClass
,因此我们可传递性地得出t2.value = 0
并且可以使用索引。
实施约束排除
现在,我们已经了解了足够的信息,可以开始开发规划器了。
在实践中,我们实施“约束排除”Constraint Exclusion
优化 - 检查提供的约束以找出哪些是互斥的,并删除此类关系(表)。
例如,此查询不能删除任何元组:
SELECT * FROM tbl1
WHERE
value > 0
AND
value <= 0;
此类模式在节点中将具有以下表示:
我们正在寻找:
BoolExpr
- 具有AND
条件的布尔表达式,其具有- 两个表达式都是
OpExpr
(运算符调用),具有 - 相反运算符
- 相等(按值)的操作数:
- 左操作数 - 表列 (
Var
) 和 - 右操作数 - 常数(
Const
)
当我们找到这样的模式时,就可以得出结论,可以删除此关系 - 它不会返回任何数据。
为简单起见,我们将仅搜索此模式 - 没有 OR
/NOT
、操作数不切换、运算符必须放在一起等。
实现
现在我们在
src/backend/optimizer/util/constrexcl.c
中工作
首先,我们创建辅助函数 extract_operands
- 它将从提供的 OpExpr
中提取 Var
和 Const
。
运算符也是一个函数,它也有参数。它们存储在成员 args
- List
中。我们发现二元运算符,所以它必须有 2 个参数。要获取列表的长度,请使用函数 list_length
:
if (list_length(expr->args) != 2)
{
return false;
}
根据模式,第一个参数必须是表列 (Var
),第二个参数必须是常量 (Const
)。要检查 Node 的类型,请使用宏 IsA(node, type)
,要从 List
中获取第一个和最后一个元素,请使用 linitial
和 llast
宏。
if (!IsA(linitial(args), Var))
{
return false;
}
if (!IsA(llast(args), Const))
{
return false;
}
*out_var = (Var *) linitial(args);
*out_const = (Const *) llast(args);
因此,整个函数如下所示:
static bool
extract_operands(OpExpr *expr, Var **out_var, Const **out_const)
{
if (list_length(expr->args) != 2)
{
return false;
}
if (!IsA(linitial(expr->args), Var))
{
return false;
}
if (!IsA(llast(expr->args), Const))
{
return false;
}
*out_var = (Var *) linitial(expr->args);
*out_const = (Const *) llast(expr->args);
return true;
}
让我们继续实现主要逻辑。它将位于函数 is_mutually_exclusive
中。此函数接受 2 个 OpExpr
并检查它们是否与模板匹配。
首先使用先前编写的函数从两个 OpExpr
中提取存储的 Var
和 Const
。
{
Var *left_var;
Const *left_const;
Var *right_var;
Const *right_const;
if (!extract_operands(left, &left_var, &left_const))
{
return false;
}
if (!extract_operands(right, &right_var, &right_const))
{
return false;
}
}
现在检查相应的操作数是否相等。要检查 2 个节点是否相等,请使用 equal
函数。
此函数接受
void *
,但仅适用于Node *
/* Table columns */
if (!equal(left_var, right_var))
{
return false;
}
/* Constants */
if (!equal(left_const, right_const))
{
return false;
}
最后一步 - 检查运算符,它们必须是相反的。这可以使用系统目录 pg_operator
中的表来完成。我们需要检查列 oprnegate
- 它包含给定的相反运算符的 Oid。
我们不会直接使用此表 - 在头文件 utils/cache/lsyscache.h
中有多个用于处理系统目录的辅助函数。函数 get_negator
是我们想要的一个 - 它返回给定的相反运算符的 Oid。
为了获得小的性能改进,只添加 1 个检查,假设如果左运算符的相反运算符是右,则右的相反运算符是左。也就是说,它们是对称的。
return get_negator(left->opno) == right-opno;
因此,我们有下一个函数:
static bool
is_mutually_exclusive(OpExpr *left, OpExpr *right)
{
Var *left_var;
Const *left_const;
Var *right_var;
Const *right_const;
if (!extract_operands(left, &left_var, &left_const))
{
return false;
}
if (!extract_operands(right, &right_var, &right_const))
{
return false;
}
if (!equal(left_var, right_var))
{
return false;
}
if (!equal(left_const, right_const))
{
return false;
}
return get_negator(left->opno) == right->opno;
}
我们要做的最后一件事是将逻辑添加到正确的位置。首先,将其添加到 1 阶段 - 查询树预处理。它包含在 subquery_planner
中。
预处理的主要逻辑包含在 proprocess_expression
函数中。它是一个通用函数,它遍历所有节点并执行预处理(可能重写查询树)。
其中我们对 simply_and_arguments
(src/backend/optimizer/util/clauses.c
) 感兴趣 - 它在 preprocess_expression
内部调用并执行 AND
表达式中多个限定的优化。
我们为什么需要这个函数?因为它有输出参数 forceFalse
。如果它设置为 true
,那么整个限定列表可以用常量 FALSE
替换。也就是说,我们不需要自己实现它。
此函数的工作原理如下。它获取 AND
中的条件列表,然后通过迭代所有元素(执行某些逻辑)创建新列表。我们可以在每次迭代的末尾添加我们的逻辑:将先前的限定存储在单独的变量中并与当前变量进行检查。
static List *
simplify_and_arguments(List *args,
eval_const_expressions_context *context,
bool *haveNull, bool *forceFalse)
{
/* ... */
while (unprocessed_args)
{
/* ... */
/* Previous and current expressions must be operator invocation */
if (IsA(arg, OpExpr) && newargs != NIL && IsA(llast(newargs), OpExpr))
{
/* Check for mutually exclusion */
if (is_mutually_exclusive((OpExpr *)arg, (OpExpr *)llast(newargs)))
{
/* Telling to replace all expressions with single FALSE */
*forceFalse = true;
return NIL;
}
}
newargs = lappend(newargs, arg);
}
return newargs;
}
现在用这个例子来测试一下我们得到的结果。我们有这样的模式:
CREATE TABLE tbl1(
id BIGINT GENERATED ALWAYS AS IDENTITY,
value INTEGER
);
CREATE TABLE tbl2(
id BIGINT GENERATED ALWAYS AS IDENTITY,
value INTEGER
);
要运行 DB 和/或 psql,您可以使用脚本
./dev/run.sh --init-db --run-db --psql
或者使用 VS Code 任务Run psql
测试查询:
EXPLAIN ANALYZE
SELECT * FROM tbl1
WHERE
value > 0
AND
value <= 0;
基线如下(vanilla pg):
QUERY PLAN
-----------------------------------------------------------------------------------------------
Seq Scan on tbl (cost=0.00..43.90 rows=11 width=4) (actual time=0.004..0.004 rows=0 loops=1)
Filter: ((value > 0) AND (value <= 0))
Planning Time: 0.186 ms
Execution Time: 0.015 ms
(4 rows)
我们可以看到,该查询实际上是在应用了过滤器的情况下执行的。现在使用我们的更改进行重建。
- 要构建源,您可以使用脚本
./dev/build.sh
,或使用 VS Code 任务Build
/快捷键Ctrl + Shift + B
- 重新运行之前,请不要忘记停止 DB - 脚本
./dev/run.sh --stop-db
,或使用 VS Code 任务Stop DB
然后再次运行该查询:
QUERY PLAN
------------------------------------------------------------------------------------
Result (cost=0.00..0.00 rows=0 width=0) (actual time=0.001..0.002 rows=0 loops=1)
One-Time Filter: false
Planning Time: 0.033 ms
Execution Time: 0.013 ms
(4 rows)
此输出告诉我们,整个输出已被空集替换:
Result
- 节点返回现成的值One-Time Filter: false
:false - 过滤以拒绝所有元组
让我们运行调试器并逐步检查我们的逻辑如何工作。在 is_mutually_exclusive
的开头设置断点。
要使用调试器进行附加(使用此存储库中提供的配置文件):
- 获取后端的 PID(如果使用此存储库中的脚本运行,则在启动 psql 后将显示 PID)
- 在 VS Code 中启动调试会话(即按 F5)
- 在弹出的窗口中输入后端的 PID
- 如有必要,请输入密码
运行我们的查询并等待到达断点。
进入函数 extract_operands
并查看提供的 OpExpr
中的内容。
- 我将使用扩展 PostgreSQL Hacker Helper 查看变量内部,而不是查看内置变量。
- 为什么?因为此扩展了解节点并使用其真实类型显示它们。
- 即给定
Node *
,它将检查其type
,转换为适当的类型并显示该变量。- 在下一个屏幕截图中,您可以看到它如何在提供的
List
中显示真实节点。如果没有此扩展,您必须在Watch
选项卡中手动输入大量表达式。
显示其内容并查看多个成员,但我们需要检查 args
(运算符的参数)中的内容。
我们可以看到 2 个元素:
Var
- 表列Const
- 常量
此函数必须正确执行并返回 true 并设置参数。继续按 F10 直到我们到达 return
- 是的,函数按预期工作。
接下来,退出函数并查看另一个 OpExpr
中的内容,变量为 right
。
我们看到的是相同的图片 - 2 个参数:Var
和 Const
。现在我们将跨过 extract_operands
,它应该可以正常工作。但在比较 Var
时,首先跨过equal
函数调用。
让我们手动比较两个 Var 的内容:
唯一不同的是location
成员。但这肯定是不同的,因为它属于查询的另一部分。跨过一步,我们到达下一个equal
变量指向同一个表列。
让我们再次手动比较常量:
继续前进,我们到达最后一步 - 检查运算符 Oid。这意味着常量也相等。
查看运算符的 Oid - OpExpr
的 opno
成员。Right 的 opno
是 521。
在函数 get_negator
的return
处设置断点并继续执行(F5)。当这个断点命中时,我们可以看到左运算符的求反器等于 523 - right部分的运算符 Oid。
我们的逻辑处于预处理阶段。这是一个重大的缺点。为什么?因为它只处理简单的情况。这样的查询不会被优化:
EXPLAIN ANALYZE
SELECT * FROM tbl1 t1
JOIN tbl2 t2
ON t1.value > 0
WHERE t1.value <= 0;
我们还具备这两个条件,可以理解此查询不会返回任何内容,但我们的代码不会返回任何内容。
为了解决这个问题,我们将代码移至下一阶段 - 规划器优化。
您可能还记得,此阶段从 query_planner
开始,因为 PlannerInfo
的成员在此初始化。这是一个非常庞大的结构,但我们所需要的只是 simple_rel_array
- RelOptInfo
数组(结构,代表关系/表)。
新逻辑的工作方式与预处理阶段类似:遍历所有条件,当找到互斥的条件时 - 用单个 FALSE
替换整个列表(现在我们自己做)。
在 RelOptInfo
中,我们需要使用成员 baserestrictinfo
- 关系上所施加的资格列表。如果我们在 JOIN 中具有资格(仅与 1 个关系相关),则它们将被移至 WHERE
列表中。
我们将遍历所有 RelOptInfo
、以及baserestrictinfo
中的所有 RestrictInfo
。当找到 2 个相邻的互斥表达式时,则替换整个资格列表。
此逻辑将位于我们将添加到 query_planner
的单个函数内。签名如下:
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
}
- root - 当前查询上下文
- rel - 我们正在探索的关系
首先,检查我们至少是否具备 2 项qualifications
:
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
if (list_length(rel->baserestrictinfo) < 2)
{
return;
}
}
现在进入主逻辑 - 找到 2 个相邻的互斥 OpExpr
。我们将前一个 OpExpr
存储在单独的变量中,并使用 baserestrictinfo
数组中的第一个元素对其进行初始化,然后从 2 个元素开始迭代 - 相应地使用 linitial
宏和 for_each_from
。
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
ListCell *lc;
RestrictInfo *prev_rinfo;
/* Read first element */
prev_rinfo = linitial(rel->baserestrictinfo);
/* Start iterating from 2 element */
for_each_from(lc, rel->baserestrictinfo, 1)
{
/* Read next element */
RestrictInfo *cur_rinfo = (RestrictInfo *)lfirst(lc);
/* ... */
}
}
当我们得到下一个元素时,检查前一个和当前资格是否都是 OpExpr
(使用 IsA
)并且(如果是)检查它们是否互斥(使用 is_mutually_exclusive
)。
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
/* Both qualifications are operator call */
if (IsA(prev_rinfo->clause, OpExpr) && IsA(cur_rinfo->clause, OpExpr) &&
/* Qualifications are mutually exclusive */
is_mutually_exclusive((OpExpr *)prev_rinfo->clause, (OpExpr *)cur_rinfo->clause))
{
/* ... */
}
}
如果检查成功,则用单个元素、常量 FALSE
替换整个列表,这样就完成了。
这可以使用 3 个单独的函数来完成:
makeBoolConst
- 创建BoolExpr
,SQL 布尔常量make_restrictinfo
- 使用之前创建的布尔常量创建RestrictInfo
(它有多个标志 - 我们不需要它们,所以它们都是false
)list_make1
- 创建包含 1 个元素的列表
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
/* ... */
/* Create qualification */
RestrictInfo *false_rinfo = make_restrictinfo(root,
/* Create constant `FALSE` */
(Expr *)makeBoolConst(false, false),
false, false, false, false,
0, NULL, NULL, NULL);
/* Create single element List */
rel->baserestrictinfo = list_make1(false_rinfo);
}
但如果检查不成功,则只需更新之前的 RestrictInfo
变量。
整个函数如下所示:
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
ListCell *lc;
RestrictInfo *prev_rinfo;
if (list_length(rel->baserestrictinfo) < 2)
{
return;
}
prev_rinfo = linitial(rel->baserestrictinfo);
for_each_from(lc, rel->baserestrictinfo, 1)
{
RestrictInfo *cur_rinfo = (RestrictInfo *)lfirst(lc);
if (IsA(prev_rinfo->clause, OpExpr) && IsA(cur_rinfo->clause, OpExpr) &&
is_mutually_exclusive((OpExpr *)prev_rinfo->clause, (OpExpr *)cur_rinfo->clause))
{
RestrictInfo *false_rinfo = make_restrictinfo(root,
(Expr *)makeBoolConst(false, false),
false, false, false, false,
0, NULL, NULL, NULL);
rel->baserestrictinfo = list_make1(false_rinfo);
return;
}
prev_rinfo = cur_rinfo;
}
}
我们为单个关系编写了函数,但查询可以有多个关系 - PlannerInfo
的 simple_rel_array
成员。我们将为此编写另一个函数 collaptse_mutually_exclusive_quals
:
void
collapse_mutually_exclusive_quals(PlannerInfo *root)
{
}
我们需要遍历整个数组并对每个数组应用逻辑。但即使在这里也不是那么简单。首先要注意的是,数组索引从 1 开始,而不是 0。这是因为此数组的索引充当范围表(Query->rtable
)中范围表条目的 ID。0 元素始终为 NULL
。所以循环如下所示:
void
collapse_mutually_exclusive_quals(PlannerInfo *root)
{
for (size_t i = 1; i < root->simple_rel_array_size; i++)
{
RelOptInfo *rel = root->simple_rel_array[i];
}
}
第二点说明 - 关系类型。您可能还记得,可以为表和 JOIN、函数等创建 RelOptInfo
。我们需要检查这一点。
最初,我们设计了处理表的逻辑,但我们不需要表特定的功能(如存储格式或其他内容)——我们只需要获取返回属性(列),甚至不需要表的名称。
如果您还记得,这种关系称为base rel
。因此,从现在开始,我们将使用基础关系 - 检查 RelOptInfo
是否为基础关系。此信息存储在 reloptkind
成员中。
void
collapse_mutually_exclusive_quals(PlannerInfo *root)
{
for (size_t i = 1; i < root->simple_rel_array_size; i++)
{
RelOptInfo *rel = root->simple_rel_array[i];
if (rel->reloptkind == RELOPT_BASEREL)
{
collapse_mutually_exclusive_quals_for_rel(root, rel);
}
}
}
业务逻辑已经实现,现在是时候将其集成到规划器中了。我们:
- 使用
baserestrictinfo
成员 - 存储在
RelOptInfo
中 - 存储在
simple_rel_array
成员中
因此,在 simple_rel_array
初始化时添加我们的代码。如果我们稍微研究一下代码,就会发现这是在 add_base_rels_to_query
函数中完成的,因此在该函数之后立即添加我们的代码(在 src/backend/optimizer/plan/planmain.c
中):
RelOptInfo *
query_planner(PlannerInfo *root,
query_pathkeys_callback qp_callback, void *qp_extra)
{
/* ... */
add_base_rels_to_query(root, (Node *) parse->jointree);
/* Constraint Exclusion */
collapse_mutually_exclusive_quals(root);
}
不要忘记从
clause.c
文件中删除第一阶段(查询树预处理)的代码
停止旧数据库,重建,运行 psql 并执行我们的查询:
QUERY PLAN
-----------------------------------------------------------------------------------------------
Seq Scan on tbl (cost=0.00..43.90 rows=11 width=4) (actual time=0.007..0.008 rows=0 loops=1)
Filter: ((value > 0) AND (value <= 0))
Planning Time: 0.042 ms
Execution Time: 0.020 ms
(4 rows)
我们的补丁没有起作用,规划器仍然使用表扫描。让我们运行调试器并观察发生了什么。在 collapse_mutually_exclusive_quals
函数启动处设置断点并运行查询:
采取一些步骤(进入其他函数),我们会发现,列表 baserestrictinfo
长度的检查没有通过 - 规划器认为它是空的(NULL
- 空列表)
原因是大多数主要数据结构(如 PlannerInfo
或 RelOptInfo
)非常庞大,因此在工作期间(动态)初始化,没有单个函数执行此操作 - 初始化分为多个函数。
我们假设 add_base_rels_to_query
创建了 baserestrictinfo
,然后每个元素也初始化。但在这种情况下并非如此 - 每个 RelOptInfo
都没有完全初始化,只有基本属性。
因此,通过正确找到初始化 baserestrictinfo
数组并可供使用的位置,可以修复此错误。它是 deconstruct_join_tree
- 在此函数调用后立即添加我们的代码。
RelOptInfo *
query_planner(PlannerInfo *root,
query_pathkeys_callback qp_callback, void *qp_extra)
{
/* ... */
joinlist = deconstruct_jointree(root);
/* Move our code invocation down */
collapse_mutually_exclusive_quals(root);
}
停止数据库,重建并再次运行查询:
QUERY PLAN
------------------------------------------------------------------------------------
Result (cost=0.00..0.00 rows=0 width=0) (actual time=0.001..0.002 rows=0 loops=1)
One-Time Filter: false
Planning Time: 0.125 ms
Execution Time: 0.012 ms
(4 rows)
约束排除逻辑已成功应用于测试查询。现在让我们使用 JOIN 在新案例上测试它:
EXPLAIN ANALYZE
SELECT * FROM tbl1 t1
JOIN tbl2 t2
ON t1.value > 0
WHERE t1.value <= 0;
运行此查询时,我们得到以下结果:
postgres=# EXPLAIN ANALYZE
SELECT * FROM tbl1 t1
JOIN tbl2 t2
ON t1.value > 0
WHERE t1.value <= 0;
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
The connection to the server was lost. Attempting reset: Failed.
The connection to the server was lost. Attempting reset: Failed.
!?>
出了点问题:后端崩溃了,我们无法再发送查询。日志文件无法获取足够的信息来检测错误原因,因此我们将对其进行调试。
启动 psql,连接调试器,在 collapse_mutually_exclusive_quals
函数上设置断点并运行查询。当该断点命中时,在 for
循环中执行一些步骤,并在使用 reloptkind
检查的 if
语句处停止。再执行一步,我们将在 collapse_mutually_exclusive_quals_for_rel
函数调用处停止。这意味着给定的 rel
是基础 rel - t1
或 t2
。
进入后,我们将在 baserestrictinfo
列表长度检查处停止。如果我们查看此列表,我们将看到 2 个 RestrictInfo
:两者都有 OpExpr
,两侧分别有 Var
和 Const
。这意味着规划器将条件从 JOIN
移到了 WHERE
列表中。
接下来,经过一些步骤之后,我们测试 RestrictInfos
是否互斥。
测试通过,现在我们正在创建具有单个常量 FALSE
的列表。
之后我们返回父函数并转到下一个关系。它还通过了“base rel”检查并调用了collapse_mutually_exclusive_quals_for_rel
。但这次baserestrictinfo
列表是空的 - 这一定是t2
表,因为它没有任何qualifications。
我们再次返回父级并移至下一个 RelOptInfo
(最后一个)。当我们进入 if
语句时,我们得到了 SEGFAULT。如果我们仔细观察,我们会看到 rel
为 NULL
。
这并不奇怪 - 这是正常情况。为什么?因为我们没有阅读 simple_rel_array
的注释:
struct PlannerInfo
{
/*
* simple_rel_array holds pointers to "base rels" and "other rels" (see
* comments for RelOptInfo for more info). It is indexed by rangetable
* index (so entry 0 is always wasted). Entries can be NULL when an RTE
* does not correspond to a base relation, such as a join RTE or an
* unreferenced view RTE; or if the RelOptInfo hasn't been made yet.
*/
struct RelOptInfo **simple_rel_array pg_node_attr(array_size(simple_rel_array_size));
}
仔细看看第 3 句话:“当 RTE 与基本关系不对应时,条目可以为 NULL…”。因此,如果我们的 rel
为 NULL
,则它不是基本关系。那是什么?查看相应的 rte:
它是 JOIN,所以那是因为没有 RelOptInfo
指针。
通过检查 rel
是否不为 NULL
来修复此错误:
void
collapse_mutually_exclusive_quals(PlannerInfo *root)
{
for (size_t i = 1; i < root->simple_rel_array_size; i++)
{
RelOptInfo *rel = root->simple_rel_array[i];
if (rel != NULL && rel->reloptkind == RELOPT_BASEREL)
{
collapse_mutually_exclusive_quals_for_rel(root, rel);
}
}
}
另外还要添加一点断言以确保万无一失:
static void
collapse_mutually_exclusive_quals_for_rel(PlannerInfo *root, RelOptInfo *rel)
{
ListCell *lc;
RestrictInfo *prev_rinfo;
Assert(rel != NULL);
if (list_length(rel->baserestrictinfo) < 2)
{
return;
}
/* ... */
}
停止数据库,重建并运行查询:
QUERY PLAN
-------------------------------------------------------------------------------------
Result (cost=0.00..0.00 rows=0 width=24) (actual time=0.002..0.002 rows=0 loops=1)
One-Time Filter: false
Planning Time: 0.046 ms
Execution Time: 0.012 ms
(4 rows)
错误已修复,查询已成功执行。
但值得注意的是,删除此类信息(谓词列表)并不好:我们删除已知信息并浪费资源尝试应用此优化。
最好的方法是在开始生成所有可能路径时创建新Path
(Result
输出为空)。这是真正的 PostgreSQL 实现。当 GUC 设置constraint_exclusion
设置为 on
(默认情况下partition
以丢弃不必要的分区)时,PostgreSQL 开始查找此类互斥条件。
如果我们深入研究,我们会看到约束排除逻辑应用于函数 relation_excluded_by_constraints
(不言自明的名称),该函数在 set_rel_size
内部调用(当计算每个关系的大小以找到最佳路径时)。此函数的逻辑有点复杂,但简而言之,它完成了我们所做的事情 - 遍历所有谓词并找到互斥的谓词(O(n^2)复杂度,而我们的实现为 O(n)。此外,它还会尝试通过所有可能的方式找到互斥谓词:尝试交换运算符,如果函数是不可变的,它将被评估(实际函数调用)等等。
我找到了这段查找互斥运算符的代码:
static bool
operator_predicate_proof(Expr *predicate, Node *clause,
bool refute_it, bool weak)
{
OpExpr *pred_opexpr,
*clause_opexpr;
Oid pred_collation,
clause_collation;
Oid pred_op,
clause_op,
test_op;
Node *pred_leftop,
*pred_rightop,
*clause_leftop,
*clause_rightop;
/*
* Both expressions must be binary opclauses, else we can't do anything.
*
* Note: in future we might extend this logic to other operator-based
* constructs such as DistinctExpr. But the planner isn't very smart
* about DistinctExpr in general, and this probably isn't the first place
* to fix if you want to improve that.
*/
if (!is_opclause(predicate))
return false;
pred_opexpr = (OpExpr *) predicate;
if (list_length(pred_opexpr->args) != 2)
return false;
if (!is_opclause(clause))
return false;
clause_opexpr = (OpExpr *) clause;
if (list_length(clause_opexpr->args) != 2)
return false;
/*
* If they're marked with different collations then we can't do anything.
* This is a cheap test so let's get it out of the way early.
*/
pred_collation = pred_opexpr->inputcollid;
clause_collation = clause_opexpr->inputcollid;
if (pred_collation != clause_collation)
return false;
/* Grab the operator OIDs now too. We may commute these below. */
pred_op = pred_opexpr->opno;
clause_op = clause_opexpr->opno;
/*
* We have to match up at least one pair of input expressions.
*/
pred_leftop = (Node *) linitial(pred_opexpr->args);
pred_rightop = (Node *) lsecond(pred_opexpr->args);
clause_leftop = (Node *) linitial(clause_opexpr->args);
clause_rightop = (Node *) lsecond(clause_opexpr->args);
if (equal(pred_leftop, clause_leftop))
{
if (equal(pred_rightop, clause_rightop))
{
/* We have x op1 y and x op2 y */
return get_negator(pred_op) == clause_op;
}
}
/* Omitted */
}
如您所见,此代码与我们的代码非常相似,除了排序规则检查之外。但最有趣的部分在省略的部分(最后)。这就是我所说的 - 找到此类运算符的所有可能方法:检查操作数的不同顺序,测试交换运算符,检查常数或常数函数,运算符评估等。
生活窍门
在最后一部分,我将为您提供一些规划器开发的生活窍门。
禁用密码提示
当我们将调试器连接到后端时,系统会提示您输入密码。这是一个安全功能,但在开发过程中不太方便。
您可以禁用此功能:
- 使用配置文件
/etc/sysctl.d/10-ptrace.conf
- 设置kernel.yama.ptrace_scope = 0
- 通过将
0
写入/proc/sys/kernel/yama/ptrace_scope
-echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
您应该将它们结合起来:应用 2 种方法立即禁用密码提示,应用 1 种方法在重启之间保存更改。否则,您必须在每次重启后使用 2 种方法。
显示后端 PID
psql
有自己的配置文件 - .psqlrc
。它包含 psql
启动时要执行的命令。
我们现在需要后端的 PID 来附加到调试器。所以这是插入我们的 SELECT pg_backend_pid();
的好地方。
接下来将环境变量 PSQLRC
设置为我们的 .psqlrc
的路径 - PSQLRC="/home/user/project/build/.psqlrc"
(不要忘记将其export
到 psql
)
之后,psql
启动时将显示后端的 PID。
提示:使用 psql
功能将输出写入外部文件,您可以将 PID 保存在文件中以供以后使用。例如,您可以将其集成到 VS Code 中,以便在按 F5(开始调试)时自动附加到后端。
PostgreSQL 调试工具
当然,PostgreSQL 有自己的调试工具。它们中的大多数与 Node 显示有关。
第一个,在头文件 print.h
中声明了许多函数,用于以自定义格式(不是 json/yaml)输出 Node 结构以进行日志记录。
最基本的函数 - pprint
。它将任何提供的 Node 显示到 stdout
。其他函数是专门的 - print_expr
、print_pathkeys
、print_tl
、print_slot
、print_rt
。
第二个,2 个特殊功能宏。必须在编译期间启用它们:
OPTIMIZER_DEBUG
- common plannerGEQO_DEBUG
- GEQO
如果定义了它们,那么在经过某个阶段后,它的工作结果将被转储到日志中。
第三,GUC设置:
debug_print_parse
debug_print_rewritten
debug_print_plan
如果设置为开启,则在相应阶段(查询解析、查询树重写和规划)之后,其工作结果将被转储到日志中。
但它们输出的是结果,而不是单独的步骤。
自动化
这不再是生活窍门,而是帮助
使用 PostgreSQL 时,我们可以定义 4 个主要阶段:
- Bootstrapping (
configure
invocation) - Compilation
- Tests running
- Run DB and
psql
对于每个步骤,我们都可以编写自动化脚本。但是当我们使用 VS Code 时,我们可以将它们集成到 VS Code 中,从而获得巨大的好处。您可以使用 VS Code 任务来执行此操作。例如,构建任务可以是以下内容:
{
"label": "Build",
"detail": "Build PostgreSQL and install into directory",
"type": "shell",
"command": "${workspaceFolder}/build.sh",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
}
通过这样的设置,您可以按 Ctrl + Shift + B 快捷键来运行构建。
我已经开发了一些用于自动化的脚本。它们位于 dev 文件夹中。您可以从终端或任何您想要的地方运行它们。在此文件夹中,您还可以找到带有教程和使用示例的 README。
此外,还有用于与这些脚本集成的 VS Code 配置文件。它们位于 .vscode 文件夹中。
PostgreSQL Hacker Helper
PostgreSQL Hacker Helper
是用于 PostgreSQL 开发的 VS Code 扩展。
它的主要功能是根据 NodeTag 的值显示 Node 变量的使用。扩展了解它们,当找到一个时,根据其 NodeTag 转换其类型并以此方式显示。
您已经在屏幕截图上看到了这个扩展,然后我们调试源代码并显示 OpExpr
、Var
、Const
变量。
正如您所注意到的,它还了解 List
- 根据其 NodeTag 显示数组的每个成员,而不是普通的 Node *
。
它还有很多其他功能,例如使用 pgindent
或扩展文件引导进行格式化。
该扩展支持几乎所有 PostgreSQL 版本 - 从 8 开始。
在此处链接到扩展。
我们所做的
总之,我们:
- 熟悉了规划器工作管道、其主要阶段和相应的功能
- 学习了 PostgreSQL 类型系统以及与之配合使用的最常用函数
- 设计并实现了我们自己的规划器优化
- 将我们的逻辑添加到不同的管道阶段
- 使用调试器修复代码# PostgreSQL 规划器开发和调试
最重要的是:不要害怕尝试 PostgreSQL Hacker Helper 扩展!