执行算子(SeqScan算子)
- 执行算子概述
- 扫描算子
- SeqScan算子
- ExecInitSeqScan函数
- InitScanRelation函数
- ExecSeqScan函数
- 总结
声明:本文的部分内容参考了他人的文章。在编写过程中,我们尊重他人的知识产权和学术成果,力求遵循合理使用原则,并在适用的情况下注明引用来源。
本文主要参考了 OpenGauss1.1.0 的开源代码和《OpenGauss数据库源码解析》一书
执行算子概述
在OpenGauss中,执行算子是用于执行数据库查询计划的基本操作单元。执行算子负责处理查询计划中的每个节点,执行各种操作,从而实现用户查询的功能。
执行算子模块包含多种计划执行算子,是计划执行的独立单元,用于实现具体的计划动作。执行计划包含4类算子,分别是控制算子、扫描算子、物化算子和连接算子。这些算子统一使用节点(node)表示,具有统一的接口,执行流程采用递归模式。整体执行流程是:首先根据计划节点的类型初始化状态节点(函数名为“ExecInit+算子名”),然后再回调执行函数(函数名为“Exec+算子名”),最后是清理状态节点(函数名为“ExecEnd+算子名”)。本文主要介绍扫描算子,因为最近在做这方面的工作。
执行算子类型如下所示:
算子类型 | 说 明 |
---|---|
控制算子 | 处理特殊执行流程,如Union语句 |
扫描算子 | 用于扫描表对象,从表中获取数据 |
物化算子 | 缓存中间执行结果到临时存储 |
连接算子 | 用于实现SQL中的各类连接操作,通常包含 nested loop join、bash join、merge-sort join等 |
扫描算子
扫描算子用于表、结果集、链表子查询等结果遍历,每次获取一条元组作为上层节点的输入。控制算子中的 BitmapAnd/BitmapOr 函数所需的位图与扫描算子(索引扫描算子)密切相关。主要包括顺序扫描(SeqScan)、索引扫描(IndexScan)、位图扫描(BitmapHeapScan)、位图索引扫描(BitmapIndexScan)、元组TID扫描(TIDScan)、子查询扫描(SubqueryScan)、函数扫描(FunctionScan)等。扫描算子如下表所示。
算子名称 | 说 明 |
---|---|
SeqScan算子 | 用于扫描基础表 |
IndexScan算子 | 对表的扫描使用索引加速元组获取 |
BitmapIndexScan算子 | 通过位图索引做扫描操作 |
BitMapHeapScan算子 | 通过位图获取实际元组 |
TIDScan算子 | 遍历元组的物理存储位置获取一个元组 |
SubqueryScan算子 | 子查询生成的子执行计划 |
FunctionScan算子 | 用于从函数返回的数据集中获取元组 |
ValuesScan算子 | 用于处理“Values(···),(···),···”类型语句,从值列表中输出元组 |
CteScan算子 | 用于处理With表达式对应的子查询 |
WorkTableScan算子 | 用于递归工作表元组输出 |
PartIteratorScan算子 | 用于支持分区表的 wise join |
SeqScan算子
我们首先来看一下SeqScan算子,SeqScan 算子是最基本的扫描算子,对应 SeqScan 执行节点,对应的代码源文件是“src/gausskernel/runtime/executor/nodeSeqScan.cpp”
,用于对基础表做顺序扫描。算子对应的主要函数如下表所示。
主要函数 | 说 明 |
---|---|
ExecInitSeqScan | 初始化SeqScan状态节点 |
ExecSeqScan | 迭代获取元组 |
ExecEndSeqScan | 清理SeqScan状态节点 |
ExecSeqMarkPos | 标记扫描位置 |
ExecSeqRestrPos | 重置扫描位置 |
ExecReScanSeqScan | 重置SeqScan |
InitScanRelation | 初始化扫描表 |
下面我们以一个案例来调试一下代码吧,首先执行sql语句:
postgres=# create table t2 (id int not null, name varchar);
CREATE TABLE
postgres=# insert into t2 values(1, 'Postgres');
INSERT 0 1
postgres=# insert into t2 values(2, 'OpenGauss');
INSERT 0 1
postgres=# select * from t2;
ExecInitSeqScan函数
函数 ExecInitSeqScan 是 OpenGauss 数据库中用于初始化顺序扫描算子(SeqScan)的函数。它是执行计划中实际执行算子的初始化过程,用于准备执行算子所需的状态和资源。在函数 ExecInitSeqScan 中打上断点进行调试
我们来解读一下 ExecInitSeqScan 函数的源码吧:(路径:src/gausskernel/runtime/executor/nodeSeqscan.cpp
)
在ExecInitSeqScan 函数中,有三个入参SeqScan* node, EState* estate, int eflags
,下面分别来解释一下他们是什么:
- SeqScan* node:这是一个指向查询计划节点的指针,表示要初始化的顺序扫描算子节点。这个节点包含了构建查询计划所需的信息,如表信息、谓词条件等。
- EState* estate:这是一个指向执行状态的指针,表示当前查询的执行状态。执行状态包含了在查询执行过程中需要的各种上下文信息,如内存管理、参数信息等。
- int eflags:这是一个标志位,表示执行的选项和参数。可以用来控制执行算子的行为,如是否要在执行过程中收集统计信息等。
/* ----------------------------------------------------------------
* ExecInitSeqScan
* ----------------------------------------------------------------
*/
SeqScanState* ExecInitSeqScan(SeqScan* node, EState* estate, int eflags)
{
TableSampleClause* tsc = NULL; /* 表样本子句 */
/*
* 曾经有可能在 SeqScan 的 outerPlan 中,但现在不再允许。
*/
Assert(outerPlan(node) == NULL);
Assert(innerPlan(node) == NULL);
/*
* 创建状态结构体
*/
SeqScanState* scanstate = makeNode(SeqScanState);
scanstate->ps.plan = (Plan*)node; /* 计划节点 */
scanstate->ps.state = estate; /* 执行状态 */
scanstate->isPartTbl = node->isPartTbl; /* 是否是分区表 */
scanstate->currentSlot = 0; /* 当前迭代位置 */
scanstate->partScanDirection = node->partScanDirection; /* 分区扫描方向 */
scanstate->rangeScanInRedis = {false, 0, 0}; /* 分布时间的范围扫描 */
if (!node->tablesample) {
scanstate->isSampleScan = false; /* 是否是表样本扫描 */
} else {
scanstate->isSampleScan = true;
tsc = node->tablesample;
}
/*
* 杂项初始化
*
* 为节点创建表达式上下文
*/
ExecAssignExprContext(estate, &scanstate->ps);
/*
* 初始化子表达式
*/
scanstate->ps.targetlist = (List*)ExecInitExpr((Expr*)node->plan.targetlist, (PlanState*)scanstate);
// 判断是否需要进行表样本扫描,如果 node 结构中的 tablesample 成员非空,就说明需要进行表样本扫描
if (node->tablesample) {
scanstate->sampleScanInfo.args = (List*)ExecInitExpr((Expr*)tsc->args, (PlanState*)scanstate);
scanstate->sampleScanInfo.repeatable = ExecInitExpr(tsc->repeatable, (PlanState*)scanstate);
scanstate->sampleScanInfo.sampleType = tsc->sampleType; //设置表样本类型 sampleType
/*
* 初始化 RowTableSample
*/
if (scanstate->sampleScanInfo.tsm_state == NULL) {
scanstate->sampleScanInfo.tsm_state = New(CurrentMemoryContext) RowTableSample(scanstate);
}
}
/*
* 元组表初始化
*/
ExecInitResultTupleSlot(estate, &scanstate->ps);
ExecInitScanTupleSlot(estate, scanstate);
InitScanRelation(scanstate, estate);
ADIO_RUN()
{
/* 添加预取相关信息 */
scanstate->ss_scanaccessor = (SeqScanAccessor*)palloc(sizeof(SeqScanAccessor));
SeqScan_Init(scanstate->ss_currentScanDesc, scanstate->ss_scanaccessor, scanstate->ss_currentRelation);
}
ADIO_END();
/*
* 初始化扫描关系
*/
// 调用InitSeqNextMtd函数初始化ScanState结构体中的ScanNextMtd函数指针,用于执行具体的扫描操作。
InitSeqNextMtd(node, scanstate);
// 判断是否存在扫描描述符
if (IsValidScanDesc(scanstate->ss_currentScanDesc)) {
// 初始化并行扫描的参数,包括指定的并行度、分区扫描方向等
scan_handler_tbl_init_parallel_seqscan(scanstate->ss_currentScanDesc,
scanstate->ps.plan->dop, scanstate->partScanDirection);
} else {
scanstate->ps.stubType = PST_Scan;
}
// 表示扫描结果并不是直接从目标列表(即查询的目标列)中获取的,而是从其他地方获取的
scanstate->ps.ps_TupFromTlist = false;
/*
* 初始化结果元组类型和投影信息。
*/
ExecAssignResultTypeFromTL(
&scanstate->ps,
scanstate->ss_currentRelation->rd_tam_type);
ExecAssignScanProjectionInfo(scanstate);
return scanstate;
}
以为下SeqScan* node的结构,可以看到,执行节点(Node)中的 type 字段的值为 T_SeqScan ,这表示这个执行节点是一个顺序扫描(Sequential Scan)节点。
以为下EState* estate的结构,而 EState estate* 中的 type 字段的值为 T_EState,它表示这个结构体是一个执行状态(Execution State)的对象。在数据库查询执行过程中,EState 存储了查询执行过程中的中间状态。
可以看到,“ExecInitSeqScan” 函数的返回类型是 “SeqScanState” 结构,SeqScanState 结构体是 OpenGauss 数据库中用于执行顺序扫描操作的状态信息的结构体。在数据库查询执行过程中,每个执行节点都会有相应的状态结构体来存储节点执行过程中的中间状态、结果等信息。对于顺序扫描操作,就是使用 SeqScanState 结构体来存储顺序扫描的状态信息。
SeqScanState 结构体定义如下:(路径:src/include/nodes/execnodes.h
)
typedef struct ScanState {
PlanState ps; /* 计划状态,首字段是NodeTag */
Relation ss_currentRelation; /* 当前关系 */
TableScanDesc ss_currentScanDesc; /* 当前扫描描述符 */
TupleTableSlot* ss_ScanTupleSlot; /* 扫描元组插槽 */
bool ss_ReScan; /* 是否重扫描 */
Relation ss_currentPartition; /* 当前分区关系 */
bool isPartTbl; /* 是否是分区表 */
int currentSlot; /* 当前迭代位置 */
ScanDirection partScanDirection; /* 分区扫描方向 */
List* partitions; /* 分区列表 */
LOCKMODE lockMode; /* 锁模式 */
List* runTimeParamPredicates; /* 运行时参数谓词 */
bool runTimePredicatesReady; /* 运行时谓词是否准备好 */
bool is_scan_end; /* 是否结束扫描,用于扫描使用信息性约束的情况 */
SeqScanAccessor* ss_scanaccessor; /* 预取相关 */
int part_id; /* 分区ID */
int startPartitionId; /* 并行线程的起始分区ID */
int endPartitionId; /* 并行线程的结束分区ID */
RangeScanInRedis rangeScanInRedis; /* 是否为分布时间的范围扫描 */
bool isSampleScan; /* 是否是表样本扫描 */
SampleScanParams sampleScanInfo; /* TABLESAMPLE 参数,包括类型/种子/可重复性 */
ExecScanAccessMtd ScanNextMtd; /* 扫描下一个方法 */
} ScanState;
/*
* SeqScan uses a bare ScanState as its state node, since it needs
* no additional fields.
*/
typedef ScanState SeqScanState;
其中,ExecInitSeqScan 函数中的这段代码是在判断是否需要进行表样本扫描,如果 node 结构中的 tablesample 成员非空,就说明需要进行表样本扫描。
if (node->tablesample) {
scanstate->sampleScanInfo.args = (List*)ExecInitExpr((Expr*)tsc->args, (PlanState*)scanstate);
scanstate->sampleScanInfo.repeatable = ExecInitExpr(tsc->repeatable, (PlanState*)scanstate);
scanstate->sampleScanInfo.sampleType = tsc->sampleType; //设置表样本类型 sampleType
/*
* 初始化 RowTableSample
*/
if (scanstate->sampleScanInfo.tsm_state == NULL) {
scanstate->sampleScanInfo.tsm_state = New(CurrentMemoryContext) RowTableSample(scanstate);
}
}
注解:什么是表样本扫描?
表样本扫描(Table Sample Scan)是一种数据库查询优化技术,用于在大型表中进行随机或者有规律的采样,以减少查询的执行时间。它适用于那些数据量庞大的表,通过从表中抽取一小部分数据进行查询,可以在不影响查询结果准确性的前提下,显著减少查询所需的时间和资源消耗。
InitScanRelation函数
接着,进入到 InitScanRelation 函数中进行初始化扫描表。InitScanRelation 函数源码如下:(路径:src/gausskernel/runtime/executor/nodeSeqscan.cpp
)
/* ----------------------------------------------------------------
* InitScanRelation
*
* 此函数对扫描关系和扫描的子计划进行初始化。
* ----------------------------------------------------------------
*/
void InitScanRelation(SeqScanState* node, EState* estate)
{
Relation current_relation; // 当前关系
Relation current_part_rel = NULL; // 当前分区关系
SeqScan* plan = NULL; // SeqScan计划节点
bool is_target_rel = false; // 是否为目标关系
LOCKMODE lockmode = AccessShareLock; // 锁模式
TableScanDesc current_scan_desc = NULL; // 当前扫描描述
// 判断当前扫描操作是否针对目标关系(即是否用于更新、删除等操作的目标表)
is_target_rel = ExecRelationIsTargetRelation(estate, ((SeqScan*)node->ps.plan)->scanrelid);
/*
* 从范围表的第scanrelid项获取关系对象id,打开该关系并在其上获取适当的锁。
*/
current_relation = ExecOpenScanRelation(estate, ((SeqScan*)node->ps.plan)->scanrelid);
/*
* 元组表初始化
*/
ExecInitResultTupleSlot(estate, &node->ps, current_relation->rd_tam_type);
ExecInitScanTupleSlot(estate, node, current_relation->rd_tam_type);
// 判断当前查询是否针对分区表(partitioned table)进行的。
// node->isPartTbl 是一个标志,用于指示当前查询是否是针对一个分区表。
if (!node->isPartTbl) {
/* 为redis添加限制条件 */
// 初始化表扫描的描述符(TableScanDesc),以便开始执行表的扫描操作。
current_scan_desc = InitBeginScan(node, current_relation);
} else {
// 将节点的计划部分转换为SeqScan类型,以便访问相关属性
plan = (SeqScan*)node->ps.plan;
/* 初始化分区列表 */
node->partitions = NULL;
// 如果当前关系不是目标关系,则将锁模式设置为AccessShareLock
if (!is_target_rel) {
lockmode = AccessShareLock;
} else {
switch (estate->es_plannedstmt->commandType) {
case CMD_UPDATE:
case CMD_DELETE:
case CMD_MERGE:
lockmode = RowExclusiveLock;
break;
case CMD_SELECT:
lockmode = AccessShareLock;
break;
default:
ereport(ERROR,
(errcode(ERRCODE_INVALID_OPERATION),
errmodule(MOD_EXECUTOR),
errmsg("无效操作 %d 用于序列扫描的分区,允许的操作为 UPDATE/DELETE/SELECT",
estate->es_plannedstmt->commandType)));
break;
}
}
node->lockMode = lockmode;
/* 生成node->partitions(如果存在) */
if (plan->itrs > 0) { // 如果有分区迭代数
Partition part = NULL;
PruningResult* resultPlan = NULL;
// 如果分区裁剪信息中的表达式不为空,获取分区信息
if (plan->pruningInfo->expr != NULL) {
resultPlan = GetPartitionInfo(plan->pruningInfo, estate, current_relation);
} else {
resultPlan = plan->pruningInfo;
}
ListCell* cell = NULL;
List* part_seqs = resultPlan->ls_rangeSelectedPartitions;
// 遍历选中的分区序号
foreach (cell, part_seqs) {
Oid tablepartitionid = InvalidOid;
int partSeq = lfirst_int(cell);
// 根据序号获取分区的Oid
tablepartitionid = getPartitionOidFromSequence(current_relation, partSeq);
// 打开分区
part = partitionOpen(current_relation, tablepartitionid, lockmode);
// 将打开的分区加入到分区列表中
node->partitions = lappend(node->partitions, part);
}
// 设置分区数量
if (resultPlan->ls_rangeSelectedPartitions != NULL) {
node->part_id = resultPlan->ls_rangeSelectedPartitions->length;
} else {
node->part_id = 0;
}
}
// 如果有分区
if (node->partitions != NIL) {
/* 构造第一个分区的HeapScanDesc */
// 获取第一个分区
Partition currentPart = (Partition)list_nth(node->partitions, 0);
// 获取分区关系
current_part_rel = partitionGetRelation(current_relation, currentPart);
// 设置当前分区关系
node->ss_currentPartition = current_part_rel;
/* 为redis添加限制条件 */
// 初始化第一个分区的扫描描述符
current_scan_desc = InitBeginScan(node, current_part_rel);
} else {
// 如果没有分区,则设置当前分区为NULL
node->ss_currentPartition = NULL;
// 初始化扫描条件
node->ps.qual = (List*)ExecInitExpr((Expr*)node->ps.plan->qual, (PlanState*)&node->ps);
}
}
// 将当前正在扫描的关系对象(表)赋值给SeqScanState结构体中的ss_currentRelation成员变量,以表示当前扫描的是哪个关系。
node->ss_currentRelation = current_relation;
// 将扫描描述符赋值给SeqScanState结构体中的ss_currentScanDesc成员变量,以便后续的扫描操作可以使用该描述符。
node->ss_currentScanDesc = current_scan_desc;
// 对扫描到的数据进行正确的解析和存储
ExecAssignScanType(node, RelationGetDescr(current_relation));
}
在上述代码中,ExecRelationIsTargetRelation 函数作用是判断当前扫描操作是否针对目标关系(即是否用于更新、删除等操作的目标表)。ExecRelationIsTargetRelation 会检查给定的扫描计划节点是否是一个目标关系,如果是的话返回true,否则返回false。这里打印结果为false,表示不是一个目标关系。
注解:在数据库操作中,一个目标关系通常是指执行更新(UPDATE)、删除(DELETE)或插入(INSERT)等修改数据操作的表。目标关系是被操作的主要表,对其进行的修改操作会直接影响到表中的数据。而非目标关系则是在操作过程中可能涉及到的其他表,但这些表不是直接受到操作影响的对象。
在本案例中,执行 SQL 查询语句SELECT * FROM t2;
时,如果查询的是表 t2 并且没有进行更新、删除等修改操作,那么这条查询语句并不会修改表的数据,因此表 t2 就不是一个目标关系。因此打印结果为false。
而 InitBeginScan(node, current_relation) 函数作用是初始化表扫描的描述符(TableScanDesc),以便开始执行表的扫描操作。具体来说,它会根据传入的参数 node 和 current_relation ,生成一个用于表扫描的描述符。这个描述符包含了扫描相关的信息,比如扫描的表、锁模式等。然后,它将这个描述符返回,以便后续的表扫描操作可以使用。
在这段代码中,如果 node 不是分区表(即 node->isPartTbl 为 false),则调用 InitBeginScan 来初始化扫描描述符,并将结果赋值给current_scan_desc。这意味着这段代码是在非分区表的情况下初始化表扫描描述符。如果是分区表,就不执行这个操作。
源码及注释如下:(路径:src/gausskernel/runtime/executor/nodeSeqscan.cpp
)
static TableScanDesc InitBeginScan(SeqScanState* node, Relation current_relation)
{
// 声明一个变量来存储表扫描描述符
TableScanDesc current_scan_desc = NULL;
// 如果不是表样本扫描
if (!node->isSampleScan) {
// 调用scan_handler_tbl_beginscan函数来初始化表扫描描述符
// 参数包括要扫描的关系、当前事务的快照、初始块号、分布式查询所需的信息,以及扫描状态
current_scan_desc = scan_handler_tbl_beginscan(current_relation, node->ps.state->es_snapshot, 0, NULL, (ScanState*)node);
} else {
// 如果是表样本扫描,则调用InitSampleScanDesc函数来初始化表样本扫描描述符
current_scan_desc = InitSampleScanDesc((ScanState*)node, current_relation);
}
// 返回初始化后的表扫描描述符
return current_scan_desc;
}
ExecSeqScan函数
ExecSeqScan 函数是 OpenGauss 中用于执行序列扫描(Sequential Scan)操作的函数。它执行基于序列扫描的查询计划,获取满足扫描条件的数据,并将结果放入一个元组表槽(TupleTableSlot)中返回。
其函数调用关系如下:
ExecSeqScan 函数实际上是通过调用通用的扫描函数 ExecScan 来执行具体的序列扫描操作。源码如下:(路径:src/gausskernel/runtime/executor/nodeSeqscan.cpp
)
/* ----------------------------------------------------------------
* ExecSeqScan(node)
*
* Scans the relation sequentially and returns the next qualifying
* tuple.
* We call the ExecScan() routine and pass it the appropriate
* access method functions.
* ----------------------------------------------------------------
*/
TupleTableSlot* ExecSeqScan(SeqScanState* node)
{
return ExecScan((ScanState*)node, node->ScanNextMtd, (ExecScanRecheckMtd)SeqRecheck);
}
其三个入参的描述如下所示:
- node: SeqScanState* 类型,表示序列扫描操作的状态信息,其中包含了查询计划的相关信息以及当前扫描的状态。
- node->ScanNextMtd: 是一个函数指针,指向具体的序列扫描操作的实现函数,用于获取下一个满足条件的元组。
- (ExecScanRecheckMtd)SeqRecheck: 一个函数指针,指向重新检查操作的函数,用于在扫描过程中需要重新检查的情况下执行。
TupleTableSlot 结构体是在数据库查询执行过程中用于存储元组数据的数据结构。它是一个用于临时存储从表中检索到的数据行(元组)的容器,通常在查询执行的不同阶段中用于传递和处理数据。来看一看 TupleTableSlot 结构体长什么样吧:(路径:src/include/executor/tuptable.h
)
typedef struct TupleTableSlot {
NodeTag type; /* 节点类型,用于标识数据结构类型 */
bool tts_isempty; /* true = 槽为空 */
bool tts_shouldFree; /* 是否应该释放 tts_tuple 内存? */
bool tts_shouldFreeMin; /* 是否应该释放 tts_mintuple 内存? */
bool tts_slow; /* 用于 slot_deform_tuple 的保存状态 */
Tuple tts_tuple; /* 物理元组,如果是虚拟的则为 NULL */
#ifdef PGXC
/*
* PGXC 扩展,用于支持从远程 Datanode 发送的元组。
*/
char* tts_dataRow; /* DataRow 格式的元组数据 */
int tts_dataLen; /* 数据行的实际长度 */
bool tts_shouldFreeRow; /* 是否应该释放 tts_dataRow 内存? */
struct AttInMetadata* tts_attinmeta; /* 存储从 DataRow 中提取值所需的信息 */
Oid tts_xcnodeoid; /* 从哪个节点获取的数据行的 Oid */
MemoryContext tts_per_tuple_mcxt;
#endif
TupleDesc tts_tupleDescriptor; /* 槽的元组描述 */
MemoryContext tts_mcxt; /* 槽本身所在的内存上下文 */
Buffer tts_buffer; /* 元组的缓冲区,如果没有则为 InvalidBuffer */
int tts_nvalid; /* tts_values 中的有效值数 */
Datum* tts_values; /* 当前每个属性的值 */
bool* tts_isnull; /* 当前每个属性的是否为 NULL 的标志 */
MinimalTuple tts_mintuple; /* 最小元组,如果没有则为 NULL */
HeapTupleData tts_minhdr; /* 仅用于最小元组的情况下的工作空间 */
long tts_off; /* 用于 slot_deform_tuple 的保存状态 */
long tts_meta_off; /* 用于 slot_deform_cmpr_tuple 的保存状态 */
TableAmType tts_tupslotTableAm; /* 槽的元组表类型 */
} TupleTableSlot;
调试信息如下:
其中,TupleTableSlot 结构体中的属性 tts_values 可以用于存储元组的具体值。这是一个指向 Datum 数组的指针,其中每个元素对应于元组的每个属性的值。在 PostgreSQL 中,Datum 是一个通用的数据类型,可以表示各种数据类型的值。
tts_isnull 是一个 bool 数组的指针,用于标识对应属性是否为 NULL。如果 tts_isnull[i] 为 true,表示属性值是 NULL;如果为 false,则表示属性值不是 NULL。
这两个数组一起,构成了一个完整的元组的值和 NULL 信息的表示。通过遍历 tts_values 数组和 tts_isnull 数组,可以访问元组的每个属性的值和是否为 NULL 的信息。
不理解??没关系,来看一个案例吧:
假设有一个关系表 students 包含以下几个属性:student_id(整数)、name(字符串)、age(整数)和 grade(字符串)。我们可以使用 TupleTableSlot 结构体来表示从该表中获取的元组。
假设我们从 students 表中获取了一个元组,具体的值如下:
属性 | 值 |
---|---|
student_id | 101 |
name | Alice |
age | 25 |
grade | A |
我们可以将这个元组的值存储在 TupleTableSlot 结构体的 tts_values 和 tts_isnull 数组中,以便后续处理。具体表示如下:
tts_values 数组:
索引 | 值 |
---|---|
索引 0 | 101(student_id) |
索引 1 | Alice(name) |
索引 2 | 25(age) |
索引 3 | A(grade) |
tts_isnull 数组:
索引 | 值 |
---|---|
索引 0 | false(student_id 不为 NULL) |
索引 1 | false(name 不为 NULL) |
索引 2 | false(age 不为 NULL) |
索引 3 | false(grade 不为 NULL) |
通过这两个数组,我们可以知道元组的每个属性的值和是否为 NULL,进而在执行操作时进行处理和判断。
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 返回的 slot 包含 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);
}
}
在执行查询计划时,扫描节点会通过 ExecScan 函数调用 ExecScanFetch 函数来获取符合条件的元组,然后再进行投影、过滤等操作。
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);
做了什么:SeqNext 函数是执行顺序扫描的核心工作函数。它从扫描描述中获取扫描信息和状态,然后通过调用 scan_handler_tbl_getnext 函数从表中获取下一个元组。获取到的元组会被保存到扫描元组槽中,并返回该槽。在这个过程中,还涉及到预取操作,以及缓冲区引用计数的处理。
函数指针定义如下:(路径:src/gausskernel/runtime/executor/nodeSeqscan.cpp
)
static TupleTableSlot* SeqNext(SeqScanState* node);
/* ----------------------------------------------------------------
* Scan Support
* ----------------------------------------------------------------
*/
/* ----------------------------------------------------------------
* SeqNext
*
* 这是 ExecSeqScan 的工作函数
* ----------------------------------------------------------------
*/
static TupleTableSlot* SeqNext(SeqScanState* node)
{
Tuple tuple;
TableScanDesc scanDesc;
EState* estate = NULL;
ScanDirection direction;
TupleTableSlot* slot = NULL;
/*
* 从 estate 和扫描状态中获取信息
*/
scanDesc = node->ss_currentScanDesc;
estate = node->ps.state;
direction = estate->es_direction;
slot = node->ss_ScanTupleSlot;
GetTableScanDesc(scanDesc, node->ss_currentRelation)->rs_ss_accessor = node->ss_scanaccessor;
/*
* 从表中获取下一个元组用于顺序扫描。
*/
tuple = scan_handler_tbl_getnext(scanDesc, direction, node->ss_currentRelation);
ADIO_RUN()
{
Start_Prefetch(GetTableScanDesc(scanDesc, node->ss_currentRelation), node->ss_scanaccessor, direction);
}
ADIO_END();
/*
* 将返回的元组和缓冲区保存在扫描元组槽中,并返回该槽。
* 注意:我们传递 'false',因为由 heap_getnext() 返回的元组是指向磁盘页上的指针,不是使用 palloc() 创建的,
* 因此不应使用 pfree_ext() 进行释放。另外注意,ExecStoreTuple 将增加缓冲区的引用计数;引用计数将不会减少,
* 直到清除元组表槽为止。
*/
return ExecMakeTupleSlot(tuple, GetTableScanDesc(scanDesc, node->ss_currentRelation), slot, node->ss_currentRelation->rd_tam_type);
}
SeqNext 函数中 使用 GetTableScanDesc 函数来处理分桶表(Bucketed Table)的情况。GetTableScanDesc 获取表扫描描述符(TableScanDesc)的函数,它根据输入的扫描描述符和关系(表)来返回对应的有效的扫描描述符。
注解:分桶表(Bucketed Table)是一种数据库表的存储组织方式,主要用于在分布式数据库系统中提高查询性能和并行处理能力。分桶表将表的数据按照某个列的值分成多个桶(bucket),每个桶包含相近的数据,从而可以使查询和分析操作更加高效。
函数源码如下:(路径:src/gausskernel/storage/access/hbstore/hbucket_am.cpp
)
TableScanDesc GetTableScanDesc(TableScanDesc scan, Relation rel)
{
if (scan != NULL && rel != NULL && RELATION_CREATE_BUCKET(scan->rs_rd)) {
return (TableScanDesc)((HBktTblScanDesc)scan)->currBktScan;
} else {
return scan;
}
}
该函数返回一个 TableScanDesc 类型的指针,表示有效的扫描描述符。调试结果如下:
SeqNext 函数调用 ExecMakeTupleSlot 函数主要用于将获取到的元组(tuple)存储到一个元组表槽(TupleTableSlot)中,同时关联元组的信息,如元组来自的扫描描述符、关联的缓冲区等。这个函数在数据库查询执行过程中扮演了重要的角色,用于将从数据库表中获取的数据以一种可管理的形式存储在内存中,以便后续的处理和返回。
函数定义如下:(路径:src/gausskernel/runtime/executor/execTuples.cpp
)
TupleTableSlot* ExecMakeTupleSlot(Tuple tuple, TableScanDesc tableScan, TupleTableSlot* slot, TableAmType tableAm)
{
if (unlikely(RELATION_CREATE_BUCKET(tableScan->rs_rd))) {
tableScan = ((HBktTblScanDesc)tableScan)->currBktScan;
}
if (tuple != NULL) {
Assert(tableScan != NULL);
slot->tts_tupslotTableAm = tableAm;
return ExecStoreTuple(tuple, /* tuple to store */
slot, /* slot to store in */
tableScan->rs_cbuf, /* buffer associated with this tuple */
false); /* don't pfree this pointer */
}
return ExecClearTuple(slot);
}
在函数 ExecMakeTupleSlot 中,ExecStoreTuple 函数用于将一个元组存储到一个 TupleTableSlot 中。TupleTableSlot 是一种用于存储元组数据的数据结构,通常用于在查询计划的不同节点之间传递元组数据。
源码如下:(路径:src/gausskernel/runtime/executor/execTuples.cpp
)
/* --------------------------------
* ExecStoreTuple
*
* This function is used to store a physical tuple into a specified
* slot in the tuple table.
*
* tuple: tuple to store
* slot: slot to store it in
* buffer: disk buffer if tuple is in a disk page, else InvalidBuffer
* shouldFree: true if ExecClearTuple should pfree_ext() the tuple
* when done with it
*
* If 'buffer' is not InvalidBuffer, the tuple table code acquires a pin
* on the buffer which is held until the slot is cleared, so that the tuple
* won't go away on us.
*
* shouldFree is normally set 'true' for tuples constructed on-the-fly.
* It must always be 'false' for tuples that are stored in disk pages,
* since we don't want to try to pfree those.
*
* Another case where it is 'false' is when the referenced tuple is held
* in a tuple table slot belonging to a lower-level executor Proc node.
* In this case the lower-level slot retains ownership and responsibility
* for eventually releasing the tuple. When this method is used, we must
* be certain that the upper-level Proc node will lose interest in the tuple
* sooner than the lower-level one does! If you're not certain, copy the
* lower-level tuple with heap_copytuple and let the upper-level table
* slot assume ownership of the copy!
*
* Return value is just the passed-in slot pointer.
*
* NOTE: before PostgreSQL 8.1, this function would accept a NULL tuple
* pointer and effectively behave like ExecClearTuple (though you could
* still specify a buffer to pin, which would be an odd combination).
* This saved a couple lines of code in a few places, but seemed more likely
* to mask logic errors than to be really useful, so it's now disallowed.
* --------------------------------
*/
TupleTableSlot* ExecStoreTuple(Tuple tuple, TupleTableSlot* slot, Buffer buffer, bool should_free)
{
/*
* sanity checks
*/
Assert(tuple != NULL);
Assert(slot != NULL);
Assert(slot->tts_tupleDescriptor != NULL);
tableam_tslot_store_tuple(tuple, slot, buffer, should_free);
return slot;
}
总结
ExecInitSeqScan 函数初始化 SeqScan 状态节点,负责节点状态结构构造,并初始化用于存储结果的元组表。
ExecSeqScan 函数是 SeqScan 算子执行的主体函数,用于迭代获取每一个元组。ExecSeqScan 函数通过回调函数调用SeqNext 函数、HbktSeqSampleNext 函数、SeqSampleNext 函数实现获取元组。非采样获取元组时调用 SeqNext 函数;如果需要采样且对应的表采用哈希桶方式存储则调用 HbktSeqSampleNext 函数,否则调用 SeqSampleNext 函数。
以下是 SeqScan 算子的执行过程的完整阐述:
- 查询解析和分析: 在 PostgreSQL 中,查询首先经过解析和分析阶段,确定了要查询的表、选择的列以及过滤条件等。
- 执行计划生成: 根据解析和分析的结果,系统生成一个查询执行计划。在这个阶段,PostgreSQL 的查询优化器会考虑各种访问路径、连接顺序和操作顺序,以生成一个优化的执行计划。
- 初始化执行环境: 执行计划生成后,会为查询执行创建一个执行环境(EState),其中包括查询的状态信息、内存上下文等。此时还会初始化一些查询中使用到的辅助数据结构。
- 初始化 SeqScanState: 对于 SeqScan 算子,会创建一个 SeqScanState 结构,用于存储该算子的执行状态。这个结构包含了 ScanState 的通用字段,以及一些特定于 SeqScan 的字段,如当前的扫描状态、分区信息等。
- 初始化扫描关系: SeqScan 需要初始化扫描关系,即要从哪个表中扫描数据。这包括打开表、获取相关的元数据等操作。
- 初始化元组插槽: 在查询执行过程中,需要使用 TupleTableSlot 来存储和传递元组数据。SeqScanState 中会初始化一个 TupleTableSlot,用于存储从表中读取的元组数据。
- 执行扫描: 正式进入执行阶段,SeqScan 开始逐行地扫描数据。具体步骤如下:
- 获取下一个扫描元组:通过底层的存储访问接口获取表中的下一个元组。
- 将元组存储到插槽:将扫描到的元组存储到初始化的 TupleTableSlot 中
以便后续操作使用。- 应用过滤条件:如果查询中存在过滤条件(WHERE 子句),会对当前扫描到的元组应用过滤条件,确定是否满足条件。
- 如果满足条件,元组会被返回给上层的操作节点进行进一步处理,例如投影、排序等。
- 如果不满足条件,会继续扫描下一个元组。
- 结束扫描: 一旦所有的元组都被扫描完毕,或者查询的其他操作已经得到了结果,SeqScan 算子的执行就会结束。这时会释放相关的资源,关闭表,清理内存等操作。
- 返回结果: 执行完成后,根据查询的需要,可能会返回一个结果集或者进行其他的操作,如数据插入、更新等。