内存是一种易失性存储介质,在断电等场景下存储在内存介质中的数据会丢失。为了保障数据的可靠性需要将共享缓冲区中的脏页写入磁盘,此即数据的持久化过程。对于最常用的持久化存储介质磁盘,由于每次读写操作都有一个“启动”代价,导致磁盘的读写操作频率有一个上限。即使是超高性能的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的同步机制,具体步骤如下。




