跟我一起读postgresql源码(九)——Executor(查询执行模块之——Scan节点(上))

从前面介绍的可优化语句处理相关的背景知识、实现思想和执行流程,不难发现可优化语句执行的核心内容是对于各种计划节点的处理,由于使用了节点表示、递归调用、统一接口等设计,计划节点的功能相对独立、代码总体流程相似,下面介绍执行器中各种计划节点的相关执行过程。

在PostgreSQL中,计划节点分为四类,分别是控制节点(Control Node)、扫描节点(ScanNode),物化节点(Materialization Node)、连接节点(Join Node) 。

  • 控制节点:是一类用于处理特殊情况的节点,用于实现特殊的执行流程。例如,Result节点可用来表示INSERT语句中VALUES子句指定的将要插人的元组。

  • 扫描节点:顾名思义,此类节点用于扫描表等对象以从中获取元组。例如,SeqScan节点用于顺序扫描一个表.毎次扫描一个元组。

  • 物化节点:这类节点种类比较复杂,但它们有一个共同特点,即能够缓存执行结果到辅助存储中。物化节点会在第一次被执行时生成其中的所有结果元组,然后将这些结果元组缓存起来,等待其上层节点取用;而非物化节点则是每次被执行时生成一个结果元组并返回给上层节点。例如,Sort节点能够获取下层节点返回的所有元组并根据指定的属性进行排序,并将排序结果全部缓存起来,每次上层节点从Sort节点取元组时就从缓存中按顺序返回下一个元组(见Postgres中的物化节点之sort节点)。

  • 连接节点:此类节点对应于关系代数中的连接操作,可以实现多种连接方式(条件连接、左连接、右连接、全连接、自然连接等),每种节点实现一种连接算法。例如,HashJoin实现了基于Hash的连接箅法。

扫描节点

扫描节点的作用是扫描表,每次获取一条元组作为上层节点的输入。扫描节点普遍存在于查询计划树的叶子节点,它不仅可以扫描表,还可以扫描函数的结果集、链表结构、子查询结果集等。

所有扫描节点都使用Scan作为公共父类,Scan不仅继承了Plan的所有属性,还扩展定义了scanrelid用于记录被扫描的表在范围表中的序号。

typedef struct Scan
{
	Plan		plan;
	Index		scanrelid;		/* relid is index into the range table */
} Scan;

扫描节点的执行状态节点都以ScanState作为公共父类,ScanState除了继承PlanState的所有属性之外,还扩展定义了ss_currentScanDesc(记录扫描的位置、关系等信息),currentRelation(记录被扫描的关系)和ss_ScanTupleSlot(记录扫描到的结果)。

typedef struct ScanState
{
	PlanState	ps;				/* its first field is NodeTag */
	Relation	ss_currentRelation;
	HeapScanDesc ss_currentScanDesc;
	TupleTableSlot *ss_ScanTupleSlot;
} ScanState;

下面是来自源码中的所有的Scan类型:

	T_SeqScanState,
	T_SampleScanState,
	T_IndexScanState,
	T_IndexOnlyScanState,
	T_BitmapIndexScanState,
	T_BitmapHeapScanState,
	T_TidScanState,
	T_SubqueryScanState,
	T_FunctionScanState,
	T_ValuesScanState,
	T_CteScanState,
	T_WorkTableScanState,
	T_ForeignScanState,
	T_CustomScanState,

下面将对其一一说明。

扫描节点有各自的执行函数,但是这些执行函数都由公共的执行函数ExecScan来实现。

TupleTableSlot *
ExecScan(ScanState *node,
		 ExecScanAccessMtd accessMtd,	/* function returning a tuple */
		 ExecScanRecheckMtd recheckMtd)

ExecScan需要三个参数:

  • 状态节点ScanState,
  • 获取扫描元组的函数指针(accessMtd,由于每一种扫描节点扫描的对象不同,因此函数都不同),
  • 判断元组是否满足符合过滤条件的函数指针(recheckMtd)。

(这里要说一下:recheckMtd函数用于并发控制,如果当前元组被其他事物修改并已提交,需要检测该元组是否仍然满足选择条件。如果你有兴趣深入了解,建议查看EvalPlanQual函数

以及src/backend/executor/README文件的EvalPlanQual (READ COMMITTED Update Checking)部分)

ExecScan迭代地扫描对象,每次执行返回一条结果(内部返回元组是通过ExecScanFetch实现的)。ExecScan会使用accessMtd获取元组,然后recheckMtd进行过滤条件判断,最终返回元组。

看例子:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;  
  
                                  QUERY PLAN  
------------------------------------------------------------------------------  
 Bitmap Heap Scan on tenk1  (cost=5.07..229.20 rows=101 width=244)  
   Recheck Cond: (unique1 < 100)                  <---recheckMtd 的作用
   ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)  
         Index Cond: (unique1 < 100

1.SeqScan 节点

SeqScan是最基本的扫描节点,它用于扫描物理表,完成没有索引辅助的顺序扫描过程。其计划节点SeqScan实际是Scan节点的一个别名,并未定义扩展属性。其执行状态节点SeqScanState也直接使用ScanState。

SeqScan节点的初始化由函数ExecInitSeqScan完成。该函数首先创建一个SeqScanState结构,将SeqScan节点链接在SeqScanState结构的ps字段中。然后调用ExecInitExpr对计划节点的目标属性和査询条件进行初始化,并将它们链接到SeqScanState相应的字段中。接下来还将为计划节点分配用于存储结果元组和扫描元组的数据结构。最后通过计划节点中scanrelid字段的信息获取被扫描对象的RelationData结构,并链接在ss_currentRelation字段中,同时利用该信息调用heap_beginscan初始化扫描描述符ss_currentScanDesc

SeqScan节点的执行函数是ExecSeqScan,在该函数中:

调用ExecScan函数,并将SeqNext函数的指针作为ExecScan函数accessMtd参数的值。并将SeqRecheck函数指针作为ExecScan函数recheckMtd参数的值。SeqNext函数将通过存储模块提供的函数heap_getnext获取下一条元组并返回;
ExecScan在利用SeqNext获得一个元组之后,还将根据计划节点中的査询条件和投影要求对得到的元组进行条件检査和投影操作,最后将满足要求的结果元组返回。这里SeqRecheck其实没做任何处理和判断,因为这个函数不使用heap_beginscan返回的keys(也就是自己去表上找,不受并发的影响。这个后面说)。

其他扫描节点的执行函数都采用类似的方式管理,即统一调用ExecScan,但根据节点的类型给ExecScan的参数AccessMtd和recheckMtd賦予不同的函数指针。

SeqScan节点的淸理过程由函数ExecEndSeqScan完成,在该函数中需要额外调用函数heap_endscan来清理ss_currentScanDesc内的信息。


2.SampleScan节点

这个是9.5 版本新增的数据取样功能,支持查询返回取样数据。当前只在常规表和物化视图上接受TABLESAMPLE子句。

语法大概是这样:

SELECT select_list FROM table_name TABLESAMPLE sampling_method ( argument [, ...] ) [ REPEATABLE ( seed ) ]

用法的话看这里:

http://www.postgres.cn/docs/9.5/sql-select.html(使用Sample)

http://www.postgres.cn/docs/9.5/tablesample-method.html(自定义Sample函数)

用白话说就是我对表中符合条件的数据可以进行采样。帮你省了一个抽奖系统,棒棒棒!!(微笑脸)。

我们看一下节点结构:

typedef struct SampleScan
{
	Scan		scan;
	/* use struct pointer to avoid including parsenodes.h here */
	struct TableSampleClause *tablesample;
} SampleScan;

可以看到,在Scan的基础上加上了TableSample相关的结构,它的数据结构如下:

typedef struct TableSampleClause
{
	NodeTag		type;
	Oid			tsmhandler;		/* OID of the tablesample handler function */
	List	   *args;			/* tablesample argument expression(s) */
	Expr	   *repeatable;		/* REPEATABLE expression, or NULL if none */
} TableSampleClause;

描述SampleScan查询状态的数据结构SampleScanState如下,简单而言,它在ScanState的基础上增加了Sample采样策略,随机种子,采样函数这些和Sample相关的数据。而这些数据就来自于SampleScan节点的TableSampleClause结构。

typedef struct SampleScanState
{
	ScanState	ss;
	List	   *args;			/* expr states for TABLESAMPLE params */
	ExprState  *repeatable;		/* expr state for REPEATABLE expr */
	/* use struct pointer to avoid including tsmapi.h here */
	struct TsmRoutine *tsmroutine;		/* descriptor for tablesample method */
	void	   *tsm_state;		/* tablesample method can keep state here */
	bool		use_bulkread;	/* use bulkread buffer access strategy? */
	bool		use_pagemode;	/* use page-at-a-time visibility checking? */
	bool		begun;			/* false means need to call BeginSampleScan */
	uint32		seed;			/* random seed */
} SampleScanState;

其他的话,诸君请看代码吧~


3.IndexScan 节点

如果选择条件涉及的属性上建立了索引,则生成的査询计划中涉及表的扫描时会使用IndexScan节点。该节点能够利用索引进行表的扫描以获取满足选择条件的元组。

IndexScan节点的定义如下面所示。除了继承Scan节点定义的属性外,IndexScan扩展定义了indexid属性(用于存储索引的OID)、indexqual属性(用于存储索引扫描的条件)、indexqualorig属性(用于存储没有处理的原始扫描条件链表以及indexonierdir属性(用于存储扫描的方向)。

typedef struct IndexScan
{
	Scan		scan;
	Oid			indexid;		/* OID of index to scan */
	List	   *indexqual;		/* list of index quals (usually OpExprs) */
	List	   *indexqualorig;	/* the same in original form */
	List	   *indexorderby;	/* list of index ORDER BY exprs */
	List	   *indexorderbyorig;		/* the same in original form */
	List	   *indexorderbyops;	/* OIDs of sort ops for ORDER BY exprs */
	ScanDirection indexorderdir;	/* forward or backward or don't care */
} IndexScan;

IndexScan节点的初始化过程由函数ExecInitlndexScan完成。该函数将构造IndexScanState节点,并使用indexid获取索引的RelationData结构存放于iss_RelationDesc字段中。同时,通过调用ExeclndexBuildScanKeys将indexqual中的索引扫描条件转换为扫描关键字(ScanKey,存储扫描满足的条件)以及运行时关键字计算结构(IndexRuntimeKeylnfo,执行时才能得到结果的表达式信息)分别存储在 iss_ScanKeys 和 iss_RuntimeKeys 这两个数组中。iss_NumScanKeys 和 iss_NumRuntimeKeys 则用于指示前面两个数组的长度,同时还要设罝iss_NumRumimeKeys为false。最后将调用索引模块提供的index_beginscan初始化扫描描述符iss_ScanDesc。而索引扫描未经特殊处理的原始约束条件链表则用于构造indexqualorig字段。

typedef struct IndexOnlyScanState
{
	ScanState	ss;				/* its first field is NodeTag */
	List	   *indexqual;					//execution state for indexqual expressions
	ScanKey		ioss_ScanKeys;				//Skey structures for index quals
	int			ioss_NumScanKeys;			//number of ScanKeys
	ScanKey		ioss_OrderByKeys;			//Skey structures for index ordering operators
	int			ioss_NumOrderByKeys;		//number of OrderByKeys
	IndexRuntimeKeyInfo *ioss_RuntimeKeys;	//info about Skeys that must be evaluated at runtime
	int			ioss_NumRuntimeKeys;		//number of RuntimeKeys
	bool		ioss_RuntimeKeysReady;		//true if runtime Skeys have been computed
	ExprContext *ioss_RuntimeContext;		//expr context for evaling runtime Skeys
	Relation	ioss_RelationDesc;			//index relation descriptor
	IndexScanDesc ioss_ScanDesc;			//index scan descriptor
	Buffer		ioss_VMBuffer;				//buffer in use for visibility map testing, if any
	long		ioss_HeapFetches;			//number of tuples we were forced to fetch from heap
} IndexOnlyScanState;

IndexScan节点的执行过程由ExecIndexScan函数完成,其执行过程同样由ExecScan统一管理,但对IndexScan节点将使用IndexNext函数来获取元组。ExecIndexScan首先判断是否有RuntimeKeys且需要计算(iss_RuntimeKeyReady为false),如果存在则调用ExecIndexReScan函数计算所有的iss_RuntimeKeys表达式,并将其存储到关联的iss_ScanKeys中。接着调用ExecScan通过IndexNext获取元组,在IndexNext中将调用索引模块提供的index_getnext函数利用索引取得元组。

IndexScan的淸理过程由EndlndexScan函数完成,其中需要回收索引关系描述结构iss_RelationDesc (调用 index_close)和索引扫描描述符 iss_ScanDesc (调用 index_endacan)。


4.IndexOnlyScan节点

所谓index only scan ,就是因为建立index时,所包含的字段集合,囊括了我们查询语句中的字段,这样,提取出相应的index ,就不必再次提取数据块了。

举个例子:对于表:

create table test(id int, name text, age int);
insert into test select generate_series(1,100000),'test'::text,generate_series(1,100000);

我们对id和age建立复合索引:

create index test_id_age on test(id ,age);

然后,执行查询:

explain select id, age from test  where id < 20 and age >0;

查询结果为:

postgres=# explain select id ,age from test where id < 20 and age >0;
                                  QUERY PLAN                                   
-------------------------------------------------------------------------------
 Index Only Scan using test_id_age on test  (cost=0.29..41.94 rows=20 width=8)
   Index Cond: ((id < 20) AND (age > 0))
(2 rows)

这个查询里查询的id和age就在索引test_id_age上,在我们取出索引的时候,我们已经获取了(id,age)值的序列,因此就不必再去表中获取记录了,在Index上我们就获得了我们需要的数据,因此称为Index Only Scan。

对这个IndexOnlyScan我们可能有疑问,万一我的索引没有及时更新,岂不是会查询出来旧的过时的数据?
对这点不必担心,我们可以看看IndexOnlyScan的执行函数:

void
ExecReScanIndexOnlyScan(IndexOnlyScanState *node)

它不是单纯地根据节点的类型给ExecScan的参数AccessMtd和recheckMtd賦予不同的函数指针,而是还要:

 *		Recalculates the values of any scan keys whose value depends on
 *		information known at runtime, then rescans the indexed relation.

也就是说,我们会先重新扫描获取scan key,然后再拿着这个key去scan。调用路径如下:

ExecIndexOnlyScan
	-->ExecReScan  * 这里是rescan,更新scan keys
		-->ExecReScanIndexOnlyScan
	
	-->ExecScan ## 用新的scan keys进行Scan

这里,IndexOnlyScan不允许Recheck。

static bool
IndexOnlyRecheck(IndexOnlyScanState *node, TupleTableSlot *slot)
{
	elog(ERROR, "EvalPlanQual recheck is not supported in index-only scans");
	return false;				/* keep compiler quiet */
}

5.BitmapIndexScan 节点

BitmapIndexScan节点也是利用属性上的索引进行扫描操作,但是BitmapIndexScan得到的结果不是实际的元组,而是一个位图,该位图标记了满足条件的元组在页面中的偏移量。BitmapIndexScan节点在第一次被执行时就将获取所有满足条件的元组并在位图中标记它们,而其上层节点中都会有特殊的扫描节点(例如下面将介绍的BitmapHeapScan)使用该位图来获取实际的元组。因此,该扫描方式下不产生实际的元组,也就是说,该节点不出现在ExecProcNode函数的调用中,不是一个独立的执行节点,只被特殊的上层节点调用

BitmapIndexScan与IndexScan节点定义几乎相同,由于一次返回关于所有的元组的位图,所以不需要记录扫描方向的indexorderdir和indexorderby字段。

typedef struct BitmapIndexScan
{
	Scan		scan;
	Oid			indexid;		/* OID of index to scan */
	List	   *indexqual;		/* list of index quals (OpExprs) */
	List	   *indexqualorig;	/* the same in original form */
} BitmapIndexScan;

其执行状态节点BitmapIndexScanState与IndexScanState也很相似,但多出了表示索引关键字属性数组及其长度的字段。BitmapIndexScan和IndexScan的执行过程也类似,只是在BitmapIndexScan处理过程中,初始化函数ExecInitBitmapIndexScan使用index_beginscan_bitmap函数初始化扫描状态,MultiExecBitmapIndexScan函数将调用index_getbitmap生成位图,并将其存放在执行状态记录节点的biss_result字段中。

typedef struct BitmapIndexScanState
{
	ScanState	ss;				/* its first field is NodeTag */
	TIDBitmap  *biss_result;				//bitmap to return output into, or NULL
	ScanKey		biss_ScanKeys;				//Skey structures for index quals
	int			biss_NumScanKeys;			//number of ScanKeys
	IndexRuntimeKeyInfo *biss_RuntimeKeys;	//info about Skeys that must be evaluated at runtime
	int			biss_NumRuntimeKeys;		//number of RuntimeKeys
	IndexArrayKeyInfo *biss_ArrayKeys;		//info about Skeys that come from ScalarArrayOpExprs
	int			biss_NumArrayKeys;			//number of ArrayKeys
	bool		biss_RuntimeKeysReady;		//true if runtime Skeys have been computed
	ExprContext *biss_RuntimeContext;		//expr context for evaling runtime Skeys
	Relation	biss_RelationDesc;			//index relation descriptor
	IndexScanDesc biss_ScanDesc;			//index scan descriptor
} BitmapIndexScanState;

6.BitmapHeapScan

上面介绍的BitmapIndexScan节点将输出位图而不是元组,为了根据位图获取实际的元组,PostgreSQL提供了BitmapHeapScan节点从BitmapIndexScan输出的位图中获取元组。

BitmapHeapScan节点定义如下所示,该节点在Scan的基础上仅扩展了约束条件检査字段(bitmapqualorig),该字段与IndexScan节点的indexqualorig功能相同。当并发事务修改并提交了当前处理的元组时,需要重新扫描更新后的元组是否满足约束条件,而不是重新获取位图,因此将直接使用该表达式进行条件计算。BitmapHeapScan有且仅有一个子节点(左子节点),显然这个左子节点必须是提供位图输出的计划节点。

typedef struct BitmapHeapScan
{
	Scan		scan;
	List	   *bitmapqualorig; /* index quals, in standard expr form */
} BitmapHeapScan;

初始化函数ExecInitBitmapHeapScan会根据节点中的scanrelid初始化扫描描述符ss_currentScanDesc。其他的初始化设置在执行过程中进行。

执行函数 ExecBitmapHeapScan 会将 BitmapHeapNext 函数指针传递给 ExecScan, ExecScan 使用BitmapHeapNext 获取元组。BitmapHeapNext 首先判断 BitmapHeapScanState 的 tbm (位图)是否为空,如果为空则调用MultiExecProcNode从左子节点获取位图,并调用tbm_begin_iterate初始化tbmiterator。如果需要预取还要调用 tbm_begin_iterate 初始化 prefetch_iterator,并将 prefetch_pages 置 0, prefetch_target设置为-1。然后执行过程会利用tbmiterator遍历位图获取物理元组的偏移量,然后从对应的缓冲区中按照偏移量取出元组并返回。

typedef struct BitmapHeapScanState
{
	ScanState	ss;				/* its first field is NodeTag */
	List	   *bitmapqualorig;		//execution state for bitmapqualorig expressions
	TIDBitmap  *tbm;				//bitmap obtained from child index scan(s)
	TBMIterator *tbmiterator;		//iterator for scanning current pages
	TBMIterateResult *tbmres;		//current-page data
	long		exact_pages;		//total number of exact pages retrieved
	long		lossy_pages;		//total number of lossy pages retrieved
	TBMIterator *prefetch_iterator;	//iterator for prefetching ahead of current page
	int			prefetch_pages;		//# pages prefetch iterator is ahead of current
	int			prefetch_target;	//target prefetch distance
} BitmapHeapScanState;

清理过程ExecEndBitmapHeapScan需要调用左子节点的清理函数,然后清理tbmiterator、prefetch_iterator以及tbm位图,最后清理扫描描述符并关闭打开的表。


7.TidScan 节点

PostgreSQL系统专用于标识元组物理位置的数据类型被称作TID (Tuple Identifier),一个TID由块号和块内偏移量组成,系统属性ctid被定义为此种类型。

PostgreSQL 自带的表是堆表,数据按行存储在HEAP PAGE中,在btree索引中,除了存储字段的value,还会存储对应的ctid(即行号),检索记录也是通过行号进行检索。 因此通过行号是可以快速检索到记录的。

行号的写法是(page_number, item_number),数据块从0开始编号,行号从1开始编号。

举例:

postgres=# select ctid ,* from zxc;
 ctid  | id |   name
-------+----+----------
 (0,4) |  1 | asdftest
 (0,5) |  3 | asdftest
 (0,6) |  9 | asdftest
(3 行)


那么我们可以用ctid来访问数据:

postgres=# select *  from zxc where ctid = '(0,5)'::tid;
 id |   name
----+----------
  3 | asdftest
(1 行)

同时,在定义游标后,可以使用“UPDATE/DELETE…WHERE CURRENT OF…”语句对当前游标位置的元组进行修改/删除。可以参考这里:http://www.postgres.cn/docs/9.5/sql-update.html

这个时候生成的査询计划树仅包含一个TidScan节点,其扫描的对象是TidScan节点中保存的一个表达式链表,其中存储的表达式可以得到ctid的值,TidScan节点将根据ctid值取得对应的元组。TidScan节点只在Scan节点的基础上扩展了一个字段tidquals用于保存可以得到ctid的表达式链表。

TidScan 节点的初始化函数 ExecInitTidScan 会根据 tidquals 初始化 TidScanState 中的 tss_tidquals字段,然后调用ExecInitExpr初始化tidquals中的表达式,并根据节点中的scanrelid初始化扫描描述符 ss_currentScanDesc。

TidScan节点的执行函数(ExecTidScan)也同样调用函数ExecScan来完成执行工作,其中传递给ExecScan函数的指针是TidNext。函数TidNext首先需要通过计算TidScanState节点的tss_tidquals链表中的表达式来构造tss_TidList数组,该数组中存放的是一系列的ctid, tss_NumTidS用于记录数组的长度,tss_TidPtr用于记录当前处理的ctid在tss_TidList数组中的偏移量,初始值设为-1。然后从tss_TidList中获取下一个ctid值,接着调用存储模块提供的heap_fetch根据该ctid获取元组并返回。出于并发的需要,当TidScan节点用于“CURRENTOF".(游标名)”语句时,获取的ctid可能已经被其他事务修改,需要获取此ctid对应元组的最新版本(利用HOT链),然后再调用heap_fetch进行获取。

清理过程ExecEndTidScan不需要特殊的操作,直接释放相关内存上下文和初始化时分配的空间。

大约还剩下7个Scan方法,下篇再说~

原文地址:https://www.cnblogs.com/flying-tiger/p/8137720.html