openGauss数据库源码解析系列文章——存储引擎源码解析

Posted Gauss松鼠会

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了openGauss数据库源码解析系列文章——存储引擎源码解析相关的知识,希望对你有一定的参考价值。

上一篇我们详细讲述“4.2.4 ustore”相关内容。本篇我们将继续为小伙伴们带来“4.2.5 行存储索引机制”、“4.2.6 行存储缓存机制”及“4.2.7 cstore”等精彩内容的详细介绍。

4.2.5 行存储索引机制

本节以B-Tree索引为例,介绍openGauss中行存储(格式)表的索引机制。索引本质上是对数据的一种物理有序聚簇。有序聚簇参考的排序字段被称为索引键。为了节省存储空间,一般索引表中只存储有序聚簇的索引键键值以及对应元组在主表中的物理位置。在查询指定的索引键键值元组时,得益于有序聚簇排序,可以快速找到目标元组在主表中的物理位置,然后通过访问主表对应页面和偏移得到目标元组。B-Tree索引的组织结构如图4-19所示。

图4-19 B-Tree索引页面间和页面内结构示意图

当前openGauss版本中,每个B-Tree的页面采用和行存储astore堆表页面基本相同的页面结构(见“4.2.3 astore”节的“2. astore堆表页面元组结构”小节)。页面间按照树形结构组织,分为根节点页面、内部节点页面和叶子节点页面。其中,根节点页面和内部节点页面中的索引元组不直接指向堆表元组,而是指向下一层的内部节点页面或叶子节点页面;叶子节点页面位于B-Tree的最底层,叶子节点页面中的索引元组指向索引键值对应的堆表元组,即存储了该元组在堆表中的物理位置(堆表页面号和页内偏移)。

B-Tree索引元组结构由索引元组头、NULL值字典和索引键值字段3分组成。

索引元组头为IndexTupleData结构体,定义代码如下所示。其中,t_tid为堆表元组的位置或下一层索引页面的位置;t_info为标志位,记录键值中是否有NULL值、是否有变长键值、索引访存方式信息以及元组长度。

typedef struct IndexTupleData {
    ItemPointerData t_tid; /* 堆表元组的物理行号 */

    /* ---------------
     * t_info标志位内容:
     *
     * 第15位:是否有NULL字段
     * 第14位:是否有变长字段
     * 第13位: 访存方式自定义
     * 第0-12位: 元组长度
     * ---------------
     */
    unsigned short t_info; /* 如上 */
} IndexTupleData; /* 实际索引元组数据紧跟该结构体 */

与astore堆表元组不同,索引表的NULL值字典是定长的,一个bit位对应一个索引字段。当前最多支持32个索引字段,因此该字典的长度为4个字节(如果要支持变长,那么长度加变长字典的实际空间并不会比定长的4个字节少多少)。如果索引元组头部t_info标志位中存在NULL值的bit位为0,那么该索引元组没有NULL值字典,可以节约4个字节的空间。

索引键值字段和astore堆表元组的字段结构是完全相同的,唯一区别是索引键值只保存创建索引的那些字段上的值。

为了在一个索引页面中能够保存尽可能多的元组个数,降低整个B-Tree结构的层数,索引元组和astore堆表元组的结构相比要紧凑很多,去掉了一些和astore堆表元组冗余的结构体成员。在实际执行索引查询的时候,一般需要加载(索引层数+1)个物理页面才能找到目标元组。一般索引层数在2至4层之间,因此每减少一个层级近似就可以节省20%以上的元组访存开销。

当前openGauss版本中,索引元组头部不保存t_xmin和t_xmax这两个事务信息,因此元组可见性的判断不会在遍历索引时确定,而是要等到获得叶子索引最终指向的堆表元组以后,通过结合查询快照和堆表元组的t_xmin、t_xmax信息,才能判断对应堆表元组对本查询是否可见。将导致以下几个现象:

  1. 对于被删除的astore堆表元组,其空间(至少其元组指针)不能立刻被释放,否则会留下悬空的索引指针,导致后续查询出现问题。

  2. 对于被更新的astore堆表元组,如果更新前后索引字段的值发生变化,那么需要插入一条新的索引元组来指向更新后的堆表元组。然而即使更新前后所有索引字段的值没有发生变化,考虑到可能还有并发的查询需要访问老元组,因此老索引元组还要保留。同时要么插入一条新索引元组来指向更新后的堆表元组。或者也可以通过将更新后元组的位置信息保存在老元组中,这样通过原来的一条索引元组,就可以一并查到更新前后的两条新、老元组了。但是这种场景下老堆表元组的清理又变得复杂起来,否则还会存在悬挂索引指针的问题。

    为了解决上述这些问题,openGauss当前提供了三种空间管理和回收的机制(参见“4.2.3 astore”节的“5. astore空间管理和回收”小节)。在对astore堆表进行轻量级清理时,无法清理索引中的垃圾数据。只有对astore进行中量级VACUUM清理,或者重量级VACUUM FULL清理时,才能够清理对应索引中的垃圾数据。

    最后,上述索引可见性判断机制有一种例外场景:如果查询不涉及非索引字段,如显示查询索引字段内容、或“SELECT COUNT(*)”类查询,且索引字段t_tid指向的astore堆表页面对应的VM(visibility map,可见性位图)比特位为1,那么该索引元组被认为是可见的,这种扫描方式称为“Index Only Scan”。该扫描方式不仅提高了可见性判断的效率,更重要的是避免了对于堆表页面的访问,从而可以节省大量I/O开销。在页面空闲空间回收过程中,如果被清理的堆表页面上的所有元组对于当前所有正在执行的事务都可见,那么其对应的VM比特位会被置为1;后续如果该堆表页面上有新的插入、删除或更新操作之后,都会将其对应的VM比特位置为0。

openGauss中的行存储索引表访存接口如表4-26所示。

表4-26 行存储索引表访存接口

接口名称

接口含义

index_open

打开一个索引表,得到索引表的相关元信息

index_close

关闭一个索引表,释放该表的加锁或引用

index_beginscan

初始化索引扫描操作

index_beginscan_bitmap

初始化bitmap索引扫描操作

index_endscan

结束并释放索引扫描操作

index_rescan

重新开始索引扫描操作

index_markpos

记录当前索引扫描位置

index_restrpos

重置索引扫描位置

index_getnext

获取下一条符合索引条件的元组

index_getnext_tid

获取下一条符合索引条件的元组指针

index_fetch_heap

根据上面的指针,获取具体的堆表元组

index_getbitmap

获取符合索引条件的所有堆表元组指针组成的bitmap

index_bulk_delete

清理索引页面上的无效元组

index_vacuum_cleanup

索引页面清理之后的统计信息和空闲空间信息更新

index_build

扫描堆表数据,构造索引表数据

和堆表存储接口不同,由于openGauss支持多种索引结构(B-Tree,hash,GIN(generalized inverted index,通用倒排索引)等),每种索引结构内部的页面间组织方式以及扫描方式都不太相同,因此在上述接口中,没有直接定义底层的页面和元组操作,而是进一步调用了各个索引自己的访存方式。不同索引的底层访存接口,可以在pg_am系统表中查询得到。

4.2.6 行存储缓存机制

行存储缓存加载和淘汰机制如图4-20所示。

图4-20 行存储缓存和淘汰机制示意图

行存储堆表和索引表页面的缓存和淘汰机制主要包含以下几个部分。

1. 共享缓冲区内存页面数组下标哈希表

共享缓冲区内存页面数组下标哈希表用于将远大于内存容量的物理页面与内存中有限个数的内存页面建立映射关系。该映射关系通过一个分段、分区的全局共享哈希表结构实现。哈希表的键值为buftag(页面标签)结构体。该结构体由“rnode”、“forkNum”、“blockNum”三个成员组成。其中“rnode”对应行存储表物理文件名的主体命名;“forkNum”对应主体命名之后的后缀命名,通过主体命名和后缀命名,可以找到唯一的物理文件;而“blockNum”对应该物理文件中的页面号。因此,该三元组可以唯一确定任意一个行存储表物理文件中的物理页面位置。哈希表的内容值为与该物理页面对应的内存页面的“buffer id”(共享内存页面数组的下标)。

因为该哈希表是所有数据页面查询的入口,所以当存在并发查询时在该哈希表上的查询和修改操作会非常频繁。为了降低读写冲突,把该哈希表进行了分区,分区个数等于NUM_BUFFER_PARTITIONS宏的定义值。在对该哈希表进行查询或修改操作之前首先需要获取相应分区的共享锁或排他锁。考虑到当对该哈希表进行插入操作时待插入的三元组键值对应的物理页面大概率不在当前的共享缓冲区中,因此该哈希表的容量等于“g_instance.attr.attr_storage.NBuffers + NUM_BUFFER_PARTITIONS”。该表具体的定义代码如下:

typedef struct buftag {
    RelFileNode rnode; /* 表的物理文件位置结构体 */
    ForkNumber forkNum; /* 表的物理文件后缀信息 */
    BlockNumber blockNum; /* 页面号 */
} BufferTag;

2. 共享buffer desc数组

该数组有“g_instance.attr.attr_storage.NBuffers”个成员,与实际存储页面内容的共享buffer数组成员一一对应,用来存储相同“buffer id”(即这两个全局数组的下标)的数据页面的属性信息。该数组成员为BufferDesc结构体,具体定义代码如下:

typedef struct BufferDesc {
    BufferTag tag; /*缓冲区页面标签 */
    pg_atomic_uint32 state; /* 状态位、引用计数、使用历史计数 */
    int buf_id;    /*缓冲区下标 */
    ThreadId wait_backend_pid;
    LWLock* io_in_progress_lock;
    LWLock* content_lock;
    pg_atomic_uint64 rec_lsn;
    volatile uint64 dirty_queue_loc;
} BufferDesc;

(1) tag成员是该页面的(relfilenode,forknum,blocknum)三元组。
(2) state成员是该内存状态的标志位,主要包含BM_LOCKED(该buffer desc结构体内容的排他锁标志)、BM_DIRTY(脏页标志)、BM_VALID(有效页面标志)、BM_TAG_VALID(有效tag标志)、BM_IO_IN_PROGRESS(页面I/O状态标志)等。
(3) buf_id成员,是该成员在数组中的下标。
(4) wait_backend_pid成员,是等待页面unpin(取消引用)的线程的线程号。
(5) io_in_progress_lock成员,是用于管理页面并发I/O操作(从磁盘加载和写入磁盘)的轻量级锁。
(6) content_lock成员,是用于管理页面内容并发读写操作的轻量级锁。
(7) rec_lsn成员,是上次写入磁盘之后该页面第一次修改操作的日志lsn值。
(8) dirty_queue_loc成员,是该页面在全局脏页队列数组中的(取模)下标。

3. 共享buffer数组

该数组有“g_instance.attr.attr_storage.NBuffers”个成员,每个数组成员即为保存在内存中的行存储表页面内容。需要注意的是,每个buffer在代码中以一个整型变量来标识,该值从1开始递增,数值上等于“buffer id + 1”,即“数组下标加1”。

4. bgwriter线程组

该数组有“g_instance.attr.attr_storage.bgwriter_thread_num”个线程。每个“bgwriter”线程负责一定范围内(目前为均分)的共享内存页面的写入磁盘操作,如图4-20中所示。如果全局共享buffer数组的长度为12,一共有3个“bgwriter”线程,那么第1个“bgwriter”线程负责“buffer id 0 - buffer id 3”的内存页面的维护和写入磁盘;第2个“bgwriter”线程负责“buffer id 4 - buffer id 7”的内存页面的维护和写入磁盘;第3个“bgwriter”线程负责buffer id 8 - buffer id 11的内存页面的维护和写入磁盘。每个“bgwriter”进程在后台循环扫描自己负责的那些共享内存页面和它们的buffer desc状态,将被业务修改过的脏页收集起来,批量写入双写文件,然后写入表文件系统。对于刷完的内存页,将其状态变为非脏,并追加到空闲buffer id队列的尾部,用于后续业务加载其他当前不在共享缓冲区的物理页面。每个“bgwriter”线程的信息记录在BgWriterProc结构体中,该结构体的定义代码如下:

typedef struct BgWriterProc {
    PGPROC *proc;
    CkptSortItem *dirty_buf_list;
    uint32 dirty_list_size;
    int *cand_buf_list;
    volatile int cand_list_size;
    volatile int buf_id_start;
    pg_atomic_uint64 head;
    pg_atomic_uint64 tail;
    bool need_flush;
    volatile bool is_hibernating;
    ThrdDwCxt thrd_dw_cxt;
 volatile uint32 thread_last_flush;
    int32 next_scan_loc;
} BgWriterProc;

其中比较关键的几个成员含义是:
(1) dirty_buf_list为存储每批收集到的脏页面buffer id的数组。dirty_list_size为该数组的长度。
(2) cand_buf_list为存储写入磁盘之后非脏页面buffer id的队列数组(空闲buffer id数组)。cand_list_size为该数组的长度。
(3) buf_id_start为该bgwriter负责的共享内存区域的起始buffer id,该区域长度通过“g_instance.attr.attr_storage.NBuffers / g_instance.attr.attr_storage.bgwriter_thread_num”得到。
(4) head为当前空闲buffer id队列的队头数组下标,tail为当前空闲buffer id队列的队尾数组下标。
(5) next_scan_loc为上次bgwriter循环扫描时停止处的buffer id,下次收集脏页从该位置开始。

5. pagewriter线程组

“pagewriter”线程组由多个“pagewriter”线程组成,线程数量等于GUC参数(g_instance.ckpt_cxt_ctl->page_writer_procs.num)的值。“pagewriter”线程组分为主“pagewriter”线程和子“pagewriter”线程组。主“pagewriter”线程只有一个,负责从全局脏页队列数组中批量获取脏页面、将这些脏页批量写入双写文件、推进整个数据库的检查点(故障恢复点)、分发脏页给各个pagewriter线程,以及将分发给自己的那些脏页写入文件系统。子“pagewriter”线程组包括多个子“pagewriter”线程,负责将主“pagewriter”线程分发给自己的那些脏页写入文件系统。
每个“pagewriter”线程的信息保存在PageWriterProc结构体中,该结构体的定义代码如下:

typedef struct PageWriterProc {
    PGPROC* proc;
    volatile uint32 start_loc;
    volatile uint32 end_loc;
    volatile bool need_flush;
    volatile uint32 actual_flush_num;
} PageWriterProc;

其中:
(1) proc成员为“pagewriter”线程属性信息。
(2) start_loc为分配给本线程待写入磁盘的脏页在全量脏页队列中的起始位置。
(3) end_loc为分配给本线程待写入磁盘的脏页在全量脏页队列中的结尾位置。
(4) need_flush为是否有脏页被分配给本“pagewriter”的标志。
(5) actual_flush_num为本批实际写入磁盘的脏页个数(有些脏页在分配给本“pagewriter”线程之后,可能被“bgwriter”线程写入磁盘,或者被DROP(删除)类操作失效)。
“pagewriter”线程与“bgwriter”线程的差别:“bgwriter”线程主要负责将脏页写入磁盘,以便留出非脏的缓冲区页面用于加载新的物理数据页;“pagewriter”线程主要的任务是推进全局脏页队列数组的进度,从而推进整个数据库的检查点和故障恢复点。数据库的检查点是数据库(故障)重启时需要回放的日志的起始位置lsn。在检查点之前的那些日志涉及的数据页面修改,需要保证在检查点推进时刻已经写入磁盘。通过推进检查点的lsn,可以减少数据库宕机重启之后需要回放的日志量,从而降低整个系统的恢复时间目标(recovery time objective,RTO)。关于“pagewriter”的具体工作原理,将在“4.2.9 持久化及故障恢复机制”小节进行更详细的描述。

6. 双写文件

一般磁盘的最小I/O单位为1个扇区(512字节),大部分文件系统的I/O单位为8个扇区。数据库最小的I/O单位为一个页面(16个扇区),因此如果在写入磁盘过程中发生宕机,可能出现一个页面只有部分数据写入磁盘的情况,会影响当前日志恢复的一致性。为了解决上述问题,openGauss引入了双写文件。所有页面在写入文件系统之前,首先要写入双写文件,并且双写文件以“O_SYNC | O_DIRECT”模式打开,保证同步写入磁盘。因为双写文件是顺序追加的,所以即使采用同步写入磁盘,也不会带来太明显的性能损耗。在数据库恢复时,首先从双写文件中将可能存在的部分写入磁盘的页面进行修复,然后再回放日志进行日志恢复。
此外也可以采用FPW(full page write,全页写)技术解决部分数据写入磁盘问题:在每次检查点之后,对于某个页面首次修改的日志中记录完整的页面数据。但是为了保证I/O性能的稳定性,目前openGauss默认使用增量检查点机制(关于增量检查点机制,参见“4.2.9 持久化及故障恢复机制”节),而该机制与FPW技术无法兼容,所以在openGauss中目前采用双写技术来解决部分数据写入磁盘问题。
结合图4-20,缓冲区页面查找的流程如下。
(1) 计算“buffer tag”对应的哈希值和分区值。
(2) 对“buffer id”哈希表加分区共享锁,并查找“buffer tag”键值是否存在。
(3) 如果“buffer tag”键值存在,确认对应的磁盘页面是否已经加载上来。如果是,则直接返回对应的“buffer id + 1”;如果不是,则尝试加载到该“buffer id”对应的缓冲区内存中,然后返回“buffer id + 1”。
(4) 如果“buffer tag”键值不存在,则寻找一个“buffer id”来进行替换。首先尝试从各个“bgwriter”线程的空闲“buffer id”队列中获取可以用来替换的“buffer id”;如果所有“bgwriter” 线程的空闲buffer id队列都为空队列,那么采用clock-sweep算法,对整个buffer缓冲区进行遍历,并且每次遍历过程中将各个缓冲区的使用计数减一,直到找到一个使用计数为0的非脏页面,就将其作为用来替换的缓冲区。
(5) 找到替换的“buffer id”之后,按照分区号从小到大的顺序,对两个“buffer tag”对应的分区同时加上排他锁,插入新“buffer tag”对应的元素,删除原来“buffer tag”对应的元素。然后再按照分区号从小到大的顺序释放上述两个分区排他锁。
(6) 最后确认对应的磁盘页面是否已经加载上来。如果是,则直接返回上述被替换的“buffer id + 1”;如果不是,则尝试加载到该“buffer id”对应的buffer内存中,然后返回“buffer id + 1”。
行存储共享缓冲区访问的主要接口和含义如表4-27所示。

表4-27 行存储共享缓冲区访问的主要接口

函数名

操作含义

ReadBufferExtended

读、写业务线程从共享缓冲区获取页面用于读、写查询

ReadBufferWithoutRelcache

恢复线程从共享缓冲区获取页面用于回放日志

ReadBufferForRemote

备机页面修复线程从共享缓冲区获取页面用于修复主机损坏页面

4.2.7 cstore

列存储格式是OLAP类数据库系统最常用的数据格式,适合复杂查询、范围统计类查询的在线分析型处理系统。本节主要介绍openGauss数据库内核中cstore列存储格式的实现方式。

1. cstore整体框架

cstore列存储格式整体框架如图4-21所示。其主要模块代码分布参见4.2.1节。与行存储格式不同,cstore列存储的主体数据文件以CU为I/O单元,只支持追加写操作,因此cstore只有读共享缓冲区。CU间和CU内的可见性由对应的CUDESE表(astore表)决定,因此其可见性和并发控制原理与行存储astore基本相同。

2. cstore存储单元结构

图4-22 CU结构示意图

如图4-22所述,cstore的存储单元是CU,分别包括以下内容。
(1) CU的CRC值,为CU结构中除CRC成员之外,其他所有字节计算出的32位CRC值。
(2) CU的magic值,为插入CU的事务号。
(3) CU的属性值,为16位标志值,包括CU是否包含NULL行、CU使用的压缩算法等CU粒度属性信息。
(4) 压缩后NULL值位图长度,如果属性值中标识该CU包含NULL行,则本CU在实际数据内容开始处包含NULL值位图,此处储存该位图的字节长度,如果该CU不包含NULL行,则无该成员。
(5) 压缩前数据长度,即CU数据内容在压缩前的字节长度,用于读取CU时进行内存申请和校验。
(6) 压缩后数据长度,即CU数据内容在压缩后的字节长度,用于插入CU时进行内存申请和校验。
(7) 压缩后NULL值位图内容,如果属性值中标识该CU包含NULL行,则该成员即为每行的NULL值位图,否则无该成员。
(8) 压缩后数据内容,即实际写入磁盘的CU主体数据内容。
每个CU最多保存对应字段的MAX_BATCH_ROWS行(默认60000行)数据。相邻CU之间按8kB对齐。
CU模块提供的主要CU操作接口如表4-28所示。

表4-28 CU操作接口

函数名称

接口含义

AppendCuData

向组装的CU中增加一行(仅对应字段)

Compress

压缩(若需)和组装CU

FillCompressBufHeader

填充CU头部

CompressNullBitmapIfNeed

压缩NULL值位图

CompressData

压缩CU数据

CUDataEncrypt

加密CU数据

ToVector

将CU数据解构为向量数组结构

UnCompress

解压(若需)和解析CU

UnCompressHeader

解析CU头部内容

UnCompressNullBitmapIfNeed

解压NULL值位图

UnCompressData

解压CU数据

CUDataDecrypt

解密CU数据

3. cstore多版本机制

cstore支持完整事务语义的DML查询,原理如下。
(1) CU间的可见性:每个CU对应CUDESC表(astore行存储表)中的一行记录(一对一),该CU的可见性完全取决于该行记录的可见性。
(2) 同一个CU内不同行的可见性:每个CU的内部可见性对应CUDESC表中的一行(多对一),该行的bitmap字段为最长MAX_BATCH_ROWS个bit的删除位图(bit 1表示删除,bit 0表示未删除),通过该位图记录的可见性和多版本,来支持CU内不同行的可见性。同时由于DML操作都是行粒度操作的,因此对于行号范围相同的、不同字段的多个CU均对应同一行位图记录。
(3) CU文件读写并发控制:CU文件自身为APPEND-ONLY,只在追加时对文件大小扩展进行加锁互斥,无须其他并发控制机制。
(4) 同一个字段的不同CU,对应严格单调递增的cu_id编号,存储在对应的CUDESC表记录中,该cu_id的获取通过图4-24中的文件扩展锁来进行并发控制。
(5) 对于cstore表的单条插入以及更新操作,提供与每个cstore表对应的delta表(astore行存储表),来接收单条插入或单条更新的元组,以降低CU文件的碎片化。
可见,cstore表的可见性依赖于对应CUDESC表中记录的可见性。一个CUDESC表的结构如表4-29所示,其与CU的对应关系如图4-23所示。

表4-29 CUDESC表的结构

字段名

类型

含义

col_id

integer

字段序号,即该cstore列存储表的第几个字段;特殊的,对于CU位图记录,该字段恒为-10

cu_id

oid

CU序号,即该列的第几个CU

min

text

该CU中该字段的最小值

max

text

该CU中该字段的最大值

row_count

integer

该CU中的行数

cu_mode

integer

CU模式

size

bigint

该CU大小

cu_pointer

text

该CU偏移(8k对齐);特殊的,对于CU位图记录,该字段为删除位图的二进制内容

magic

integer

该CU magic号,与CU头部的magic相同,校验用

extra

text

预留字段

图4-23 CUDESC表和CU对应关系示意图

如图4-24、图4-25所示,下面结合并发插入和并发插入查询2种具体场景,介绍openGauss中cstore多版本的具体实现方法。

图4-24 cstore表并发插入示意图

图4-25 cstore表并发插入和查询示意图

1)并发插入操作
对于并发的插入操作,会话1和会话2首先分别在各自的局部内存中完成待插入CU的拼接。然后假设会话1先获取到cstore表的扩展锁,那么会话2会阻塞在该锁上。在持锁阶段,会话1申请到该字段下一个cuid 1001,预占了该cu文件0 - 6 K的内容(即cuid 1001的内容大小),将cuid的大小、偏移以及cuid 1001头部部分信息填充到CUDESC记录中,并完成CUDESC记录的插入。接着,会话1放锁,并将cuid 1001的内容写入到CU对应偏移处,记录日志,再将删除位图记录插入CUDESC表中。当会话1释放cstore表的扩展锁之后,会话2就可以获取到该锁,然后,类似会话1的后续操作,完成cuid 1002的插入操作。
2) 并发插入和查询操作
假设在上述会话2的插入事务(事务号101)执行过程中,有并发的查询操作执行。对于查询操作,首先基于col_id和cuid这两个索引键对CUDESC表做索引扫描。由于事务号101在查询的快照中,因此cuid 1002的所有记录对于查询事务不可见,查询事务只能看到cuid 1001(事务号100)的那些记录。然后,查询事务根据CUDESC记录中对应的CU文件偏移和CU大小,将cuid 1001的数据从磁盘文件或缓存中加载到局部内存中,并拼接成向量数组的形式返回。

4. cstore访存接口和索引机制

cstore访存接口如表4-30所示,主要包括扫描、插入、删除和查询操作。

表4-30 cstore访存接口

接口名称

接口含义

CStoreBeginScan

开启cstore扫描

CStore::RunScan

执行cstore扫描,根据执行计划,内层执行cstore顺序扫描或者cstore min-max过滤扫描

CStoreGetNextBatch

继续扫描,返回下一批向量数组

CStoreEndScan

结束cstore扫描

CStore::CStoreScan

cstore顺序扫描

CStore::CStoreMinMaxScan

cstore min-max过滤扫描

CStoreInsert::BatchInsert(VectorBatch)

将输入的向量数组批量插入cstore表中

CStoreInsert::BatchInsert(bulkload_rows)

将输入的多行数组插入cstore表中

CStoreInsert::BatchInsertCommon

将一批多行数组(最多MAX_BATCH_ROWS行)插入cstore表各个列的CU文件中、插入对应CUDESC表记录、插入索引

CStoreInsert::InsertDeltaTable

将一批多行数组插入cstore表对应的delta表中

InsertIdxTableIfNeed

将一批多行数组插入cstore表的索引表中

CStoreDelete::PutDeleteBatch

将一批待删除的向量数组暂存到局部数据结构中,如果达到局部内存上限,则触发一下删除操作

CStoreDelete::PutDeleteBatchForTable

CStoreDelete::PutDeleteBatch对于普通cstore表的内层实现

CStoreDelete::PutDeleteBatchForPartition

CStoreDelete::PutDeleteBatch对于分区cstore表的内层实现

CStoreDelete::PutDeleteBatchForUpdate

CStoreDelete::PutDeleteBatch对于更新cstore表操作的内层实现(更新操作由删除操作和插入操作组合而成)

CStoreDelete::ExecDelete

执行cstore表删除,内层调用普通cstore表删除或分区cstore表删除

CStoreDelete::ExecDeleteForTable

执行普通cstore表删除

CStoreDelete::ExecDeleteForPartition

执行分区cstore表删除

CStoreDelete::ExecDelete(rowid)

删除cstore表中特定一行的接口

CStoreUpdate::ExecUpdate

执行cstore表更新

cstore表查询执行流程,可以参考图4-26中所示。其中,灰色部分实际上是在初始化cstore扫描阶段执行的,根据每个字段的具体类型,绑定不同的CU扫描和解析函数,主要有FillVector、FillVectorByTids、FillVectorLateRead3类CU扫描解析接口。

图4-26 cstore表查询流程示意图

cstore表插入执行流程,可以参考图4-27所示。其中灰色部分内的具体流程可以参考图4-24、图4-25中所示。当满足以下3个条件时,可以支持delta表插入:
(1) 打开enable_delta_store GUC参数。
(2) 该批向量数组为本次导入的最后一批向量数组。
(3) 该批向量数组的行数小于delta表插入的阈值。

图4-27 cstore表插入流程示意图
cstore表的删除流程主要分为两步。

(1) 如果存在delta表,那么先从delta表中删除满足谓词条件的记录。
(2) 在CUDESC表中更新待删除行所在CU的删除位图记录。
cstore表的更新操作由删除操作和插入操作组合而成,流程不再赘述。
openGauss的cstore表支持psort和cbtree两种索引。
psort索引是一种局部排序聚簇索引。psort索引表的组织形式也是cstore表,该cstore表的字段包括索引键中的各个字段,再加上对应的行号(TID)字段。如图4-28所示,将一定数量的记录按索引键经过排序聚簇之后,与TID字段共同拼装成向量数组之后,插入psort索引cstore表中,插入流程和上面cstore表插入流程相同。

图4-28 psort索引插入原理图

查询时如果使用psort索引扫描,会首先扫描psort索引cstore表(扫描方式和上面cstore表扫描流程相同)。在一个psort索引CU的内部,由于做了局部聚簇索引,因此可以使用基于索引键的二分查找方式,快速找到符合索引条件的记录在该psort索引中的行号,该行的TID字段值即为该条记录在cstore主表中的行号。上述流程如图4-29所示。值得一提的是由于做了局部聚簇索引,因此在索引cstore表扫描过程中,在真正加载索引表CU文件之前,可以通过CUDESC中的min max做到非常高效的初筛过滤。

cstore表的cbtree索引和行存储表的B-Tree索引在结构和使用方式上几乎完全一致,相关原理可以参考行存储索引章节(“4.2.5 行存储索引机制”节),此处不再赘述。

openGauss cstore表索引对外提供的主要接口如表4-31所示。

表4-31 cstore表索引对外接口

接口名称

接口含义

psortgettuple

通过psort索引,返回下一条满足索引条件的元组。伪接口,实际psort索引扫描通过CStore::RunScan实现

psortgetbitmap

通过psort索引,返回满足索引条件的元组的tid bitmap。伪接口,实际psort索引扫描通过CStore::RunScan实现

psortbuild

构建psort索引表数据。主要流程包括,从cstore主表中扫描数据、局部聚簇排序、插入到psort索引cstore表中

cbtreegettuple

通过cbtree索引,返回下一条满足索引条件的元组。内部和btgettuple都是通过调用_bt_gettuple_internal函数实现的

cbtreegetbitmap

通过cbtree索引,返回满足索引条件的元组的tid bitmap。内部和btgetbitmap都是通过调用_bt_next函数实现的

cbtreebuild

构建cbtree索引表数据。内部实现与btbuild类似,先后调用_bt_spoolinit、CStoreGetNextBatch、_bt_spool、_bt_leafbuild和_bt_spooldestroy等几个主要函数实现。与btbuild区别在于,B-Tree的构建过程中,扫描堆表是通过heapam接口实现的,而cbtree扫描的是cstore表,因此使用的是CStoreGetNextBatch

5. cstore缓存机制

考虑到cstore列存储格式主要面向只读查询居多的OLAP类业务,因此openGauss提供只读的共享CU缓冲区机制。
openGauss中CU只读共享缓冲区的结构如图4-30所示。和行存储页面粒度的共享缓冲区类似,最上层为共享哈希表,哈希表键值为CU的slot类型、relfilenode、colid、cuid、cupointer构成的五元组,哈希表的记录值为该CU对应的缓冲区槽位slot id(对应行存储共享缓区的buffer id)。在全局CacheDesc数组中,用CacheDesc结构体记录与slot id对应的缓存槽位的状态信息(对应行存储缓冲区的BufferDesc结构体)。在共享CU数组中,用CU结构体记录与slot id对应的缓存CU的结构体信息。
与行存储固定的页面大小不同,不同CU的大小可能是不同的(行存储页面大小都是8 K),因此上述CU槽位只记录指向实际内存中CU数据的指针。另一方面为了保证共享内存大小可控,通过另外的全局变量来记录已经申请的有效槽位中所有CU的大小总和。

图4-30 CU只读共享缓存结构示意图

CU只读共享缓冲区的工作机制如图4-31所示。
(1) 当从磁盘读取一个CU放如Cache Mgr时,需要从FreeSlotList里拿到一个free slot(空闲槽位)存放CU,然后插入到哈希表中。
(2) 当FreeSlotList为NULL的时,需要根据LRU算法淘汰掉一个slot(槽位),释放CU data占的内存,减小CU总大小计数,并从哈希表中删除,然后存放新的CU,再插入哈希表中。
(3) 缓存大小可以配置。如果内存超过设置的大小,需要淘汰掉适量的slot,并释放CU data占用的内存。
(4) 支持缓存压缩态的CU或解压态的CU两种模式,可以通过配置文件修改,同时只能存在一种模式。

图4-31 CU只读共享缓存读取示意图
与CU只读共享缓冲区相关的关键数据结构代码如下:
typedef struct CUSlotTag {
    RelFileNodeOld m_rnode;
int m_colId;
    int32 m_CUId;
    uint32 m_padding;
    CUPointer m_cuPtr;
} CUSlotTag;
/* slot id哈希表键值主要部分,各个成员的含义从命名中可以清晰看出 */

typedef struct DataSlotTag {
    DataSlotTagKey slotTag;
    CacheType slotType;
} DataSlotTag;
/* slot id哈希表键值结构体,成员包括CUSlotTag与slot类型(CU、OBS外表等) */

typedef struct CacheLookupEnt {
    CacheTag cache_tag;
    CacheSlotId_t slot_id;
} CacheLookupEnt;
/* slot id哈希表记录结构体,成员包括哈希表键值和对应的slot id  */

typedef struct CacheDesc {
    uint16 m_usage_count;
    uint16 m_ring_count;
    uint32 m_refcount;
    CacheTag m_cache_tag;
    CacheSlotId_t m_slot_id;
    CacheSlotId_t m_freeNext;
    LWLock *m_iobusy_lock;
    LWLock *m_compress_lock;
    /*The data size in the one slot.*/
    int m_datablock_size;
    bool m_refreshing;
    slock_t m_slot_hdr_lock;
    CacheFlags m_flag;
} CacheDesc;

/* CU共享缓冲区槽位状态结构体,其中m_usage_count、m_ring_count为LRU淘汰算法需要的使用计数,m_refcount为判断能否淘汰的被引用计数,m_freeNext指向下一次空闲的slot槽位(如果本槽位在free list中的话,否则m_freeNext恒等于-2),m_iobusy_lock为I/O并发控制锁,m_compress_lock为压缩并发控制锁,m_datablock_size为CU实际数据的大小,m_slot_hdr_lock保护整个CacheDesc的并发读写操作,m_flag表示槽位状态(包括全新、有效、freelist中、空闲、I/O中、错误等状态)*/ 

4.2.8 日志系统

内存是一种易失性存储介质,在断电等场景下存储在内存介质中的数据会丢失。为了保障数据的可靠性需要将共享缓冲区中的脏页写入磁盘,此即数据的持久化过程。对于最常用的持久化存储介质磁盘,由于每次读写操作都有一个“启动”代价,导致磁盘的读写操作频率有一个上限。即使是超高性能的SSD磁盘,其读写频率也只能达到10000次/秒左右。如果多个磁盘读写请求的数据在磁盘上是相邻的,就可以被合并为一次读写操作。因为合并后可以等效降低读写频率,所以磁盘顺序读写的性能通常要远优于随机读写。由于如上原因,数据库通常都采用顺序追加的预写日志(write ahead log,WAL)来记录用户事务对数据库页面的修改。对于物理表文件所对应的共享内存中的脏页会等待合适的时机再异步、批量地写入磁盘。

日志可以按照用户对数据库不同的操作类型分为以下几类,每种类型日志分别对应一种资源管理器,负责封装该日志的子类、具体结构以及回放逻辑等。如表4-32所示。

表4-32 日志类型

日志类型名字

资源管理器类型

对应操作

XLOG

RM_XLOG_ID

pg_control控制文件修改相关的日志,包括检查点推进、事务号分发、参数修改、备份结束等

Transaction

RM_XACT_ID

事务控制类日志,包括事务提交、回滚、准备、提交准备、回滚准备等

Storage

RM_SMGR_ID

底层物理文件操作类日志,包括文件的创建和截断

CLOG

RM_CLOG_ID

事务日志修改类日志,包括CLOG拓展、CLOG标记等

Database

RM_DBASE_ID

数据库DDL类日志,包括创建、删除、更改数据库等

Tablespace

RM_TBLSPC_ID

表空间DDL类日志,包括创建、删除、更新表空间等

MultiXact

RM_MULTIXACT_ID

MultiXact类日志,包括MultiXact槽位的创建、成员页面的清空、偏移页面的清空等

RelMap

RM_RELMAP_ID

表文件名字典文件修改日志

Standby

RM_STANDBY_ID

备机支持只读相关日志

Heap

RM_HEAP_ID

行存储文件修改类日志,包括插入、删除、更新、pd_base_xid修改、新页面、加锁等操作

Heap2

RM_HEAP2_ID

行存储文件修改类日志,包括空闲空间清理、元组冻结、元组可见性修改、批量插入等

Heap3

RM_HEAP3_ID

行存储文件修改类日志,目前该类日志不再使用,后续可以拓展

Btree

RM_BTREE_ID

B-Tree索引修改相关日志,包括插入、节点分裂、插入叶子节点、空闲空间清理等

hash

RM_HASH_ID

hash索引修改相关日志

Gin

RM_GIN_ID

GIN索引(generalized inverted index,通用倒排索引)修改相关日志

Gist

RM_GIST_ID

Gist索引修改相关日志

SPGist

RM_SPGIST_ID

SPGist索引相关日志

Sequence

RM_SEQ_ID

序列修改相关日志,包括序列推进、属性更新等

Slot

RM_SLOT_ID

流复制槽修改相关日志,包括流复制槽的创建、删除、推进等

MOT

RM_MOT_ID

内存引擎相关日志

openGauss日志文件、页面和日志记录的格式如图4-32所示。

图4-32 日志文件、页面和记录格式示意图
日志文件在逻辑意义上是一个最大长度为64位无符号整数的连续文件。在物理分布上,该逻辑文件按XLOG_SEG_SIZE大小(默认为16MB)切断,每段日志文件的命名规则为“时间线+日志id号+该id内段号”。“时间线”用于表示该日志文件属于数据库的哪个“生命历程”,在时间点恢复功能中使用

以上是关于openGauss数据库源码解析系列文章——存储引擎源码解析的主要内容,如果未能解决你的问题,请参考以下文章

openGauss数据库源码解析系列文章——存储引擎源码解析

openGauss数据库源码解析系列文章——存储引擎源码解析

openGauss数据库源码解析系列文章——公共组件源码解析(上)

openGauss数据库源码解析系列文章—— 执行器解析

openGauss数据库源码解析系列文章——数据安全技术(上)

openGauss数据库源码解析系列文章—— 事务机制源码解析