【OpenGauss源码学习 —— 执行算子(IndexScan算子)】

执行算子(IndexScan算子)

  • IndexScan 算子
    • ExecInitIndexScan 函数
      • IndexScan 结构
    • ExecIndexScan 函数
      • ExecScan 函数
      • ExecScanFetch 函数
      • IndexNext 函数
      • IndexScanDescData 结构体
    • ExecEndIndexScan 函数
  • 总结

声明:本文的部分内容参考了他人的文章。在编写过程中,我们尊重他人的知识产权和学术成果,力求遵循合理使用原则,并在适用的情况下注明引用来源。
本文主要参考了 OpenGauss1.1.0 的开源代码和《OpenGauss数据库源码解析》一书

IndexScan 算子

  IndexScan 算子是索引扫描算子,对应 IndexScan 计划节点,相关的代码源文件是 “nodeIndexScan.cpp”。如果过滤条件涉及索引,则查询计划对表的扫描使用 IndexScan 算子,利用索引加速元组获取。
  IndexScan 算子执行索引扫描操作,它是通过遍历数据库表的索引来查找符合查询条件的数据行,而不是全表扫描。这可以大大加速查询操作,尤其是在大型表上。此外,IndexScan 算子接收一个或多个查询条件,通常是等值条件(例如,WHERE column = value),用于定位匹配条件的行。IndexScan 算子通常作为查询计划中的一部分出现,由查询优化器决定是否使用索引扫描来执行查询。优化器会考虑查询条件索引类型表大小等因素来选择最佳的执行计划。
  IndexScan 算子对应的主要函数如下表所示:

主要函数 说 明
ExecInitIndexScan 初始化 IndexScan 状态节点
ExecIndexScan 迭代获取元组
ExecEndIndexScan 清理 IndexScan 状态节点
ExecIndexMarkPos 标记扫描位置
ExecIndexRestrPos 重置扫描位置
ExecReScanIndexScan 重置 IndexScan

  按照传统,下面我们还是以一个案例来调试一下代码吧,首先执行sql语句:

-- 创建 employees 表
-- 创建示例表
CREATE TABLE employees (
    employee_id serial PRIMARY KEY,
    first_name text,
    last_name text,
    salary numeric
);

-- 创建索引
CREATE INDEX idx_employee_id ON employees (employee_id);

-- 插入一些示例数据
INSERT INTO employees (first_name, last_name, salary) VALUES
    ('John', 'Doe', 50000),
    ('Jane', 'Smith', 60000),
    ('Bob', 'Johnson', 55000),
    ('Alice', 'Williams', 62000);

-- 使用 IndexScan 算子查询
EXPLAIN ANALYZE
SELECT * FROM employees WHERE employee_id = 2;

ExecInitIndexScan 函数

  ExecInitIndexScan 函数的作用是初始化 IndexScan 算子的状态信息,包括创建表达式上下文、初始化扫描键打开基表和索引表,为执行 IndexScan 算子做好准备,提供必要的环境和数据结构。该函数主要用于执行索引扫描操作的初始化工作。
  执行SELECT * FROM employees WHERE employee_id = 2;语句后在 ExecInitIndexScan 函数上打上断点进行调试。

 ┌──nodeIndexscan.cpp─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
   │494     }                                                                                                                                                   │
   │495                                                                                                                                                         │
   │496     /* ----------------------------------------------------------------                                                                                 │
   │497      *              ExecInitIndexScan                                                                                                                   │
   │498      *                                                                                                                                                  │
   │499      *              Initializes the index scan's state information, creates                                                                             │
   │500      *              scan keys, and opens the base and index relations.                                                                                  │
   │501      *                                                                                                                                                  │
   │502      *              Note: index scans have 2 sets of state information because                                                                          │
   │503      *                        we have to keep track of the base relation and the                                                                        │
   │504      *                        index relation.                                                                                                           │
   │505      * ----------------------------------------------------------------                                                                                 │
   │506      */                                                                                                                                                 │
   │507     IndexScanState* ExecInitIndexScan(IndexScan* node, EState* estate, int eflags)                                                                      │
   │508     {                                                                                                                                                   │
B+509         IndexScanState* index_state = NULL;                                                                                                             │
   │510         Relation current_relation;                                                                                                                      │
   │511         bool relis_target = false;                                                                                                                      │
   │512                                                                                                                                                         │
   │513         gstrace_entry(GS_TRC_ID_ExecInitIndexScan);                                                                                                     │
   │514         /*                                                                                                                                              │
   │515          * create state structure                                                                                                                       │
   │516          */                                                                                                                                             │
   │517         index_state = makeNode(IndexScanState);                                                                                                         │
   │518         index_state->ss.ps.plan = (Plan*)node; 
   └────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘    

  ExecInitIndexScan 函数源码如下:(路径:src/gausskernel/runtime/executor/nodeIndexscan.cpp

/*
 * ExecInitIndexScan
 * 
 * 初始化索引扫描的状态信息,创建扫描键,打开基表和索引表。
 * 注意:索引扫描有两组状态信息,因为我们必须跟踪基表和索引表。
 */
IndexScanState* ExecInitIndexScan(IndexScan* node, EState* estate, int eflags)
{
    IndexScanState* index_state = NULL;
    Relation current_relation;
    bool relis_target = false;

    gstrace_entry(GS_TRC_ID_ExecInitIndexScan);

    /*
     * 创建状态结构
     */
    index_state = makeNode(IndexScanState);
    index_state->ss.ps.plan = (Plan*)node;
    index_state->ss.ps.state = estate;
    index_state->ss.isPartTbl = node->scan.isPartTbl;
    index_state->ss.currentSlot = 0;
    index_state->ss.partScanDirection = node->indexorderdir;

    /*
     * 杂项初始化
     *
     * 为节点创建表达式上下文
     */
    ExecAssignExprContext(estate, &index_state->ss.ps);

    index_state->ss.ps.ps_TupFromTlist = false;

    /*
     * 初始化子表达式
     *
     * 注意:我们只初始化与运行时键对应的索引条件的子部分,也是如此。
     * 同样适用于 indexorderby,如果有的话。 但是 indexqualorig 表达式总是初始化,
     * 即使它只在一些不常见的情况下使用 --- 希望改进这一点。(问题在于表达式中的任何子计划现在都必须找到...)
     */
    index_state->ss.ps.targetlist = (List*)ExecInitExpr((Expr*)node->scan.plan.targetlist, (PlanState*)index_state);
    index_state->ss.ps.qual = (List*)ExecInitExpr((Expr*)node->scan.plan.qual, (PlanState*)index_state);
    index_state->indexqualorig = (List*)ExecInitExpr((Expr*)node->indexqualorig, (PlanState*)index_state);

    /*
     * 打开基表并在其上获取适当的锁。
     */
    current_relation = ExecOpenScanRelation(estate, node->scan.scanrelid);

    index_state->ss.ss_currentRelation = current_relation;
    index_state->ss.ss_currentScanDesc = NULL; /* 这里不进行堆扫描 */
    
    /*
     * 元组表初始化
     */
    ExecInitResultTupleSlot(estate, &index_state->ss.ps, current_relation->rd_tam_type);
    ExecInitScanTupleSlot(estate, &index_state->ss, current_relation->rd_tam_type);

    /*
     * 从关系描述符中获取扫描类型。
     */
    ExecAssignScanType(&index_state->ss, CreateTupleDescCopy(RelationGetDescr(current_relation)));
    index_state->ss.ss_ScanTupleSlot->tts_tupleDescriptor->tdTableAmType = TAM_HEAP;

    /*
     * 初始化结果元组类型和投影信息。
     */
    ExecAssignResultTypeFromTL(&index_state->ss.ps);
    index_state->ss.ps.ps_ResultTupleSlot->tts_tupleDescriptor->tdTableAmType =
            index_state->ss.ss_ScanTupleSlot->tts_tupleDescriptor->tdTableAmType;

    ExecAssignScanProjectionInfo(&index_state->ss);
    Assert(index_state->ss.ps.ps_ResultTupleSlot->tts_tupleDescriptor->tdTableAmType != TAM_INVALID);

    /*
     * 如果只是执行解释(即不打算运行计划),则在这里停止。这允许索引顾问插件解释包含对不存在索引的引用的计划。
     */
    if (eflags & EXEC_FLAG_EXPLAIN_ONLY) {
        gstrace_exit(GS_TRC_ID_ExecInitIndexScan);
        return index_state;
    }

    /*
     * 打开索引关系。
     *
     * 如果父表是查询的目标关系之一,则 InitPlan 已经打开并对索引进行了写锁定,
     * 因此我们可以避免在这里再次获取锁。否则,我们需要正常的读取锁。
     */
    relis_target = ExecRelationIsTargetRelation(estate, node->scan.scanrelid);
    index_state->iss_RelationDesc = index_open(node->indexid, relis_target ? NoLock : AccessShareLock);
    if (!IndexIsUsable(index_state->iss_RelationDesc->rd_index)) {
        ereport(ERROR,
            (errcode(ERRCODE_INDEX_CORRUPTED),
                errmsg("无法使用不可用索引 \"%s\" 初始化索引扫描",
                    RelationGetRelationName(index_state->iss_RelationDesc))));
    }

    /*
     * 初始化索引特定的扫描状态
     */
    index_state->iss_RuntimeKeysReady = false;
    index_state->iss_RuntimeKeys = NULL;
    index_state->iss_NumRuntimeKeys = 0;

    /*
     * 从索引限制条件构建索引扫描键
     */
    ExecIndexBuildScanKeys((PlanState*)index_state,
        index_state->iss_RelationDesc,
        node->indexqual,
        false,
        &index_state->iss_ScanKeys,
        &index_state->iss_NumScanKeys,
        &index_state->iss_RuntimeKeys,
        &index_state->iss_NumRuntimeKeys,
        NULL, /* 无数组键 */
        NULL);

    /*
     * 任何 ORDER BY 表达式也必须以相同的方式转换为扫描键
     */
    ExecIndexBuildScanKeys((PlanState*)index_state,
        index_state->iss_RelationDesc,
        node->indexorderby,
        true,
        &index_state->iss_OrderByKeys,
        &index_state->iss_NumOrderByKeys,
        &index_state->iss_RuntimeKeys,
        &index_state->iss_NumRuntimeKeys,
        NULL, /* 无数组键 */
        NULL);

    /*
     * 如果存在运行时键,我们需要一个 ExprContext 来计算它们。节点的标准上下文不合适,
     * 因为我们希望为每个元组重置该上下文。因此,建立另一个上下文,与其他上下文相同...
     * -tgl 7/11/00
     */
    if (index_state->iss_NumRuntimeKeys != 0) {
        ExprContext* stdecontext = index_state->ss.ps.ps_ExprContext;
        ExecAssignExprContext(estate, &index_state->ss.ps);
        index_state->iss_RuntimeContext = index_state->ss.ps.ps_ExprContext;
        index_state->ss.ps.ps_ExprContext = stdecontext;
    } else {
        index_state->iss_RuntimeContext = NULL;
    }

    /* 处理分区信息 */
    if (node->scan.isPartTbl) {
        index_state->iss_ScanDesc = NULL;

        if (node->scan.itrs > 0) {
            Partition current_partition = NULL;
            Partition currentindex = NULL;

            /* 为后续扫描初始化表分区列表和索引分区列表 */
            ExecInitPartitionForIndexScan(index_state, estate);

            if (index_state->ss.partitions != NIL) {
                /* 使用第一个表分区构建带有虚拟关系的哑关系,以进行后续扫描 */
                current_partition = (Partition)list_nth(index_state->ss.partitions, 0);
                index_state->ss.ss_currentPartition =
                    partitionGetRelation(index_state->ss.ss_currentRelation, current_partition);

                /* 使用第一个索引分区构建带有虚拟关系的哑关系,以进行后续扫描 */
                currentindex = (Partition)list_nth(index_state->iss_IndexPartitionList, 0);
                index_state->iss_CurrentIndexPartition =
                    partitionGetRelation(index_state->iss_RelationDesc, currentindex);

                /* 为分区表初始化扫描描述符 */
                index_state->iss_ScanDesc = scan_handler_idx_beginscan(index_state->ss.ss_currentPartition,
                    index_state->iss_CurrentIndexPartition,
                    estate->es_snapshot,
                    index_state->iss_NumScanKeys,
                    index_state->iss_NumOrderByKeys,
                    (ScanState*)index_state);
                Assert(PointerIsValid(index_state->iss_ScanDesc));
            }
        }
    } else {
        /*
         * 初始化扫描描述符。
         */
        index_state->iss_ScanDesc = scan_handler_idx_beginscan(current_relation,
            index_state->iss_RelationDesc,
            estate->es_snapshot,
            index_state->iss_NumScanKeys,
            index_state->iss_NumOrderByKeys,
            (ScanState*)index_state);
    }

    /*
     * 如果没有运行时键需要计算,就把扫描键传递给索引 AM。
     */
    if (index_state->iss_ScanDesc == NULL) {
        index_state->ss.ps.stubType = PST_Scan;
    } else if (index_state->iss_NumRuntimeKeys == 0) {
        scan_handler_idx_rescan_local(index_state->iss_ScanDesc,
            index_state->iss_ScanKeys,
            index_state->iss_NumScanKeys,
            index_state->iss_OrderByKeys,
            index_state->iss_NumOrderByKeys);
    }

    /* 全部完成 */
    gstrace_exit(GS_TRC_ID_ExecInitIndexScan);
    return index_state;
}

ExecInitIndexScan 函数的执行过程如下:

  1. 创建 IndexScanState 结构体,用于存储 IndexScan 算子的状态信息,并将其初始化。
  2. 为算子创建表达式上下文(expression context),以便在执行时计算表达式。
  3. 初始化目标列表(targetlist)限定列表(qual),这些用于评估索引扫描条件投影输出
  4. 打开基表(base relation)并获取适当的,存储在 index_state->ss.ss_currentRelation 中。
  5. 初始化元组表插槽(tuple table slot),用于存储扫描结果
  6. 获取索引表的元数据信息,如表的类型信息、结果类型等。
  7. 如果只需要执行解释计划(例如 EXPLAIN 查询),则直接返回,不执行实际的查询。
  8. 打开索引表并获取合适的,存储在 index_state->iss_RelationDesc 中。如果索引不可用,会报告错误。
  9. 初始化索引扫描键,包括运行时扫描键排序键,这些扫描键用于索引扫描操作
  10. 为运行时键初始化表达式上下文(ExprContext),以便在每个元组上重置表达式。
  11. 如果是分区表,会初始化分区表的相关信息,包括表分区索引分区的列表等。
  12. 初始化索引扫描描述符(ScanDesc),用于执行索引扫描
  13. 如果没有运行时键需要计算,直接将扫描键传递给索引 AM(Access Method)
  14. 准备就绪后,返回 index_state 结构体,完成初始化过程

  其中,以下代码是在为执行索引扫描初始化表达式上下文和子表达式。

 index_state->ss.ps.targetlist = (List*)ExecInitExpr((Expr*)node->scan.plan.targetlist, (PlanState*)index_state);
 index_state->ss.ps.qual = (List*)ExecInitExpr((Expr*)node->scan.plan.qual, (PlanState*)index_state);
 index_state->indexqualorig = (List*)ExecInitExpr((Expr*)node->indexqualorig, (PlanState*)index_state);

  具体来说:

  1. index_state->ss.ps.targetlist 是用于存储查询目标的列表。通过 ExecInitExpr 函数初始化,该函数会遍历并初始化查询计划中的目标表达式。这是为了准备执行查询时需要的目标列表
  2. index_state->ss.ps.qual 是用于存储查询的限制条件的列表。同样,通过 ExecInitExpr 函数初始化,它初始化了查询计划中的限制条件表达式。这是为了在执行查询时对结果进行筛选
  3. index_state->indexqualorig 是用于存储索引扫描的原始限制条件的列表。同样,通过 ExecInitExpr 函数初始化,它初始化了索引扫描计划中的原始限制条件表达式。这是为了执行索引扫描时用于构建索引扫描键

IndexScan 结构

  ExecInitIndexScan 函数入参包含了一个名为 IndexScan 的数据结构,用于表示索引扫描节点的属性和特性。下面是各字段的解释:(路径:src/include/nodes/plannodes.h

/* ----------------
 *		index scan node
 *		索引扫描节点
 *
 * indexqualorig is an implicitly-ANDed list of index qual expressions, each
 * in the same form it appeared in the query WHERE condition.  Each should
 * be of the form (indexkey OP comparisonval) or (comparisonval OP indexkey).
 * The indexkey is a Var or expression referencing column(s) of the index's
 * base table. The comparisonval might be any expression, but it won't use
 * any columns of the base table. The expressions are ordered by index
 * column position (but items referencing the same index column can appear
 * in any order). indexqualorig is used at runtime only if we have to recheck
 * a lossy indexqual.
 * indexqualorig 是一个隐式的AND逻辑与操作列表,包含了索引限制条件表达式,每个表达式的形式与其在查询的 WHERE 条件中出现的形式相同。
 * 每个表达式应该是 "(indexkey OP comparisonval)" 或 "(comparisonval OP indexkey)" 的形式。
 * indexkey 是引用索引基表的列的 Var 或表达式。comparisonval 可以是任何表达式,但不会使用基表的列。
 * 这些表达式按照索引列的位置进行排序(但引用相同索引列的项可以以任何顺序出现)。
 * indexqualorig 仅在运行时用于重新检查有损失的索引限制条件。
 *
 * indexqual has the same form, but the expressions have been commuted if
 * necessary to put the indexkeys on the left, and the indexkeys are replaced
 * by Var nodes identifying the index columns (their varno is INDEX_VAR and
 * their varattno is the index column number).
 * indexqual 具有相同的形式,但如果需要,表达式已经被换位,将索引键放在左边,并用标识索引列的 Var 节点替换索引键(它们的 varno 是 INDEX_VAR,varattno 是索引列的编号)。
 *
 * indexorderbyorig is similarly the original form of any ORDER BY expressions
 * that are being implemented by the index, while indexorderby is modified to
 * have index column Vars on the left-hand side. Here, multiple expressions
 * must appear in exactly the ORDER BY order, and this is not necessarily the
 * index column order. Only the expressions are provided, not the auxiliary
 * sort-order information from the ORDER BY SortGroupClauses; it's assumed
 * that the sort ordering is fully determinable from the top-level operators.
 * indexorderbyorig is unused at run time, but is needed for EXPLAIN.
 * (Note these fields are used for amcanorderbyop cases, not amcanorder cases.)
 * indexorderbyorig 是类似地处理索引实现的 ORDER BY 表达式的原始形式,而 indexorderby 已经修改为具有索引列 Var 在左侧的形式。
 * 在这里,必须以与 ORDER BY 顺序完全相同的顺序出现多个表达式,这不一定是索引列的顺序。
 * 只提供表达式,不提供来自 ORDER BY SortGroupClauses 的辅助排序信息;假定排序顺序可以从顶级操作符中完全确定。
 * indexorderbyorig 在运行时不使用,但在 EXPLAIN 中需要。
 * (注意,这些字段用于 amcanorderbyop 情况,而不是 amcanorder 情况。)
 *
 * indexorderdir specifies the scan ordering, for indexscans on amcanorder
 * indexes (for other indexes it should be "don't care").
 * indexorderdir 指定索引扫描的顺序,用于 amcanorder 索引(对于其他索引,它应该是 "不关心")。
 * ----------------
 */
typedef struct IndexScan {
    Scan scan;                  /* 继承自Scan结构的通用扫描节点信息 */
    Oid indexid;                 /* 要扫描的索引的对象标识符(OID) */
    char* indexname;             /* 要扫描的索引的名称 */
    List* indexqual;             /* 索引扫描的限制条件列表,通常是操作表达式(OpExprs)的形式 */
    List* indexqualorig;         /* 与indexqual相同的限制条件列表,但保留了原始的形式 */
    List* indexorderby;          /* 索引扫描的ORDER BY表达式列表 */
    List* indexorderbyorig;      /* 与indexorderby相同的ORDER BY表达式列表,但保留了原始的形式 */
    ScanDirection indexorderdir; /* 指定索引扫描的排序方向,可以是前向、后向或不关心 */
    bool usecstoreindex;         /* 用于标记是否为列式存储索引 */
    Index indexscan_relid;       /* 用于处理列式存储索引的特殊情况,将索引视为普通关系 */
    List* idx_cstorequal;        /* 仅包含可以推送到存储引擎的列式存储索引的条件 */
    List* cstorequal;            /* 可以推送到列式存储基表的条件列表 */
    List* targetlist;            /* 用于列式存储索引的特殊情况,表示在此节点计算的目标列表 */
    bool index_only_scan;       /* 表示是否仅进行索引扫描,而不需要访问基表数据 */
} IndexScan;

  调试信息如下所示:

(gdb) p * node
$6 = {scan = {plan = {type = T_IndexScan, plan_node_id = 1, parent_node_id = 0, exec_type = EXEC_ON_DATANODES, startup_cost = 0,
      total_cost = 8.2675800000000006, plan_rows = 1, multiple = 1, plan_width = 100, dop = 1, pred_rows = 0, pred_startup_time = 0, pred_total_time = 0,
      pred_max_memory = 0, recursive_union_plan_nodeid = 0, recursive_union_controller = false, control_plan_nodeid = 0, is_sync_plannode = false,
      targetlist = 0x7fd1da6868f8, qual = 0x0, lefttree = 0x0, righttree = 0x0, ispwj = false, paramno = -1, initPlan = 0x0, distributed_keys = 0x0,
      exec_nodes = 0x7fd1da686ee0, extParam = 0x0, allParam = 0x0, vec_output = false, hasUniqueResults = false, isDeltaTable = false, operatorMemKB = {0, 0},
      operatorMaxMem = 0, parallel_enabled = false, hasHashFilter = false, var_list = 0x0, filterIndexList = 0x0, ng_operatorMemKBArray = 0x0, ng_num = 0,
      innerdistinct = 0, outerdistinct = 0}, scanrelid = 1, isPartTbl = false, itrs = 0, pruningInfo = 0x0, bucketInfo = 0x0,
    partScanDirection = NoMovementScanDirection, scan_qual_optimized = false, predicate_pushdown_optimized = false, tablesample = 0x0, mem_info = {opMem = 0,
      minMem = 0, maxMem = 0, regressCost = 0}}, indexid = 65644, indexname = 0x0, indexqual = 0x7fd1da687008, indexqualorig = 0x7fd1da687328,
  indexorderby = 0x0, indexorderbyorig = 0x0, indexorderdir = ForwardScanDirection, usecstoreindex = false, indexscan_relid = 0, idx_cstorequal = 0x0,
  cstorequal = 0x0, targetlist = 0x0, index_only_scan = false}

ExecIndexScan 函数

  ExecIndexScan 函数用于执行索引扫描操作,其主要功能包括处理运行时索引扫描键,特殊处理分区表,并执行实际的索引扫描操作,以获取匹配的元组。ExecIndexScan 函数源码如下所示:(路径:src/gausskernel/runtime/executor/nodeIndexscan.cpp

/* ----------------------------------------------------------------
 *		ExecIndexScan(node)
 * ----------------------------------------------------------------
 */
TupleTableSlot* ExecIndexScan(IndexScanState* node)
{
    /*
     * 如果我们有运行时密钥,但它们还没有设置,那么现在就执行。
     */
    if (node->iss_NumRuntimeKeys != 0 && !node->iss_RuntimeKeysReady) {
        /*
         * 为分区表设置一个标志,这样我们就可以专门处理它
         * 当我们重新扫描分区表时
         */
        if (node->ss.isPartTbl) {
            if (PointerIsValid(node->ss.partitions)) {
                node->ss.ss_ReScan = true;  // 标记为需要重新扫描
                ExecReScan((PlanState*)node);  // 执行重新扫描操作
            }
        } else {
            ExecReScan((PlanState*)node);  // 执行重新扫描操作
        }
    }

	// 调用ExecScan函数执行索引扫描,获取下一个匹配的元组,并执行重检查操作
    return ExecScan(&node->ss, (ExecScanAccessMtd)IndexNext, (ExecScanRecheckMtd)IndexRecheck); 
}

  这里打印 node 的内容如下所示:

(gdb) p *node
$9 = {ss = {ps = {type = T_IndexScanState, plan = 0x7fd1da6866d0, state = 0x7fd1debdc060, instrument = 0x7fd1ded08968, targetlist = 0x7fd1dece8778, qual = 0x0,
      lefttree = 0x0, righttree = 0x0, initPlan = 0x0, subPlan = 0x0, chgParam = 0x0, hbktScanSlot = {currSlot = 0}, ps_ResultTupleSlot = 0x7fd1dece9548,
      ps_ExprContext = 0x7fd1dece8288, ps_ProjInfo = 0x0, ps_TupFromTlist = false, vectorized = false, nodeContext = 0x7fd1dece3c68, earlyFreed = false,
      stubType = 0 '\000', jitted_vectarget = 0x0, plan_issues = 0x0, recursive_reset = false, qual_is_inited = false, ps_rownum = 1},
    ss_currentRelation = 0x7fd1da6188d8, ss_currentScanDesc = 0x0, ss_ScanTupleSlot = 0x7fd1dece9720, ss_ReScan = false, ss_currentPartition = 0x0,
    isPartTbl = false, currentSlot = 0, partScanDirection = ForwardScanDirection, partitions = 0x0, lockMode = 0, runTimeParamPredicates = 0x0,
    runTimePredicatesReady = false, is_scan_end = false, ss_scanaccessor = 0x0, part_id = 0, startPartitionId = 0, endPartitionId = 0, rangeScanInRedis = {
      isRangeScanInRedis = 0 '\000', sliceTotal = 0 '\000', sliceIndex = 0 '\000'}, isSampleScan = false, sampleScanInfo = {args = 0x0, repeatable = 0x0,
      sampleType = SYSTEM_SAMPLE, tsm_state = 0x0}, ScanNextMtd = 0x0}, indexqualorig = 0x7fd1dece94e0, iss_ScanKeys = 0x7fd1ded084d0, iss_NumScanKeys = 1,
  iss_OrderByKeys = 0x7fd1ded08660, iss_NumOrderByKeys = 0, iss_RuntimeKeys = 0x0, iss_NumRuntimeKeys = 0, iss_RuntimeKeysReady = false,
  iss_RuntimeContext = 0x0, iss_RelationDesc = 0x7fd1da6150f8, iss_ScanDesc = 0x7fd1e010f060, iss_IndexPartitionList = 0x0, lockMode = 0,
  iss_CurrentIndexPartition = 0x0, part_id = 0}

ExecScan 函数

  ExecScan 函数是一个通用的扫描函数,它用于处理各种扫描节点,如 SeqScanIndexScan。其中,我们在【OpenGauss源码学习 —— 执行算子(SeqScan算子)】
一文中已经对 ExecScan 函数进行了介绍。ExecScan 在处理不同类型的扫描节点时,调用具体的扫描方法(如 ExecSeqScanExecIndexScan),并在需要的情况下执行条件检查和投影。这种分层的设计使得能够共享通用的扫描逻辑,同时在特定扫描节点中实现特定的操作。

ExecScan 函数是一个通用的扫描函数,它接受以下参数:

  1. node(类型:ScanState*):表示正在执行的扫描节点的状态信息。这个参数包括有关扫描节点的各种信息,如扫描的条件扫描方向投影信息等。
  2. access_mtd(类型:ExecScanAccessMtd)表示用于访问扫描的访问方法access method。这是一个函数指针,指向用于从扫描操作中获取下一个满足条件的元组的方法。
  3. recheck_mtd(类型:ExecScanRecheckMtd)表示用于重新检查元组是否满足条件的方法。这是一个函数指针,指向用于在扫描中检查元组是否满足附加条件的方法。

  其中,ExecScanAccessMtdExecScanRecheckMtd 的定义如下:(路径:src/include/nodes/execnodes.h

/*
 * prototypes from functions in execScan.c
 */
typedef TupleTableSlot *(*ExecScanAccessMtd) (ScanState *node);
typedef bool(*ExecScanRecheckMtd) (ScanState *node, TupleTableSlot *slot);

以上段代码是函数原型的声明,包括两种函数指针类型的声明:

  1. ExecScanAccessMtd 是一个函数指针类型,用于表示访问方法(access method。它表示一种函数,该函数接受一个名为 node 的参数,类型为 ScanState*,并返回一个指向 TupleTableSlot 类型的指针。这个函数用于从扫描节点中获取下一个满足条件的元组
  2. ExecScanRecheckMtd 是另一种函数指针类型,用于表示重新检查方法(recheck method。它表示一种函数,该函数接受两个参数,一个是 node,类型为 ScanState*,另一个是 slot,类型为 TupleTableSlot*。这个函数用于检查在访问方法获取的元组是否满足附加的重新检查条件

  ExecScan 函数源码如下:(路径:src/gausskernel/runtime/executor/execScan.cpp

/*
 * ExecScan
 * 
 * 扫描关系使用指定的访问方法,并根据全局变量 ExecDirection 返回下一个满足条件的元组。该访问方法返回下一个元组,而 execScan() 负责检查返回的元组是否满足条件表达式。
 * 
 * 还必须提供一个“重新检查方法”,它可以检查关系的任何内部访问方法中实现的条件表达式对任意元组的条件是否成立。
 * 
 * 条件:
 * -- 由AMI维护的“光标”位于先前返回的元组之前。
 * 
 * 初始状态:
 * -- 表示进行扫描的关系已打开,使“光标”定位在第一个满足条件的元组之前。
 */
TupleTableSlot* ExecScan(ScanState* node, ExecScanAccessMtd access_mtd, ExecScanRecheckMtd recheck_mtd)
{
    ExprContext* econtext = NULL;
    List* qual = NIL;
    ProjectionInfo* proj_info = NULL;
    ExprDoneCond is_done;
    TupleTableSlot* result_slot = NULL;

    if (node->isPartTbl && !PointerIsValid(node->partitions))
        return NULL;

    /*
     * 从节点中获取数据
     */
    qual = node->ps.qual;
    proj_info = node->ps.ps_ProjInfo;
    econtext = node->ps.ps_ExprContext;

    /*
     * 如果既没有要检查的条件也没有要执行的投影,那么跳过所有开销并返回原始的扫描元组。
     */
    if (qual == NULL && proj_info == NULL) {
        ResetExprContext(econtext);
        return ExecScanFetch(node, access_mtd, recheck_mtd);
    }

    /*
     * 检查我们是否仍然从上一个扫描元组中投影出元组(因为投影表达式中有一个返回集的函数)。如果是的话,尝试投影另一个。
     */
    if (node->ps.ps_TupFromTlist) {
        Assert(proj_info); /* 如果没有投影,不可能到达这里 */
        result_slot = ExecProject(proj_info, &is_done);
        if (is_done == ExprMultipleResult)
            return result_slot;
        /* 处理完当前源元组后... */
        node->ps.ps_TupFromTlist = false;
    }

    /*
     * @hdfs
     * 通过使用信息约束优化扫描。
     * 如果 is_scan_end 为 true,则扫描结束。
     */
    if (node->is_scan_end) {
        return NULL;
    }

    /*
     * 重置每个元组内存上下文,以释放在上一个元组周期中分配的表达式计算存储。请注意,直到我们完成了从扫描元组中投影出元组才能发生这种情况。
     */
    ResetExprContext(econtext);

    /*
     * 从访问方法中获取一个元组。循环直到获取一个满足条件的元组。
     */
    for (;;) {
        TupleTableSlot* slot = NULL;

        CHECK_FOR_INTERRUPTS();

        slot = ExecScanFetch(node, access_mtd, recheck_mtd);
        /* 每次循环刷新条件表达式 */
        qual = node->ps.qual;
        /*
         * 如果由 accessMtd 返回的元组中包含 NULL,那么意味着没有更多的内容进行扫描,因此我们只返回一个空元组,务必使用投影结果元组槽,以便具有正确的 tupleDesc。
         */
        if (TupIsNull(slot) || unlikely(executorEarlyStop())) {
            if (proj_info != NULL)
                return ExecClearTuple(proj_info->pi_slot);
            else
                return slot;
        }

        /*
         * 将当前元组放入表达式上下文
         */
        econtext->ecxt_scantuple = slot;

        /*
         * 检查当前元组是否满足条件表达式
         *
         * 在这里检查非空条件,以避免在条件为空时调用 ExecQual() 的函数调用 ... 节省了一些循环周期,但它们会累积...
         */
        if (qual == NULL || ExecQual(qual, econtext, false)) {
            /*
             * 找到满足条件的扫描元组。
             */
            if (proj_info != NULL) {
                /*
                 * 形成一个投影元组,将其存储在结果元组槽中并返回它 --- 除非我们发现我们无法从这个扫描元组中投影出元组,如果是这样,那么继续扫描。
                 */
                result_slot = ExecProject(proj_info, &is_done);
#ifdef PGXC
                /* 如果底层扫描槽具有xcnodeoid,则复制xcnodeoid */
                result_slot->tts_xcnodeoid = slot->tts_xcnodeoid;
#endif /* PGXC */
                if (is_done != ExprEndResult) {
                    node->ps.ps_TupFromTlist = (is_done == ExprMultipleResult);

                    /*
                     * @hdfs
                     * 通过使用信息约束优化外部扫描。
                     */
                    if (IsA(node->ps.plan, ForeignScan)) {
                        ForeignScan* foreign_scan = (ForeignScan*)(node->ps.plan);
                        if (foreign_scan->scan.scan_qual_optimized) {
                            /*
                             * 如果找到合适的元组,则将 is_scan_end 设置为 true。
                             * 这意味着如果在下一个迭代中找不到合适的元组,迭代结束。
                             */
                            node->is_scan_end = true;
                        }
                    }
                    return result_slot;
                }
            } else {
                /*
                 * 通过使用信息约束优化外部扫描。
                 */
                if (IsA(node->ps.plan, ForeignScan)) {
                    ForeignScan* foreign_scan = (ForeignScan*)(node->ps.plan);
                    if (foreign_scan->scan.scan_qual_optimized) {
                        /*
                         * 如果找到合适的元组,则将 is_scan_end 设置为 true。
                         * 这意味着如果在下一个迭代中找不到合适的元组,迭代结束。
                         */
                        node->is_scan_end = true;
                    }
                }
                /*
                 * 在这里,我们没有进行投影,因此只需返回扫描元组。
                 */
                return slot;
            }
        } else
            InstrCountFiltered1(node, 1);

        /*
         * 元组不满足条件,因此释放每个元组内存并重试。
         */
        ResetExprContext(econtext);
    }
}

ExecScanFetch 函数

  ExecScanFetch 函数在【OpenGauss源码学习 —— 执行算子(SeqScan算子)】章节已经做过了描述,这里可以再简单赘述一下。在执行查询计划时,扫描节点会通过 ExecScan 函数调用 ExecScanFetch 函数来获取符合条件的元组,然后再进行投影过滤等操作。
  ExecScanFetch 函数的核心任务是从关系中获取下一个元组,以及根据重新检查方法检查该元组是否满足内部的限定条件。这个函数的逻辑会根据当前的扫描方向(正向或反向)和访问方法的实现来决定如何获取下一个元组。 ExecScanFetch 函数源码如下:(路径:src/gausskernel/runtime/executor/execScan.cpp

/*
 * ExecScanFetch -- 获取下一个潜在元组
 *
 * 这个函数主要用于在 EvalPlanQual 重新检查时替换测试元组。
 * 如果不是在 EvalPlanQual 中,就执行访问方法的下一个元组操作。
 */
static TupleTableSlot* ExecScanFetch(ScanState* node, ExecScanAccessMtd access_mtd, ExecScanRecheckMtd recheck_mtd)
{
	// 从node的执行状态结构体中获取执行状态estate
    EState* estate = node->ps.state;

	// 检查是否存在 EvalPlanQual(执行计划中的计划块),即是否正在执行 EvalPlanQual 重新检查
    if (estate->es_epqTuple != NULL) {
        /*
         * 我们在 EvalPlanQual 重新检查中。如果有测试元组可用,就在重新检查任何访问方法特定条件后返回该测试元组。
         */
        Index scan_rel_id = ((Scan*)node->ps.plan)->scanrelid;

        Assert(scan_rel_id > 0);
        if (estate->es_epqTupleSet[scan_rel_id - 1]) {
            TupleTableSlot* slot = node->ss_ScanTupleSlot;

            /* 如果我们已经返回了一个元组,则返回空槽 */
            if (estate->es_epqScanDone[scan_rel_id - 1])
                return ExecClearTuple(slot);
            /* 否则标记以记住不应该再返回更多元组 */
            estate->es_epqScanDone[scan_rel_id - 1] = true;

            /* 如果没有测试元组,就返回空槽 */
            if (estate->es_epqTuple[scan_rel_id - 1] == NULL)
                return ExecClearTuple(slot);

            /* 将测试元组存储在计划节点的扫描槽中 */
            (void)ExecStoreTuple(estate->es_epqTuple[scan_rel_id - 1], slot, InvalidBuffer, false);

            /* 检查是否满足访问方法的条件 */
            if (!(*recheck_mtd)(node, slot))
                (void)ExecClearTuple(slot); /* 不会被扫描返回 */

            return slot;
        }
    }

    /*
     * 运行节点类型特定的访问方法函数来获取下一个元组
     */
    return (*access_mtd)(node);
}

  在这里, (*access_mtd)(node) 在 SeqScan 算子中调用的是 SeqNext 函数,而这里则调用的是 IndexNext 函数。

IndexNext 函数

  IndexNext 函数是一个索引扫描节点的核心功能,它从当前关系中使用索引获取下一个符合条件的元组。它在执行期间负责与索引交互,检查元组是否满足索引条件,并返回满足条件的元组,或者指示索引扫描结束。函数还包括索引条件的重新检查,以确保不会漏掉任何符合条件的元组。函数源码如下:(路径:src/gausskernel/runtime/executor/nodeIndexscan.cpp

static TupleTableSlot* IndexNext(IndexScanState* node)
{
    EState* estate = NULL;       // 获取执行上下文
    ExprContext* econtext = NULL; // 获取表达式上下文
    ScanDirection direction;     // 扫描方向
    IndexScanDesc scandesc;      // 索引扫描描述符
    HeapTuple tuple;             // 用于存储获取的元组
    TupleTableSlot* slot = NULL; // 用于存储元组的槽

    /*
     * 从索引扫描节点中提取必要的信息
     */
    estate = node->ss.ps.state;     // 获取执行状态
    direction = estate->es_direction; // 获取执行方向

    // 如果索引扫描需要反向扫描,但是当前方向是正向扫描,则将扫描方向设为反向
    if (ScanDirectionIsBackward(((IndexScan*)node->ss.ps.plan)->indexorderdir)) {
        if (ScanDirectionIsForward(direction))
            direction = BackwardScanDirection;
        else if (ScanDirectionIsBackward(direction))
            direction = ForwardScanDirection;
    }

    scandesc = node->iss_ScanDesc;  // 获取索引扫描描述符
    econtext = node->ss.ps.ps_ExprContext; // 获取表达式上下文
    slot = node->ss.ss_ScanTupleSlot;  // 获取元组槽

    /*
     * 现在,我们拥有了所需的信息,可以获取下一个元组。
     */
    while ((tuple = scan_handler_idx_getnext(scandesc, direction)) != NULL) {
        IndexScanDesc index_scan = GetIndexScanDesc(scandesc); // 获取索引扫描描述符

        /*
         * 将扫描到的元组存储在扫描状态的元组槽中。
         * 注意:我们传递 'false',因为amgetnext返回的元组是指向磁盘页的指针,不应该pfree_ext()。
         */

        // 将扫描到的元组存储到元组槽中
        (void)ExecStoreTuple(tuple, slot, index_scan->xs_cbuf, false);

        /*
         * 如果索引是有损的,需要使用索引条件再次检查获取的元组。
         */

        // 如果索引是有损的,使用获取的元组重新检查索引条件
        if (index_scan->xs_recheck) {
            econtext->ecxt_scantuple = slot; // 设置表达式上下文的扫描元组
            ResetExprContext(econtext);      // 重置表达式上下文
            if (!ExecQual(node->indexqualorig, econtext, false)) {
                /* 未通过条件的元组,跳过并继续获取下一个 */
                InstrCountFiltered2(node, 1); // 记录未通过条件的元组
                continue; // 跳过当前元组并继续获取下一个元组
            }
        }

        return slot; // 返回获取的元组
    }

    /*
     * 如果代码执行到这里,表示索引扫描结束,返回空的元组槽。
     */

    // 返回一个空的元组槽,表示索引扫描结束
    return ExecClearTuple(slot);
}

  调试获取的索引扫描描述符如下所示:

$3 = {heapRelation = 0x7fe17b3bded8, indexRelation = 0x7fe17b3c1b18, xs_gpi_scan = 0x0, xs_snapshot = 0x7fe17eebc620, numberOfKeys = 1, numberOfOrderBys = 0,
  keyData = 0x7fe180bb06d0, orderByData = 0x0, xs_want_itup = false, xs_want_ext_oid = false, kill_prior_tuple = false, ignore_killed_tuples = true,
  xactStartedInRecovery = false, opaque = 0x7fe17ef40060, xs_itup = 0x0, xs_itupdesc = 0x7fe17b3c1e68, xs_ctup = {tupTableType = 1 '\001', t_bucketId = -23131,
    t_len = 2779096485, t_self = {ip_blkid = {bi_hi = 65535, bi_lo = 65535}, ip_posid = 0}, t_tableOid = 2779096485, t_xid_base = 11936128518282651045,
    t_multi_base = 11936128518282651045, t_xc_node_id = 2779096485, t_data = 0x0}, xs_cbuf = 0, xs_recheck = 165, xs_continue_hot = false,
  xs_heapfetch = 0x7fe180bb0920, xs_ctbuf_hdr = {t_choice = {t_heap = {t_xmin = 2779096485, t_xmax = 2779096485, t_field3 = {t_cid = 2779096485,
          t_xvac = 2779096485}}, t_datum = {datum_len_ = -1515870811, datum_typmod = -1515870811, datum_typeid = 2779096485}}, t_ctid = {ip_blkid = {
        bi_hi = 42405, bi_lo = 42405}, ip_posid = 42405}, t_infomask2 = 42405, t_infomask = 42405, t_hoff = 165 '\245',
    t_bits = 0x7fe180aaf117 '\245' <repeats 200 times>...}}

  在 SeqScan 算子中,使用 scan_handler_tbl_getnext 函数获取下一个符合扫描条件的元组。而在 IndexScan 算子中,则使用 scan_handler_idx_getnext 函数来获取下一个符合扫描条件的元组
  scan_handler_tbl_getnextscan_handler_idx_getnext 函数都用于从不同的访问方法AM)中获取下一个元组。这两个函数在不同的扫描情景中被调用,根据执行计划访问方法的不同,选择调用适当的函数来获取下一个元组。它们的具体实现可能会有所不同,因为表扫描和索引扫描的底层操作是不同的。但它们的共同目标是获取下一个符合条件的元组,以满足查询的需求。

IndexScanDescData 结构体

  IndexScanDescData 结构体用于表示索引扫描的参数和状态信息,包括堆关系索引关系筛选条件排序条件等等。其中, IndexScanDescData结构体主要由访问方法AM)和索引扫描代码内部使用,而 IndexScan 结构体则用于执行计划节点的表示和执行IndexScanDescData 结构体源码如下所示(路径:src/include/access/relscan.h

/*
 * 我们为基于amgettuple和基于amgetbitmap的索引扫描都使用相同的IndexScanDescData结构。
 * 某些字段仅在基于amgettuple的扫描中相关。
 */
typedef struct IndexScanDescData {
    /* 扫描参数 */
    // !! heapRelation 必须是第一个成员 !!
    Relation heapRelation;      /* 堆关系描述符,或为NULL */

    Relation indexRelation;     /* 索引关系描述符 */
    GPIScanDesc xs_gpi_scan;    /* 全局分区索引扫描使用的信息 */
    Snapshot xs_snapshot;       /* 用于查看的快照 */
    int numberOfKeys;           /* 索引筛选条件的数量 */
    int numberOfOrderBys;       /* 排序操作符的数量 */
    ScanKey keyData;            /* 索引筛选条件描述符数组 */
    ScanKey orderByData;        /* 排序操作描述符数组 */
    bool xs_want_itup;          /* 调用者请求索引元组 */
    bool xs_want_ext_oid;       /* 全局分区索引需要分区OID */

    /* 向索引AM发送关于终止索引元组的信号 */
    bool kill_prior_tuple;      /* 上次返回的元组已被删除 */
    bool ignore_killed_tuples;  /* 不返回已删除的条目 */
    bool xactStartedInRecovery; /* 防止删除/查看已删除元组 */

    /* 索引访问方法的私有状态 */
    void* opaque;               /* 访问方法特定的信息 */

    /* 在仅索引扫描中,成功执行amgettuple后,这些字段有效 */
    IndexTuple xs_itup;         /* AM返回的索引元组 */
    TupleDesc xs_itupdesc;      /* xs_itup的行类型描述符 */

    /* 在成功的index_getnext后,xs_ctup/xs_cbuf/xs_recheck字段有效 */
    HeapTupleData xs_ctup;       /* 当前堆元组(如果有) */
    Buffer xs_cbuf;             /* 扫描中的当前堆缓冲区(如果有) */
    /* 注意:如果xs_cbuf不是InvalidBuffer,则我们在该缓冲区上持有锁 */
    bool xs_recheck;             /* T表示需要重新检查扫描键 */

    /* 在index_getnext中遍历HOT链的状态数据 */
    bool xs_continue_hot;         /* T表示必须继续遍历HOT链 */
    IndexFetchTableData *xs_heapfetch;
    /* 将解压后的堆元组数据放入xs_ctbuf_hdr中,注意!在分配内存时,应为xs_ctbuf_hdr提供额外的内存。
     * xs_ctbuf_hdr包含t_bits字段,它是可变长度数组 */
    HeapTupleHeaderData xs_ctbuf_hdr;
    /* 请勿在此处添加任何其他成员。xs_ctbuf_hdr必须是最后一个成员。 */
} IndexScanDescData;
*/

ExecEndIndexScan 函数

  ExecEndIndexScan 函数主要完成了以下工作:

  1. 释放索引扫描过程中的资源,包括表达式上下文和元组插槽。
  2. 关闭索引关系,释放索引扫描描述符。
  3. 如果表是分区表,还需要关闭索引分区和表分区的资源。
  4. 最后,关闭堆关系,完成索引扫描的结束操作。

ExecEndIndexScan 函数源码如下所示:(路径:  src/gausskernel/runtime/executor/nodeIndexscan.cpp

/*
 * ExecEndIndexScan
 * - 用于结束索引扫描操作的函数
 */
void ExecEndIndexScan(IndexScanState* node)
{
    Relation index_relation_desc;    // 索引关系描述符
    IndexScanDesc index_scan_desc;   // 索引扫描描述符
    Relation relation;               // 表关系描述符

    /*
     * 从节点中提取相关信息
     */
    index_relation_desc = node->iss_RelationDesc;  // 获取索引关系描述符
    index_scan_desc = node->iss_ScanDesc;          // 获取索引扫描描述符
    relation = node->ss.ss_currentRelation;       // 获取当前的表关系描述符

    /*
     * 释放表达式上下文(现在是死代码,参见ExecFreeExprContext)
     */
#ifdef NOT_USED
    ExecFreeExprContext(&node->ss.ps);
    if (node->iss_RuntimeContext)
        FreeExprContext(node->iss_RuntimeContext, true);
#endif

    /*
     * 清空元组表插槽
     */
    (void)ExecClearTuple(node->ss.ps.ps_ResultTupleSlot);   // 清空结果元组插槽
    (void)ExecClearTuple(node->ss.ss_ScanTupleSlot);        // 清空扫描元组插槽

    /*
     * 关闭索引关系(如果已经打开的话)
     */
    if (index_scan_desc)
        scan_handler_idx_endscan(index_scan_desc);

    /*
     * 关闭索引关系(如果已经打开的话)
     * 如果关系是非分区表,则关闭索引关系
     * 如果关系是非分区表,则关闭索引分区和表分区
     */
    if (node->ss.isPartTbl) {
        if (PointerIsValid(node->iss_IndexPartitionList)) {
            Assert(PointerIsValid(index_relation_desc));
            Assert(PointerIsValid(node->ss.partitions));
            Assert(node->ss.partitions->length == node->iss_IndexPartitionList->length);

            Assert(PointerIsValid(node->iss_CurrentIndexPartition));
            releaseDummyRelation(&(node->iss_CurrentIndexPartition));

            Assert(PointerIsValid(node->ss.ss_currentPartition));
            releaseDummyRelation(&(node->ss.ss_currentPartition));

            /* 关闭索引分区 */
            releasePartitionList(node->iss_RelationDesc, &(node->iss_IndexPartitionList), NoLock);

            /* 关闭表分区 */
            releasePartitionList(node->ss.ss_currentRelation, &(node->ss.partitions), NoLock);
        }
    }

    if (index_relation_desc)
        index_close(index_relation_desc, NoLock);

    /*
     * 关闭堆关系
     */
    ExecCloseScanRelation(relation);
}

总结

  IndexScan 是一种查询计划节点,用于执行索引扫描操作。它通常用于加速基于查询条件的数据检索操作,特别是在大型数据表中。IndexScan 算子通常用于 SELECT 查询操作,特别是在需要快速检索数据的情况下,或需要按特定顺序排序数据时。本文仅作简单介绍,其中个别函数可能未涉及,感兴趣的读者可以自行阅读源码。

你可能感兴趣的:(OpenGauss,gaussdb,数据库)