存储引擎源码解析 | 磁盘引擎(16)

2023年 11月 23日 49.3k 0

4.2.8 日志系统

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

openGauss日志文件、页面和日志记录的格式如图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信息、页面标志位信息、时间线信息、页面(在整个逻辑日志文件中的)偏移信息、有效长度信息、系统识别号信息、段尺寸信息、页尺寸信息等。
短页头结构体的代码如下:

uint16 xlp_magic; /* 日志magic校验信息 */
uint16 xlp_info; /* 标志位 */
TimeLineID xlp_tli; /* 该页面第一条日志的时间线 */
XLogRecPtr xlp_pageaddr; /* 该页面起始位置的lsn */
uint32 xlp_rem_len; /*如果是跨页记录,本字段描述该跨页记录在本页面内的剩余长度 */
} XLogPageHeaderData;

长页头结构体的代码如下:

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部分将被跳过。

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所示。

日志的读接口为XLogReadRecord接口。该接口从指定的日志偏移处(或上次读到的那条记录结尾位置处)开始读取和解析下一条完整的日志记录。如果当前缓存的日志段文件页面中无法读完,那么会调用ReadPageInternal接口加载下一个日志段文件页面到内存中继续读取,直到读完所有等于日志头部xl_tot_len长度的日志数据。然后,调用DecodeXLogRecord接口,将日志记录按图4-32中所示的5个组成部分进行解析。
日志文件读写的最小I/O粒度为一个页面。在事务执行过程中,只会进行(顺序追加)写日志操作。为了提高写日志的性能,在共享内存中,单独开辟一片特定大小的区域,作为写日志页面的共享缓冲区。对该共享缓冲区的并发操作(拷贝日志记录到单个页面中、淘汰lsn过老的页面、读取单个页面并写入磁盘)是事务执行流程中的关键瓶颈之一,对整个数据库系统的并发能力至关重要。

如图4-33所示,在openGauss中对该共享缓冲区的操作采用Numa-aware的同步机制,具体步骤如下。
(1) 业务线程在本地内存中将日志记录组装成图4-32中所示的、5部分组成的字节流。
(2) 找到本线程所绑定的NUMA Node对应的日志插入锁组,并在该锁组中随机找一个槽位对应的锁。
(3) 检查该锁的组头线程号。如果没有说明本线程是第一个请求该锁的,那么这个锁上所有的写日志请求将由本线程来执行,将锁的组头线程号设置为本线程号;否则说明已经存在这批写日志请求的组头线程,记录下当前组头线程的线程号,并将自己加入到这批的插入组队列中,等待组头线程完成日志插入。
(4) 对于组头线程,获取该日志插入锁的排他锁。
(5) 为该组所有的插入线程在逻辑日志文件中占位,即对当前该文件的插入偏移进行原子CAS(compare and swap,比较后交换)操作。
(6) 将该组所有后台线程本地内存中的日志依次拷贝到日志共享缓冲区的对应页面中。每当需要拷贝到下一个共享内存页面时,需要判断下一个页面对应的逻辑页面号是否和插入者的预期页面号一致(因为共享内存有限,因此同一个共享内存页面对应取模相同的逻辑页面)。首先,将自己预期的逻辑页面号,写入当前持有的日志插入锁的槽位中,然后进行上述判断。如果不一致,即共享内存页面当前的逻辑页面号比插入者预期的逻辑页面号要小,那么需要将该页面数据从共享内存中写入到磁盘,然后才能复用为新的逻辑页面号。为了防止可能还有并发业务线程在向该共享内存页面拷贝属于当前逻辑页面号的日志数据,因此需要阻塞遍历每个日志插入者持有的插入锁,直到日志插入锁被释放,或者被持有的插入锁的逻辑页面号大于目标共享内存页面中现有的逻辑页面号。经过上述检查之后,就可以保证没有并发的业务线程还在对该共享内存页面写入对应当前逻辑页面号的日志数据,因此可以将其内容写入磁盘,并更新其对应的逻辑页面号为目标逻辑页面号。
(7) 重复上一步操作,直到把该组所有后台线程待插入的日志记录拷贝完。
(8) 释放日志插入锁。
(9) 唤醒本组所有后台线程。

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论