PostgreSQL的学习心得和知识总结(一百六十八)|深入理解PostgreSQL数据库之PostgreSQL 规划器开发与调试(翻译)

发布于:2025-02-15 ⋅ 阅读:(10) ⋅ 点赞:(0)

注:提前言明 本文借鉴了以下博主、书籍或网站的内容,其列表如下:

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数据库的兼容功能开发,近段时间 将着重于学习分享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 文件夹
    • 安装所需的扩展

要下载源代码,您还需要 wgetcurltar 来解压。如果缺少它们,请手动安装它们或使用此链接手动下载存档并将其存储在与 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 个不同的阶段:

  1. 解析
  2. 重写
  3. 规划
  4. 执行

在这里插入图片描述

正如您所猜测的,我们将讨论 3 个阶段 - 规划。它也可以分为 4 个阶段:

  1. 预处理
  2. 优化
  3. 寻找可能的路径
  4. 计划创建

1 和 2 阶段执行一些优化。主要区别在于预处理Preprocessing使用查询树Query tree并进行“简单simple”优化,即常量折叠(计算常量表达式的结果)。但优化Optimization会进行更难的优化。此类优化使用“整个查询”知识:JOIN、常量、分区、表等。

在第 3 阶段,我们找到执行此查询的所有可能方法(不包括那些已知肯定效率低下的方法)。即表有索引,在我们执行一些优化后,可能会发现我们可能不使用显式排序,因为仅索引扫描Index only scan提供已排序的数据。

在最后一个阶段,我们找到创建执行计划的最佳路径。执行器将使用此计划来实际运行查询。


源代码组织

在源代码中,这些阶段以这种方式组织。我们有"main"功能 - "main"活动的入口点:

  • query_planner - 创建表的访问路径(即 SeqScanIndexScan 等),并创建和初始化主要数据结构以供进一步使用。
  • 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 *intOidTransactionId(仅一种特定类型)。使用它的标签,我们可以定义存储在其中的数据:

Type NodeTag
void * T_List
int T_IntList
Oid T_OidList
TransactionId T_XidList

注意:List的独特之处在于单个结构有 4 个不同的标签。

Expr - 是节点的基本结构,可以是执行(表达式)。您可以在 SELECTWHERE 约束的属性列表中遇到它们。示例:

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 查询执行的结果

NodeExpr是抽象的,它们没有自己的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 是彩色的。让我们将 RelOptKindRTEKind 进行比较。

RELOPT_BASEREL 对应于 RTE_RELATION(表)、RTE_FUNCTION(函数)和 RTE_SUBQUERY(子查询)。基本上,我们不需要如此精确的信息,我们只需要两件事:名称(能够寻址)和返回的属性列表。

此类关系称为基础关系 base relations。规划器喜欢使用它们,而关系来源的知识并不那么重要。

RELOPT_JOINREL 是为 RTE_JOIN 创建的。在这里我们可以看到与查询树的另一个区别。在查询树中,我们有许多 RTE_JOIN(每个 JOIN 一个),但只有一个。其原因隐藏在存储格式中 - 规划器将连接的表存储为集合。这简化了最佳 JOIN 搜索所需的工作。

RelOptKind 的其他值未涵盖。但它们也很有用,即支持表继承或 UNION ALL


额外结构

在其工作规划器中使用多个辅助数据结构。现在我们仅介绍其中两个:RestrictInfoEquivalenceClass

在这里插入图片描述

RestrictInfo 是应用于查询的限制(约束)。即它可以是来自 WHEREJOIN 的条件。在模式的查询中有 3 个限制:

  • t1.id = t2.id - JOIN condition
  • t1.value = t2.value - first condition AND in WHERE
  • 1.value = 0 - second condition AND in WHERE

任何称为qualifications (或 quals)的条件/限制conditions/restrictions

等价类EquivalenceClass是一组已知彼此相等的值。在同一个查询中,我们有 2 个等价类:

  • t1.id and t2.id
  • t1.value, t2.value and 0

您可以注意到使用 RestrictInfo 创建的 EquivalenceClass。它们两个 (ds) 都很重要,因为它们帮助规划器找到更优化的扫描路径(示例):

  • RestrictInfo - 使用哪个索引。如果我们在 t1.value 上有索引,那么我们可以使用它,因为 t1.value = 0 限定
  • EquivalenceClass - 查找所用数据之间的依赖关系。如果在前面的例子中,我们在 t2.value(不是 t1.value)上有索引,那么我们不能使用 t1.value = 0 限定,但是 t1.valuet2.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 中提取 VarConst

运算符也是一个函数,它也有参数。它们存储在成员 args - List 中。我们发现二元运算符,所以它必须有 2 个参数。要获取列表的长度,请使用函数 list_length

if (list_length(expr->args) != 2)
{
    return false;
}

根据模式,第一个参数必须是表列 (Var),第二个参数必须是常量 (Const)。要检查 Node 的类型,请使用宏 IsA(node, type),要从 List 中获取第一个和最后一个元素,请使用 linitialllast 宏。

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 中提取存储的 VarConst

{
    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 个元素:

  1. Var - 表列
  2. Const - 常量

此函数必须正确执行并返回 true 并设置参数。继续按 F10 直到我们到达 return - 是的,函数按预期工作。

接下来,退出函数并查看另一个 OpExpr 中的内容,变量为 right

在这里插入图片描述

我们看到的是相同的图片 - 2 个参数:VarConst。现在我们将跨过 extract_operands,它应该可以正常工作。但在比较 Var 时,首先跨过equal函数调用。

让我们手动比较两个 Var 的内容:

在这里插入图片描述

唯一不同的是location成员。但这肯定是不同的,因为它属于查询的另一部分。跨过一步,我们到达下一个equal变量指向同一个表列。

让我们再次手动比较常量:

在这里插入图片描述
继续前进,我们到达最后一步 - 检查运算符 Oid。这意味着常量也相等。

查看运算符的 Oid - OpExpropno 成员。Right 的 opno 是 521。

在这里插入图片描述

在函数 get_negatorreturn处设置断点并继续执行(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;
    }
}

我们为单个关系编写了函数,但查询可以有多个关系 - PlannerInfosimple_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);
        }
    }
}

业务逻辑已经实现,现在是时候将其集成到规划器中了。我们:

  1. 使用 baserestrictinfo 成员
  2. 存储在 RelOptInfo
  3. 存储在 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 - 空列表)

在这里插入图片描述

原因是大多数主要数据结构(如 PlannerInfoRelOptInfo)非常庞大,因此在工作期间(动态)初始化,没有单个函数执行此操作 - 初始化分为多个函数。

我们假设 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 - t1t2

在这里插入图片描述

进入后,我们将在 baserestrictinfo 列表长度检查处停止。如果我们查看此列表,我们将看到 2 个 RestrictInfo:两者都有 OpExpr,两侧分别有 VarConst。这意味着规划器将条件从 JOIN 移到了 WHERE 列表中。

在这里插入图片描述

接下来,经过一些步骤之后,我们测试 RestrictInfos 是否互斥。

在这里插入图片描述

测试通过,现在我们正在创建具有单个常量 FALSE 的列表。

在这里插入图片描述
之后我们返回父函数并转到下一个关系。它还通过了“base rel”检查并调用了collapse_mutually_exclusive_quals_for_rel。但这次baserestrictinfo列表是空的 - 这一定是t2表,因为它没有任何qualifications。

在这里插入图片描述

我们再次返回父级并移至下一个 RelOptInfo(最后一个)。当我们进入 if 语句时,我们得到了 SEGFAULT。如果我们仔细观察,我们会看到 relNULL

在这里插入图片描述

这并不奇怪 - 这是正常情况。为什么?因为我们没有阅读 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…”。因此,如果我们的 relNULL,则它不是基本关系。那是什么?查看相应的 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)

错误已修复,查询已成功执行。

但值得注意的是,删除此类信息(谓词列表)并不好:我们删除已知信息并浪费资源尝试应用此优化。

最好的方法是在开始生成所有可能路径时创建新PathResult输出为空)。这是真正的 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"(不要忘记将其exportpsql

之后,psql 启动时将显示后端的 PID。

提​​示:使用 psql 功能将输出写入外部文件,您可以将 PID 保存在文件中以供以后使用。例如,您可以将其集成到 VS Code 中,以便在按 F5(开始调试)时自动附加到后端。


PostgreSQL 调试工具

当然,PostgreSQL 有自己的调试工具。它们中的大多数与 Node 显示有关。

第一个,在头文件 print.h 中声明了许多函数,用于以自定义格式(不是 json/yaml)输出 Node 结构以进行日志记录。

最基本的函数 - pprint。它将任何提供的 Node 显示到 stdout。其他函数是专门的 - print_exprprint_pathkeysprint_tlprint_slotprint_rt

第二个,2 个特殊功能宏。必须在编译期间启用它们:

  • OPTIMIZER_DEBUG - common planner
  • GEQO_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 转换其类型并以此方式显示。

您已经在屏幕截图上看到了这个扩展,然后我们调试源代码并显示 OpExprVarConst 变量。

正如您所注意到的,它还了解 List - 根据其 NodeTag 显示数组的每个成员,而不是普通的 Node *

它还有很多其他功能,例如使用 pgindent 或扩展文件引导进行格式化。

该扩展支持几乎所有 PostgreSQL 版本 - 从 8 开始。

此处链接到扩展。


我们所做的

总之,我们:

  1. 熟悉了规划器工作管道、其主要阶段和相应的功能
  2. 学习了 PostgreSQL 类型系统以及与之配合使用的最常用函数
  3. 设计并实现了我们自己的规划器优化
  4. 将我们的逻辑添加到不同的管道阶段
  5. 使用调试器修复代码# PostgreSQL 规划器开发和调试

最重要的是:不要害怕尝试 PostgreSQL Hacker Helper 扩展!