上一篇我们详细讲述“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信息,才能判断对应堆表元组对本查询是否可见。将导致以下几个现象:
-
对于被删除的astore堆表元组,其空间(至少其元组指针)不能立刻被释放,否则会留下悬空的索引指针,导致后续查询出现问题。
-
对于被更新的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内段号”。“时间线”用于表示该日志文件属于数据库的哪个“生命历程”,在时间点恢复功能中使用。“日志id号”从0开始,按每4G大小递增加1。“id内段号”表示该16MB大小的段文件在该4G“日志id号”内是第几段,范围为0至255。上面3个值在日志段文件名中都以16进制方式显示。
每个日志段文件都可以用XLOG_BLCKSZ(默认8kB)为单位,划分为多个页面。每个8kB页面中,起始位置为页面头,如果该页是整个段文件的第一个页面,那么页面头为一个长页头(XLogLongPageHeader),否则为一个正常页头(短页头)(XLogPageHeader)。在页头之后跟着一条或多条日志记录。每个日志记录对应一个数据库的某种操作。为了降低日志记录的大小(日志写入磁盘时延是影响事务时延的主要因素之一),每条日志内部都是紧密排列的。各条日志之间按8字节(64位系统)对齐。一条日志记录可以跨两个及以上的日志页面,其最大长度限制为1G。对于跨页的日志记录,其后续日志页面页头的标志位XLP_FIRST_IS_CONTRECORD会被置为1。
长、短页头结构体的定义如下,其中存储了用于校验的magic信息、页面标志位信息、时间线信息、页面(在整个逻辑日志文件中的)偏移信息、有效长度信息、系统识别号信息、段尺寸信息、页尺寸信息等。
短页头结构体的代码如下:
typedef struct XLogPageHeaderData {
uint16 xlp_magic; /* 日志magic校验信息 */
uint16 xlp_info; /* 标志位 */
TimeLineID xlp_tli; /* 该页面第一条日志的时间线 */
XLogRecPtr xlp_pageaddr; /* 该页面起始位置的lsn */
uint32 xlp_rem_len; /*如果是跨页记录,本字段描述该跨页记录在本页面内的剩余长度 */
} XLogPageHeaderData;
长页头结构体的代码如下:
typedef struct XLogLongPageHeaderData {
XLogPageHeaderData std; /* 短页头 */
uint64 xlp_sysid; /* 系统标识符,和pg_control文件中相同 */
uint32 xlp_seg_size; /* 单个日志文件的大小 */
uint32 xlp_xlog_blcksz; /* 单个日志页面的大小 */
} XLogLongPageHeaderData;
单条日志记录的结构如图4-32中所示,其由5个部分组成:
(1) 日志记录头,对应XLogRecord结构体,存储了记录长度、主备任期号、事务号、上一条日志记录起始偏移、标志位、所属的资源管理器、crc校验值等信息。
(2) 1 - 33个相关页面的元信息,对应XLogRecordBlockHeader结构体,存储了页面下标(0 - 32)、页面对应的物理文件的后缀、标志位、页面数据长度等信息;如果该日志没有对应的页面信息,则无该部分。
(3) 日志数据主体的元信息,对应(长/短)XLogRecordDataHeader结构体,记录了特殊的页面下标,用于和第二部分区分,以及主体数据的长度。
(4) 1 - 33个相关页面的数据;如果该日志没有对应的页面信息,则无该部分。
(5) 日志数据主体。
这5部分对应的结构体代码如下。如上所述,在记录日志内容时,每个部分之间是紧密挨着的,无补空字符。如果一个日志记录没有对应的相关页面信息,那么第2和第4部分将被跳过。
typedef struct XLogRecord {
uint32 xl_tot_len; /* 记录总长度 */
uint32 xl_term;
TransactionId xl_xid; /* 事务号 */
XLogRecPtr xl_prev; /* 前一条记录的起始位置lsn */
uint8 xl_info; /* 标志位 */
RmgrId xl_rmid; /* 资源管理器编号 */
int2 xl_bucket_id;
pg_crc32c xl_crc; /* 该记录的CRC校验值 */
/* 后面紧接XLogRecordBlockHeaders或XLogRecordDataHeader结构体 */
} XLogRecord;
typedef struct XLogRecordBlockHeader {
uint8 id; /* 页面下标(即该记录中包含的第几个页面信息) */
uint8 fork_flags; /* 页面属于哪个后缀文件,以及标志位 */
uint16 data_length; /* 实际页面相关的数据长度(紧接该头部结构体) */
/* 如果BKPBLOCK_HAS_IMAGE标志位为1,后面紧跟XLogRecordBlockImageHeader结构体以及页面内连续数据 */
/* 如果BKPBLOCK_SAME_REL标志位没有设置,后面紧跟RelFileNode结构体 */
/* 后面紧跟页面号 */
} XLogRecordBlockHeader;
typedef struct XLogRecordDataHeaderShort {
uint8 id; /* 特殊的XLR_BLOCK_ID_DATA_SHORT页面下标 */
uint8 data_length; /* 短记录数据长度 */
} XLogRecordDataHeaderShort;
typedef struct XLogRecordDataHeaderLong {
uint8 id; /* 特殊的XLR_BLOCK_ID_DATA_LONG页面下标 */
/* 后面紧跟长记录长度,无对齐 */
} XLogRecordDataHeaderLong;
单条日志记录的操作接口主要分为插入(写)和读接口。其中,一个完整的日志插入操作一般包含以下几步接口,如表4-33所示。
表4-33 日志插入操作
步骤序号 |
接口名称 |
对应操作 |
---|---|---|
1 |
XLogBeginInsert |
初始化日志插入相关的全局变量 |
2 |
XLogRegisterData |
注册该日志记录的主体数据 |
3 |
XLogRegisterBuffer/ XLogRegisterBlock |
注册该日志记录相关页面的元信息 |
4 |
XLogRegisterBufData |
注册该日志记录相关页面的数据 |
5 |
XLogInsert |
执行真正的日志插入,包含5.1和5.2 |
5.1 |
XLogRecordAssemble |
将上述注册的所有日志信息,按照图4-32中所示的紧密排列的5部分,重新组合成完整的二进制串 |
5.2 |
XLogInsertRecord |
在整个逻辑日志中,预占偏移和长度,计算CRC,将完整的日志记录拷贝到日志共享缓冲区中 |
日志的读接口为XLogReadRecord接口。该接口从指定的日志偏移处(或上次读到的那条记录结尾位置处)开始读取和解析下一条完整的日志记录。如果当前缓存的日志段文件页面中无法读完,那么会调用ReadPageInternal接口加载下一个日志段文件页面到内存中继续读取,直到读完所有等于日志头部xl_tot_len长度的日志数据。然后,调用DecodeXLogRecord接口,将日志记录按图4-32中所示的5个组成部分进行解析。
日志文件读写的最小I/O粒度为一个页面。在事务执行过程中,只会进行(顺序追加)写日志操作。为了提高写日志的性能,在共享内存中,单独开辟一片特定大小的区域,作为写日志页面的共享缓冲区。对该共享缓冲区的并发操作(拷贝日志记录到单个页面中、淘汰lsn过老的页面、读取单个页面并写入磁盘)是事务执行流程中的关键瓶颈之一,对整个数据库系统的并发能力至关重要。
图4-33 并发日志写入流程示意图 如图4-33所示,在openGauss中对该共享缓冲区的操作采用Numa-aware的同步机制,具体步骤如下。 (1) 业务线程在本地内存中将日志记录组装成图4-32中所示的、5部分组成的字节流
(2) 找到本线程所绑定的NUMA Node对应的日志插入锁组,并在该锁组中随机找一个槽位对应的锁。
(3) 检查该锁的组头线程号。如果没有说明本线程是第一个请求该锁的,那么这个锁上所有的写日志请求将由本线程来执行,将锁的组头线程号设置为本线程号;否则说明已经存在这批写日志请求的组头线程,记录下当前组头线程的线程号,并将自己加入到这批的插入组队列中,等待组头线程完成日志插入。
(4) 对于组头线程,获取该日志插入锁的排他锁。
(5) 为该组所有的插入线程在逻辑日志文件中占位,即对当前该文件的插入偏移进行原子CAS(compare and swap,比较后交换)操作。
(6) 将该组所有后台线程本地内存中的日志依次拷贝到日志共享缓冲区的对应页面中。每当需要拷贝到下一个共享内存页面时,需要判断下一个页面对应的逻辑页面号是否和插入者的预期页面号一致(因为共享内存有限,因此同一个共享内存页面对应取模相同的逻辑页面)。首先,将自己预期的逻辑页面号,写入当前持有的日志插入锁的槽位中,然后进行上述判断。如果不一致,即共享内存页面当前的逻辑页面号比插入者预期的逻辑页面号要小,那么需要将该页面数据从共享内存中写入到磁盘,然后才能复用为新的逻辑页面号。为了防止可能还有并发业务线程在向该共享内存页面拷贝属于当前逻辑页面号的日志数据,因此需要阻塞遍历每个日志插入者持有的插入锁,直到日志插入锁被释放,或者被持有的插入锁的逻辑页面号大于目标共享内存页面中现有的逻辑页面号。经过上述检查之后,就可以保证没有并发的业务线程还在对该共享内存页面写入对应当前逻辑页面号的日志数据,因此可以将其内容写入磁盘,并更新其对应的逻辑页面号为目标逻辑页面号。
(7) 重复上一步操作,直到把该组所有后台线程待插入的日志记录拷贝完。
(8) 释放日志插入锁。
(9) 唤醒本组所有后台线程。
4.2.9 持久化及故障恢复机制
1. 行存储持久化和检查点机制
如“4.2.8 日志系统”节中所述,通过采用WAL日志的方式可以在对性能影响较小的情况下保障用户事务对数据库修改的持久化。然而如果只是依赖日志来保障持久化的话,那么数据库服务(故障)重启之后将需要回放大量的日志数据量,这会导致很大的RTO,对业务的可用性影响极大。因此共享缓冲区中的脏页也需要异步地写入磁盘中,来减少宕机重启后所需要回放的日志数据量,降低系统的RTO时间。
如果数据库系统在事务提交之后、异步写入磁盘的脏页写入磁盘之前发生宕机,那么需要在数据库再次启动之后,首先把那些宕机之前还没有来得及写入磁盘的脏页上的修改所对应的日志进行回放,使得这些脏页可以恢复到宕机之前的内容。
基于如上原理,可以得出数据库持久化的一个关键是:在宕机重启的时候,通过某种机制确定从WAL的哪个lsn开始进行恢复;可以保证在该lsn之前的那些日志,它们涉及的数据页面修改已经在宕机之前完成写入磁盘。这个恢复起始的lsn,即是数据库的检查点。
在“4.2.6 行存储缓存机制”节介绍行存储缓存加载和淘汰机制中,已经知道参与脏页写入磁盘的主要有两类线程:bgwriter和pagewriter。前者负责脏页持久化的主体工作;后者负责数据库检查点lsn的推进。openGauss采用一个无锁的全局脏页队列数组来依次记录曾经被用户写操作置脏的那些数据页面。该队列数组成员为DirtyPageQueueSlot结构体,定义代码如下,其中:buffer为队列成员对应的buffer(该值为buffer id + 1),slot_state为该队列成员的状态。
typedef struct DirtyPageQueueSlot {
volatile int buffer;
pg_atomic_uint32 slot_state;
} DirtyPageQueueSlot;
图4-34 全局脏页队列的运行机制和检查点的推进机制 全局脏页队列的运作机制如图4-34所示,它的实现方式是一个多生产者、单消费者的循环数组。单个/多个业务线程是脏页队列的生产者,在其要修改数据页面之前,首先判断该页面buffer desc的首次脏页lsn是否非0:若该脏页buffer desc中的首次脏页lsn已经非0,说明该脏页在之前置脏的时候就已经被加入到脏页队列中,那么本次就跳过加入脏页队列的步骤;否则,对当前脏页队列的tail位置进行CAS加1操作,完成队列占位,同时,在上述CAS操作中,获取了脏页队列的lsn位置lsn1。然后,将占据的槽位位置(即CAS之前的tail值)和lsn1记录到脏页的buffer desc中。接着,将脏页的“buffer id”记录到占位的槽位中,再将槽位状态置为valid。最后,记录页面修改的日志,并尝试将该日志的位置lsn2更新到脏页队列的lsn中(如果此时脏页队列的lsn值已经被其他写业务更新为更大的值,则本线程就不更新了,也是一个CAS操作)。
基于上面这种机制,当将脏页队列中某个成员对应的脏页写入磁盘之后,检查点即可更新到该脏页“buffer desc”中记录的lsn位置。小于该lsn位置的日志,它们对应修改的页面,已经在记录这些日志之前就被加入到脏页队列中,亦即这些脏页在全局脏页队列中的位置一定比当前脏页更靠前,因此一定已经保证写入磁盘了。在图4-34中,“pagewriter”线程作为全局脏页队列唯一的消费者,负责从脏页队列中批量获取待写入磁盘的脏页,在完成写入磁盘操作之后,“pagewriter”自身不负责检查点的推进,而只是推进整个脏页队列的队头到下一个待写入磁盘的槽位位置。
实际检查点的推进由“Checkpointer”线程来负责。这是因为“pagewriter” 线程的写入磁盘操作,只是将共享缓冲区中的脏页写入到文件系统的缓存中,(由于文件系统的I/O合并优化)此时可能并没有真正写入磁盘。因此,在“Checkpointer”线程中,其先获取当前全局脏页队列的队头位置,以及对应槽位中脏页的首次脏页lsn值,然后对截至目前所有被写入文件系统的文件进行fsync(刷盘)操作,保证文件系统将它们写入物理磁盘中。然后就可以将上述lsn值作为检查点位置更新到control文件中,用于数据库重启之后回放日志的起始位置。
上述这套持久化和检查点推进机制的主要控制信息,保存在knl_g_ckpt_context结构体中,该结构体定义代码如下:
typedef struct knl_g_ckpt_context {
uint64 dirty_page_queue_reclsn;
uint64 dirty_page_queue_tail;
CkptSortItem* CkptBufferIds;
/* 脏页队列相关成员 */
DirtyPageQueueSlot* dirty_page_queue;
uint64 dirty_page_queue_size;
pg_atomic_uint64 dirty_page_queue_head;
pg_atomic_uint32 actual_dirty_page_num;
/* pagewriter线程相关成员 */
PageWriterProcs page_writer_procs;
uint64 page_writer_actual_flush;
volatile uint64 page_writer_last_flush;
/* 全量检查点相关信息成员 */
volatile bool flush_all_dirty_page;
volatile uint64 full_ckpt_expected_flush_loc;
volatile uint64 full_ckpt_redo_ptr;
volatile uint32 current_page_writer_count;
volatile XLogRecPtr page_writer_xlog_flush_loc;
volatile LWLock *backend_wait_lock;
volatile bool page_writer_can_exit;
volatile bool ckpt_need_fast_flush;
/* 检查点刷页相关统计信息(除数据页面外) */
int64 ckpt_clog_flush_num;
int64 ckpt_csnlog_flush_num;
int64 ckpt_multixact_flush_num;
int64 ckpt_predicate_flush_num;
int64 ckpt_twophase_flush_num;
volatile XLogRecPtr ckpt_current_redo_point;
uint64 pad[TWO_UINT64_SLOT];
} knl_g_ckpt_context;
其中和当前上述检查点机制相关的成员有:
(1) dirty_page_queue_reclsn是脏页队列的lsn位置,dirty_page_queue_tail是脏页队列的队尾,这两个成员构成一个16字节的整体,通过128位的CAS操作进行整体原子读、写操作,保证脏页队列中每个成员记录的lsn一定随着入队顺序单调递增。
(2) CkptBufferIds是每批pagewriter待刷脏页数组。
(3) dirty_page_queue是全局脏页队列数组。
(4) dirty_page_queue_size是脏页数组长度,等于“g_instance.attr.attr_storage.NBuffers * PAGE_QUEUE_SLOT_MULTI_NBUFFERS”,当前PAGE_QUEUE_SLOT_MULTI_NBUFFERS取值5,以防止脏页队列因为DDL(data definition language,数据定义语言)等操作引入的空洞过多,导致脏页队列撑满阻塞业务的场景。
(5) dirty_page_queue_head是脏页队列头部。
(6) actual_dirty_page_num是脏页队列中实际的脏页数量。
2. 故障恢复机制
当数据库发生宕机重启之后需要从检查点位置开始回放之后所有的日志。不同类型的日志的回放逻辑由对应的资源管理器来实现。
当用户业务压力较大时会同时有很多业务线程并发执行事务和日志记录的插入,单位时间内产生的日志量是非常大的。对此openGauss采用多种回放线程组来进行日志的并行回放,各个回放线程组之间采用高效的流水线工作方式,各个回放线程组内采用多线程并行的工作方式,以便保证日志的回放速率不会明显低于日志产生的速率。
图4-35 openGauss并行回放流程示意图 openGauss并行回放流程如图4-35所示,其中每个线程(组)的运行机制如下。
(1) “Walreceiver”线程收到日志成功写入磁盘后,“XLogReadWorker”线程从“Walreceiver”线程的缓冲区中读取字节流,“XLogReadManager”线程将字节流decode(解码)成redoitem(单个回放对象)。“Startupxlog”线程按照表文件名粒度(refilenode)将redoitem发放给各个“ParseRedoRecord”线程,其他的日志发送给“TrxnManager”线程。
(2) “ParseRedoRecord”线程负责表文件(relation)相关的日志处理,从队列中获取批量的日志进行解析,将日志按照页面粒度进行拆分,然后发给“PageRedoManager”线程。拆分原理如下。
针对行存储表、索引等数据页面操作的日志,按照涉及的页面个数拆成多条日志。例如heap_update日志,如果删除的老元组和插入的新元组在不同的页面上,那么会被拆成2条,分别插入到哈希表中。
xact、truncate、drop database等日志是针对表的,不能进行拆分。针对这些日志,先清理掉哈希表中相关日志,然后等这些日志之前的日志都回放之后,再在PageRedoManger中进行回放,并将该日志分发给所有“PageRedoWorker”线程来进行invalid page(无效页面)的清理、数据写入磁盘等操作。
针对Createdb(创建数据库)操作要等所有“PageRedoWorker”线程将Createdb日志之前的日志都回放后,再由一个“PageRedoManager”线程进行Createdb操作的回放。这个过程中其余线程需要等待Createdb操作回放结束后才能继续回放后续日志。
(3) “PageRedoManager”线程利用哈希表按照页面粒度组织日志,同一个页面的日志按照lsn顺序放入到一个列表中,之后将页面日志列表分发给“PageRedoWorker”线程。
(4) “PageRedoWorker”线程负责页面日志回放功能,从队列中获取一个日志列表进行批量处理。
(5) “TrxnManager”线程负责事务相关的XLOG日志的分发,以及需要全局协调的事务处理。
(6) “TrxnWorker”线程负责事务日志回放功能,从队列中获取一个日志进行处理。当前只有一个“TrxnWorker”线程负责处理事务日志。
为了保证高效的日志分发性能,“PageRedoManager”进程和“PageRedoWorker”进程之间采用了带阻塞功能的无锁单生产者单消费者(single producer single consumer,SPSC)队列。如图4-36所示,分配线程作为生产者将解析后的日志放入回放线程的列队中,回放线程从队列中消费日志进行回放。另一方面为了提升整体并行回放机制的可靠性,会在一个页面的回放动作中对日志记录头部的lsn和页面头部的lsn进行校验,以保证回放过程中数据库系统的一致性。
图4-36 无锁SPSC队列示意图
3. cstore列存储持久化机制
由于在openGauss中cstore主体数据没有写缓冲区,因此对于所有的插入或更新事务,在拼装完新的CU之后都是直接调用pwrite来写入文件系统缓存,并且在事务提交之前调用“CUStorage::FlushDataFile”接口完成本地磁盘的持久化(该函数内部调用fsync执行写入磁盘)。由于OLAP系统中通常插入事务都是批量导入执行的,因此在这个过程中对于cstore表物理文件的写操作基本都是顺序I/O,可以获得较高的性能。
4.2.10 主备机制
openGauss提供主备机制来保障数据的高可靠和数据库服务的高可用。如图4-37所示,在主、备实例之间通过日志复制来进行数据库数据和状态的一致性同步。日志同步是指将主机对数据的修改日志同步到备机,备机通过日志回放将日志重新还原为数据修改。
图4-37 主备机日志同步示意图 参与日志同步的主要有“wal sender”(主机端)和“wal receiver”(备机端)两个线程。一个主机上可以由多个“wal sender”线程同时存在,用于给不同的备机进行日志复制;一个备机上同一时刻只会有一个“wal receiver”线程,从唯一一个指定的主机上拷贝日志。 “wal sender”线程的所有关键信息均保存在knl_t_walsender_context结构体中,其定义代码如下:
typedef struct knl_t_walsender_context {
char* load_cu_buffer;
int load_cu_buffer_size;
struct WalSndCtlData* WalSndCtl;
struct WalSnd* MyWalSnd;
int logical_xlog_advanced_timeout;
DemoteMode Demotion;
bool wake_wal_senders;
bool wal_send_completed;
int sendFile;
XLogSegNo sendSegNo;
uint32 sendOff;
struct WSXLogJustSendRegion* wsXLogJustSendRegion;
XLogRecPtr sentPtr;
XLogRecPtr catchup_threshold;
struct StringInfoData* reply_message;
struct StringInfoData* tmpbuf;
char* output_xlog_message;
Size output_xlog_msg_prefix_len;
char* output_data_message;
uint32 output_data_msg_cur_len;
XLogRecPtr output_data_msg_start_xlog;
XLogRecPtr output_data_msg_end_xlog;
struct XLogReaderState* ws_xlog_reader;
TimestampTz last_reply_timestamp;
TimestampTz last_logical_xlog_advanced_timestamp;
bool waiting_for_ping_response;
volatile sig_atomic_t got_SIGHUP;
volatile sig_atomic_t walsender_shutdown_requested;
volatile sig_atomic_t walsender_ready_to_stop;
volatile sig_atomic_t response_switchover_requested;
ServerMode server_run_mode;
char gucconf_file[MAXPGPATH];
char gucconf_lock_file[MAXPGPATH];
FILE* ws_dummy_data_read_file_fd;
uint32 ws_dummy_data_read_file_num;
struct cbmarray* CheckCUArray;
struct LogicalDecodingContext* logical_decoding_ctx;
XLogRecPtr logical_startptr;
int remotePort;
bool walSndCaughtUp;
} knl_t_walsender_context;
其中:
(1) WalSndCtl指向保存全局所有“wal sender”线程控制状态的共享结构体,是一致性复制协议的关键所在。
(2) MyWalSnd指向上述全局共享结构体中当前“wal sender”线程的槽位。
(3) Demotion为当前主机降备模式,分为未降备(NoDemote)、优雅降备(SmartDemote)和快速降备(FastDemote)。
(4) sendFile、sendSegNo、sendOff用于保存当前复制的日志文件的文件操作状态。
(5) reply_message用于保存备机回复的消息。
(6) output_xlog_message为待发送的日志内容主体。
(7) server_run_mode为wal sender线程启动时的HA(high availability,HA)高可靠性)状态,即主机(primary)、备机(standby)或未决(pending)。
(8) walSndCaughtUp指示备机是否已经追赶上主机。
(9) remotePort为wal receiver线程的端口,用于身份验证。
(10) load_cu_buffer 、load_cu_buffer_size 、output_data_message、output_data_msg_cur_len、output_data_msg_start_xlog、output_data_msg_end_xlog、ws_xlog_reader、CheckCUArray为后续支持混合类型(日志+增量页面)复制的预留接口。
wal receiver线程的所有关键信息均保存在knl_t_walreceiver_context结构体中,其定义代码如下:
typedef struct knl_t_walreceiver_context {
volatile sig_atomic_t got_SIGHUP;
volatile sig_atomic_t got_SIGTERM;
volatile sig_atomic_t start_switchover;
char gucconf_file[MAXPGPATH];
char temp_guc_conf_file[MAXPGPATH];
char gucconf_lock_file[MAXPGPATH];
char** reserve_item;
time_t standby_config_modify_time;
time_t Primary_config_modify_time;
TimestampTz last_sendfilereply_timestamp;
int check_file_timeout;
struct WalRcvCtlBlock* walRcvCtlBlock;
struct StandbyReplyMessage* reply_message;
struct StandbyHSFeedbackMessage* feedback_message;
struct StandbySwitchRequestMessage* request_message;
struct ConfigModifyTimeMessage* reply_modify_message;
volatile bool WalRcvImmediateInterruptOK;
bool AmWalReceiverForFailover;
bool AmWalReceiverForStandby;
int control_file_writed;
} knl_t_walreceiver_context;
其中:
(1) walRcvCtlBlock指向“wal receiver”线程主控数据,保存当前日志复制进度,备机日志写盘、写入磁盘进度,以及接收日志缓冲区。
(2) reply_message保存用于回复主机的消息。
(3) feedback_message用于保存热备的相关信息,供主机空闲空间清理时参考。
(4) request_message用于保存主机降备请求的相关信息。
(5) reply_modify_message用于保存请求配置文件复制的相关信息。
(6) AmWalReceiverForFailover表示当前“wal receiver”线程处于failover场景下,连接从备进行日志追赶。
(7) AmWalReceiverForStandby表示当前“wal receiver”线程为连接备机进行日志复制的级联备机。
主备日志同步,主要包括以下6个场景。
1. 备机发起复制请求,进入流式复制。
图4-38 主备建连和流式复制流程图 如图4-38所示,日志复制请求是由“wal receiver”线程发起的。在libpqrcv_connect函数中,备机通过libpq协议连上主机,通过特殊的连接串信息,触发主机侧启动“wal sender”线程来处理该连接请求(相比之下,对于普通客户端查询请求,主机启动backend线程或线程池线程来处理连接请求)。在WalSndHandshake函数中,wal sender线程与wal receiver线程完成身份、日志一致性等校验之后,进入WalSndLoop开始日志复制循环。主要的主、备机握手和校验报文如表4-34所示,在主机收到T_StartReplicationCmd报文之后,开始进入日志复制阶段。 表4-34 主、备机握手和校验报文
报文类型 |
报文作用 |
---|---|
T_IdentifySystemCmd |
请求主机发送主机侧system_identifier,校验是否和备机一致 |
T_IdentifyVersionCmd |
请求主机发送主机侧版本号,校验是否和备机一致 |
T_IdentifyModeCmd |
请求主机发送主机侧HA状态,校验是否是主机状态 |
T_IdentifyMaxLsnCmd |
请求主机发送当前最大的lsn位置(即日志偏移),用于备机重建 |
T_IdentifyConsistenceCmd |
请求主机发送指定lsn位置日志记录的crc值,校验是否和备机一致 |
T_IdentifyChannelCmd |
请求主机校验备机的端口是否在repliconn_info参数中,返回校验结果 |
T_IdentifyAZCmd |
请求主机发送主机侧AZ名字 |
T_BaseBackupCmd |
请求主机开始发起全量重建 |
T_CreateReplicationSlotCmd |
请求主机创建流复制槽 |
T_DropReplicationSlotCmd |
请求主机删除流复制槽 |
T_StartReplicationCmd |
请求主机开始日志复制 |
2. Quorum一致性复制协议
为了保证数据库数据的可靠和高可用,当主机上执行的事务修改产生日志之后,在事务提交之前需要将本事务产生的日志同步到多个备机上。openGauss采用Quorum一致性复制协议,即当多数备机完成上述事务的日志同步之后主机事务方可提交。这个过程中作为事务提交参考的是同步备,其他备机是异步备,作为冗余备份。同步备和异步备的具体选择可以通过配置synchronus_standby_names参数实现。
图4-39 事务提交和一致性复制协议 主机上事务提交和一致性复制协议的工作运行机制如图4-39所示。主要涉及的数据结构是WalSndCtlData数据结构体,其定义代码如下:
typedef struct WalSndCtlData {
SHM_QUEUE SyncRepQueue[NUM_SYNC_REP_WAIT_MODE];
XLogRecPtr lsn[NUM_SYNC_REP_WAIT_MODE];
bool sync_standbys_defined;
bool most_available_sync;
bool sync_master_standalone;
DemoteMode demotion;
slock_t mutex;
WalSnd walsnds[FLEXIBLE_ARRAY_MEMBER];
} WalSndCtlData;
其中SyncRepQueue是等待不同同步方式(备机日志写入磁盘、备机日志接收、备机日志回放等同步方式)的业务线程等待队列,用于当某一种同步方式满足条件之后,唤醒该类型的业务线程完成事务提交。lsn是上述几种队列队头后台线程等待的日志同步位置。sync_standbys_defined表示是否配置了同步备机。most_available_sync表示是否配置了最大可用模式;如果已配置,则在没有同步备机连接的情况下,后台业务线程可以直接提交,不用阻塞等待。sync_master_standalone表示当前是否有同步备机连接。demotion表示主机的降备方式。mutex表示保护walsnds结构体并发访问的互斥锁。walsnds表示保存wal sender的具体同步状态和进度信息。
3. 计划外切换(failover)
图4-40 failover流程示意图 如图4-40所示,failover(故障切换)时主机是异常状态,所以只有备机参与failover。failover的核心是让备机在满足一定条件以后退出日志复制和日志恢复流程。当数据库主线程“postmaster”线程(简称PM线程)在reaper中收到“startup”线程(即恢复线程)的停止信号后,将实例状态设置为PM_RUN,并将实例HA状态设置为PRIMARY_MODE。
4. 计划内切换(switchover)
图4-41 switchover流程示意图 如图4-41所示,switchover的过程比failover多了主机降备的处理,备机的流程和failover流程一致,因此没有在图中标出,参考failover流程即可。
5. 备机重建
图4-42 备机重建流程示意图 如图4-42所示,备机重建的过程相当于对主机进行了一次全量备份和恢复的操作,主要步骤包括:清理残留数据、全量拷贝数据文件、复制增量日志、启动备实例。这个过程中比较关键的两点是:文件和日志的拷贝顺序,以及备机第一次启动时选择的日志恢复起始位置。
6. cstore数据复制
在openGauss中,对于cstore表的数据复制与上述介绍略有不同。在一主多备部署场景下,每个CU填充写盘之后都会将CU整体数据记录到日志文件中,从而通过主备的日志复制和备机的日志回放,就可以实现cstore表增量数据的主备同步。在主备从部署场景下,每个CU填充写盘之后会直接将该CU数据拷贝到主机与备机之间的数据发送线程的局部内存中,并在事务提交之前阻塞等待数据发送线程传输完增量的CU数据才能完成事务提交,因此也实现了cstore表增量数据的主备同步。
下一篇我们详细讲述“4.3 内存表”相关内容。