PostgreSQL Tuple管理

  • 介绍PostgreSQL存储引擎相关知识
在PostgreSQL的基础就是Tuple数据的管理均是由以此,

    源文件在: postgresql/src/backend/access/common/heaptuple.c 在函数heap_compute_data_size中,该函数用来计算Tuple的大小,在该函数的输入参数中,isnull 用来标识该tuple种的数据,那些属性上是可以为null,因为在关系数据库中,对于一张表上的字段,某些字段可以为空,而这些为空的字段在保存在tuple时候,通过构建该tuple的函数中体现,对于空属性则使用字符’n’来占位描述,而对于非null属性则使用’ ‘ 来进行占位。

heap_fill_tuple 函数用来完成对于tuple数据填充。最后的数据都分装在Datum* values中,该函数是用来加载数据。

当我们创建一个表时候,会使用xdb_catalog.c中的heap_create来,由于catalog中所保存的数据均为元数据信息,由于xmldb是架构在pg基础上的,因此我们的元数据管理,仍然采用pg的一套规则,我们所创建的所有表在pg的底层均存在中, 例如: pg_catalog表。

由于我们将Tuple的的列的描述机制进行了修改,将TupleDesc改由ColumInfo来描述,即:对于关系数据库我们在创建表的时候,使用
createTable A {
col1 int,
col2 varchar,
col3 char
}

如上的脚本进行创建表时候,我们可以清楚描述该表的列信息:该表上有多少个列,每个列的大小,类型等信息等信息,而这些基本信息会在pg_attribute(?) 系统的元数据表中保存。在需要使用的时候我们可以根据该表的oid来在系统元数据表中进行查找从而能获得该表的列信息。

但是我们不同与关系数据库其可以通过脚本创建表,而我们xml数据库则则没有通过脚本来创建表的接口,因此我们将表的列信息使用columninfo来描述,而这些columninfo在系统初始化时候就进行设置,这些我们可以从metaData.cpp中可以看出 getColInfo函数,描述了使用Oid来进行获取ColumnInfo的方法。然后会在 relache.c中的构造一个Relation对象,并将设置该新构造的Relation对象的相关参数,例如:Relation的oid, TupleDesc等参数,然后将该Relation插入到relcache中,所以这些操作均在 RelationBuildLocalRelation中完成。 在RelationOpenSmgr,完成对relation到后台文件系统的映射,例如:根据releation 的oid以及databbase oid以及table space的oid来生成相应的文件路径,如:base/1222/22/。在完成了Relation到oid的映射后,我们就可以按照该生成的路径来生成相应的物理文件,该项功能由 RelationCreateStorage来完成,而存储层(物理磁盘 Magnetic Disk)与Relation由 SMgrRelation来描述,例如在 storage.c中

tuple_debug
而smgropen函数中完成对于SMgrRelation的打开工作,其首先会从hash table中进行查找,如果没有的话会缓存进去。然后调用smgrcreate函数来完成对于物理文件的创建, smgr_create所对应的函数为 mdcreate函数,而该函数是磁盘文件系统的管理函数,直接与os文件系统交互。 在完成上述的物理文件创建后,对于xmldb系统来说,其会将其插入到元数据表中(table id: 254, 此表上创建了相应的索引 )。

然后使用 xxx_heap_formtuple函数来完成Tuple的构造,将数据(char* 或者Datum形式的数据,封装在Tuple中) 然后调用 simple_heap_insert函数来进行数据插入。而在该函数中,又使用heap_insert来进行数据的插入。新的tuple被打上当前事务id和当前的命令id。

在该函数中(heap_insert)首先要进行相应的infomask掩码的设置并设计该tuple所对于的事务id和命令id等等信息(例如:HeapTupleHeaderSetXmin, HeapTupleHeaderSetCmin, HeapTupleHeaderSetXmax等,而这些参数又是标识该元组可见性的重要数据。) (大家想想,在插入数据的时候会涉及到哪些问题? 元组的可见性,事务冲突性检查,然后buffer更新?然后磁盘写入,日志的记录等等问题) 。

接下来进行读写行的冲突检查,当我们没有检测到相应的读写冲突时候,说明我们可以进行相应的插入操作,此时首先会将该tuple插入到相应的buffer中,在完成插入到buffer之前,我们要根据所给出的relation id 在buffer中查询出可以空间满足我们所插入tuple大小的buffer来,而该项操作由函数 RelationGetBufferForTuple来完成。 从官方源码中的注释: * Returns pinned and exclusive-locked buffer of a page in given relation * with free space >= given len. 可以看出,当满足该空间要求后,会将该buffer进行上锁操作,以防止该空间被其它事务所使用。当我们在buffer pool中没有找到所需要的buffer空间时候,说明我们这个page没有在buffer中,因此需要我们使用fsm来查询空闲的buffer块, 当我们能够从buffer pool中查找到相应的page时候我们使用BufferGetBlockNumber函数来获得该buffer所对应的块号,这样我们在进行数据写入的时候可以定位到相应的块号,当没有空闲的空间时候,使用GetPageWithFreeSpace来从fsm所描述的空闲空间上查找到空闲空间,在完成上述操作后,通过函数ReadBufferBI将指定的块号的数据读入buffer中(这里不再详细描述,其使用ReadBuffer_common函数来完成与操作系统文件系统的io操作,由函数smgrnblocks来完成读取指定block的数据)

BufferPool的构成形式,
——————————————–
page1 | page2 | page3| …. | pageN|
——————————————–

在完成relation的查找后,获得目标buffer的位置,然后就使用RelationPutHeapTuple 函数将之前所设定好的Tuple,保存到该buffer上。使用PageAddItem函数将Tuple中所所对应的数据,按照PageHeader中所描述的

heap_arc_black
此项结构进行数据填充,此时需要将修改LinpN已经ItemDataPointer所指向的数据,以及pd_lower和pd_upper的指针,这两个在PageHeaderData中的指针描述了数据可以空间的上下限。然后该buffer标记为dirty状态,表明该buffer需要进行刷盘操作,然后进行日志写入(WAL)。 而标记dirty的过程就是将该buffer进行上锁,然后将标准设置为dirty。 写磁盘使用的操作由后台进程 bgwriter来完成,由于在markdirty时候,首先要写日志,并设置一个checkpoint,因此后台进程bgwriter在检查到该checkpoint时候,就会执行buffersync操作。 此时如果系统crash后,也可以通过日志来进行redo操作完成数据的写盘操作。
tuple_callstack
至此我们就完成了,创建一个relation,然后插入数据的过程。

下面我们将分析下数据的读取:
首先调用 relation_open来打开一个表,relation。首先会从relation cache中进行查找,并将cache中的relation返回,而后系统会调用heap_beginscan来进行开始扫描,而其会调用真正的扫描heap_beginscan_internal函数来完成对数据的扫描。在heap_beginscan_internal函数内部首先系统会构造一个scandesc对象,而这个对象描述了如何使用何种条件进行数据扫描,例如:所要扫描的表的oid,snapshot类型,扫描的key的数量,以及开始和结束的块的编号。首先,需要判断是否需要加意向锁(?那么为啥 indexscan的时候不需要加意向锁? 由于在进行indexscan的时候,我们会在特定的page range内加锁,而对于普通的heapscan没有使用更细粒度的锁,因此我们需要首先判断是否需要加意向锁,这只是在全表扫描的且事务是序列化事务隔离级别的时候,为了保证序列化事务下。不容许进行数据插入。); 然后会启动initscan启动扫描,在该函数中主要初始化设置HeapScanDesc对象。那么问题来了,我们是如何获得数据的呢? 不能就仅仅是初始化完HeapScanDesc对象就可以了? 答案是:heap_getnext函数。 使用该函数来获得相应的满足条件的数据。

heap_getnext函数又调用 heapgettup函数来真正的数据的获取,在进行初步判断后(例如:是否已经做过initscan操作,已经该要查找的relation是否为空等),就使用heapgetpage来获取满足条件的page,读取相应page的函数由ReadBufferExtended来完成。(大家注意该函数的输入参数),在该函数中会使用SMgrRelation对象来进行实际物理磁盘的读写;(1)使用RelationOpenSmgr宏来根据 relation中所指定的表oid,数据库的oid,tablespace的oid来与物理文件的映射(详细如何映射,后续讲座进行详述),然后就是该函数进行 ReadBuffer_common来根据所指定的blocknum来读取相应的数据,由于这里我们讨论的是全表扫描故而是由表的第一个块号进行读取数据,因此在HeapScanDesc->rs_nblocks =1 ; ///* number of blocks to scan */ ; 对于函数ReadBuffer_common的输入参数大家可以仔细看看下,其所需要的参数,其中描述了其开始的块号,ForkNum,以及所需要读取的blocknum,smgrread完成与md的交互(md为 magnetic disk管理的代码)。至此,我们从创建一个表开始,如何创建一个表,其在创建表的过程发生了什么,以及在进行全表扫描的过程中发生了什么,系统是如果知道需要查找那些数据(具体点是:起始块号和结束块号等等)。

对于索引扫描: 对于xmldb来说我们定义一个所谓的EntrySetScan这么一个类,而该类描述了我们所打开表的所做的扫描条件信息。而我们的openIndexEntrySet 函数所做的操作就是根据表的oid来打开一个表,此种的操作与传统的普通表的打开是一样的。 这里就不在详述了。然后对调用startEntrySetScan来完成对于ScanKey的初始化工作,这些工作最好都由fd_ScanKeyInitWithCallbackInfo函数来完成,其主要是设置比较函数,比较条件等信息。而这些比较条件在扫描的过程中会调用预先设置的回调函数句柄已经条件信息等来完成tuple的index搜索。按下这些不表,我们继续 startEntrySetScan函数的分析,而这些比较条件则存储在ScanKeyData类的sk_address和sk_arglen两个域中,而这两个域是pg原生代码中不存在的(具体pg的处理方式以后在论)。在完成索引条件的初始化(构建)和表的打开操作后,我们就可以开始进行索引扫描了,由函数index_beginscan 来完成索引扫描的操作,而该函数内部会调用btbeginscan函数来完成扫描,在此期间会完成IndexScanDesc的信息初始化工作,startEntrySetScan函数中第一次执行fd_index_beginscan时候并未设置相应的scan conditon的数据,而在在该函数中的第二次scan的时候进行设置,即调用fd_index_rescan时候将该scanConditon传入到IndexScanDesc类型中,因为第一次我们只是生成出一个初始的IndexScanDesc对象并对其进行相关简单参数的设置,第二次时候是进行相关Scan Condition的设置并调用btrescan进行重新扫描。在完成对于index表的打开已经初始扫描条件的初始化工作后,我们相当于已经打开了一个索引表并且已经获得了我们需要的扫描比较条件,那么我们就可以沿着这个b+tree的根节点进行遍历,每次遍历过程中都需要与我们所给出的条件进行相比较,该过程由函数 index_getnext来完成。

但是在扫描的过程中有一个问题需要我们尤为注意的是HOT链中的数据处理方式不同(有什么不同呢?何为HOT链?其用来解决什么问题?请大家思考)。在index_getnext函数中又会使用btgettuple的索引树的底层函数来根据我们的扫描条件来获取相应的数据,接下来就是调用_bt_first,_bt_next 等函数在b+树上进行遍历了,但在大家要主要在B+树上遍历的时候会有需要对元组的可见性进行判断的过程,这点大家又注意,同时索引树上可能存在着所谓的”dead”数据,这是由于我们在建立索引的过程中,这些数据可能对于本事务是属于dead,但是对于其它事务来说,这些dead数据又有可能是属于非dead数据,因此在创建过程中我们还是将这些数据建立在索引中, 这写函数最后归更到底总会通过SMgrRelation以及md.c中所定义的smgr中的bufferread落实到读盘操作的,该过程与上述的heapscan的最后落实的读盘操作类似,这里不再详述了。