
内核一周一审: 一种对 wal 文件组织方式的改进与思考
我们必须扎扎实实地与国际社区相结合, 才可以真正为繁荣中国 PostgreSQL 生态, 做出关键性的贡献.
同时经过近期的经历, 我必须指出, 无论是自研, 或者是二次开发, 倘若没有认真做软件之想法, 乃至于钻研之精神, 则终必以失败为归宿.
"内核一周一审"的目标在于, 每周围绕 PostgreSQL 的一个 Patch 所提出的改进思路, 乃至于背后的背景信息等展开讨论, 力争帮助更多的内核研究人员参与到 PostgreSQL 的改进之中, 同时, 我们真诚期待 PostgreSQL 的进步不仅仅有益于自身乃至于衍生的数据库以及拓展, 同时它能够为其它的数据库内核研发, 提供参考与借鉴.
言归正传, 在本次的"内核一周一审"中, 我们将对 PostgreSQL 社区对 wal 日志的改进想法展开讨论与研究, 感兴趣的读者可以阅读 <Updating the WAL infrastructure> 等材料, 更为深入地了解相关的信息.
背景信息阐述
在 PostgreSQL 中, wal 日志承担着重要的职责, 一方面, 它为故障恢复提供了有益的参考, 帮助我们在数据库出现问题的时候, 快速填补已经载入日志, 但是尚未作用于数据文件的数据操作;而另外一个方面来看, wal 为流复制提供了帮助与支持, 通过节点间的数据传输, PostgreSQL 可以便利地达成数据同步之目的.
与 wal 日志相互关联的数个结构
而于源代码之中, PostgreSQL 对于 wal 日志的结构等, 规划如下所示:
/* 代码来自于 src/include/access/xlogrecord.h */
/*
* The overall layout of an XLOG record is:
* Fixed-size header (XLogRecord struct)
* XLogRecordBlockHeader struct
* XLogRecordBlockHeader struct
* ...
* XLogRecordDataHeader[Short|Long] struct
* block data
* block data
* ...
* main data
*
*/
/*
补充: PostgreSQL 的这段注释, 实际上并没有考虑到所有的情况,
因此确实存在有一定的误导性, 如在记录较短的时候(少于 256 字节),
将会直接使用 main data 字节数组存储数据
而在记录较长的时候, 则会把数据分割为数据块存储数据
因此, 我们需要结合具体情况, 做具体的分析
*/
用图形的形式所展示的话, 可如下所示:
而相对应的结构, 则如下而所示:
/* 用于描述 xlog 记录整体的信息 */
/*
XLogRecordBlockHeaders 允许有0个或者多个.
同时, 0个或者多个字节将会为资源管理器数据而准备, 他们并不会与
数据块相互关联.
XLogRecord 结构总是使用 wal 中 MAXALIGN 进行对齐, 但是域的
其它部分则不会对齐.
XLogRecordBlockHeader, XLogRecordDataHeaderShort,
XLogRecordDataHeaderLong 这三个结构体全部用单独的 'id'
开始.它被用于区分指向不同数据块的引用, 以及 main data 结构.
*/
typedef struct XLogRecord
{
uint32 xl_tot_len; /* 整条 wal 记录的长度 */
TransactionId xl_xid; /* 对应事务 ID */
XLogRecPtr xl_prev; /* 指向前一条日志记录的指针 */
uint8 xl_info; /* 标志位, 参考如下 */
RmgrId xl_rmid; /* 为本条 wal 记录分配的资源管理器 */
/* 两个初始化为 0 的字节用于填充 */
pg_crc32c xl_crc; /* 用于 CRC 验证 */
/* 接下来是 XLogRecordBlockHeaders and XLogRecordDataHeader 结构体, 无填充 */
} XLogRecord;
/*
被用于 "main data" 部分, 当数据记录长度少于 256 字节, 则选择
XLogRecordDataHeaderShort
否则是 XLogRecordDataHeaderLong
*/
typedef struct XLogRecordDataHeaderShort
{
uint8 id; /* 常量 XLR_BLOCK_ID_DATA_SHORT */
uint8 data_length; /* 数据块长度 */
}XLogRecordDataHeaderShort;
/* XLogRecordDataHeaderShort 结构长度 */
#define SizeOfXLogRecordDataHeaderShort (sizeof(uint8) * 2)
typedef struct XLogRecordDataHeaderLong
{
uint8 id; /* 常量 XLR_BLOCK_ID_DATA_LONG */
/* 后面是 uint32 data_length, 用于标识数据长度, 没有对齐 */
}XLogRecordDataHeaderLong;
/* XLogRecordDataHeaderLong 结构长度 */
#define SizeOfXLogRecordDataHeaderLong (sizeof(uint8) + sizeof(uint32))
/* 用于描述加入到 wal 记录到的数据块的元信息 */
typedef struct XLogRecordBlockHeader
{
uint8 id; /* 所对应的数据块的 ID */
uint8 fork_flags; /* 对应的关系数据的 fork 编号, 以及标志位 */
uint16 data_length; /* 与资源管理器相互关联的数据块的长度, 它并不包含可能的全页面映像的长度, 同时也不包含XLogRecordBlockHeader 自身的长度 */
/* 如果 BKPBLOCK_HAS_IMAGE 被指定, 则 XLogRecordBlockImageHeader 跟随其后 */
/* 如果 BKPBLOCK_SAME_REL 没有被指定, 则 RelFileLocator 跟随其后 */
/* 接下来是数据块的数字编号 */
} XLogRecordBlockHeader;
/*
当 BKPBLOCK_HAS_IMAGE 被指定时, 该结构被用于描述 Block Image
*/
typedef struct XLogRecordBlockImageHeader
{
uint16 length; /* 数据块长度 */
uint16 hole_offset; /* 在数据空洞之前的长度 */
uint8 bimg_info; /* 标志比特位, 参考如下 */
/*
* 当 BKPIMAGE_HAS_HOLE and BKPIMAGE_COMPRESSED() 被指定
* 的时候, XLogRecordBlockCompressHeader 跟随其后
*/
} XLogRecordBlockImageHeader;
typedef struct XLogRecordBlockCompressHeader
{
uint16 hole_length; /* 数据空洞的长度 */
} XLogRecordBlockCompressHeader;
/*
用于指向关系型数据的物理文件地址, 注意 PostgreSQL 的关系型数据
可以由不同的物理文件组成, 每一个 fork 便是一个单独的文件, 同时
他们将会被划分为不同的数据段
*/
typedef struct RelFileLocator
{
Oid spcOid; /* 表所处的 tablespace, 当 dbOid 为 0 时, 则为 GLOBALTABLESPACE_OID */
Oid dbOid; /* 表所处的数据库, 若是该数据为全数据库集簇共享, 则为 0 */
RelFileNumber relNumber; /* 对应的关系型数据库文件编号, 对应 pg_class.relfilenode */
} RelFileLocator;
总结: 这些设立好的结构, 实际上便是从不同的角度, 解读了 wal 日志整体的情况, 乃至于数据块的情况(视长度乃至于数据块数据来源加以处理), 我们需要根据具体的情况, 做具体的分析.
数据空洞现象
数据空洞实际上便是用于代指 wal 日志记录中某些空白的部分, 他们产生的原因多种多样, 但其尺寸和长度都必须被记录下来, 否则我们就无法解码出对应的 wal 数据块来.
PostgreSQL 社区认为 wal 组织结构所存在的不足
根据社区有关文档, 社区认为 wal 所存在的不足在于:
- XLogRecord 结构的对齐问题 目前自整体的尺寸上面看, 它的对齐距离对齐要求, 差了两个字节, 这就使得我们在其它很多地方, 都需要做补充(如, 把一些配套尺寸补充四个字节)来解决问题.
- XLogRecord 所记录的 TransactionID 在很多场景都应用不上 所有核心的索引都没有使用 XLogRecord 中记录的 TransactionId, 很多资源管理器同理. 如果这些用不上的话, 我们就不应当在所有的时候, 都记录这四个字节. 而到了 XID-64 时代, 这些信息将会被拓展为 8 个字节. 社区正在全力阻止这一资源浪费发生.
- XLogRecord 的平均长度远远小于 2^16 所有 wal 日志记录极少达到 2^16 同时总是小于 255 字节. 但是存储长度的 xt_tot_len 总是为 uint32, 这就使得出现了 2 ~ 3 个字节的资源浪费. 如果我们使用可变长度的编码(或者与之类似的事情), 我们可以节约一些字节.
- 数据块的数据长度可能总是 0, 或者至少小于 2^8.
- 压缩目前所考虑的是逐个页面的压缩, 但是很多情况下, 不同的页面共享相同的数据, 这就使得针对整体的压缩往往更加有效.
- 可以尝试支持针对于数据块中某几个指定好了的字节的"精确恢复", 这样在数据恢复时, 就可以用更少的 wal 达到同样的恢复目的
- 可以尝试支持 wal 日志的并行恢复
而我们本次所审阅的 Patch, 则围绕第一点而展开, 它所对应的讨论链接位于 https://www.postgresql.org/message-id/flat/CAEze2WjuJqVeB6EUZ1z75_ittk54H6Lk7WtwRskEeGtZubr4bQ@mail.gmail.com , 而 Commitfest 的页面对应 https://commitfest.postgresql.org/49/4324/ ,参考如下:
现在, 就让我们阅读初始版本的 Patch 以及邮件中的关键部分, 理解其改进思路吧!
对 Patch "XLog size reductions: smaller XLogRecordBlockHeader" 的分析
由作者的描述可知(节选):
Attached is a patch that reduces this overhead by up to 2 bytes by encoding how large the block data length field is into the block ID, and thus optionally reducing the block data's length field to 0 bytes.
附件是一个减少了 Xlog 尺寸最多达2字节的 Patch, 方式是将数据块的长度信息整合进入数据块 ID, 同时根据情况设立选项, 削减块数据的长度域至 0 字节.
It changes the block IDs used to fit in 6 bits, using the upper 2 bits of the block_id field to store how much data is contained in the record
它将数据块 ID 整合为 6个字节, 使用 block_id 中最多2个字节描述该记录中包含了多少数据.
This is part 1 of a series of patches trying to decrease the size of WAL.
这是我对于 wal 日志记录长度展开精简的一系列工作的第一部分.
而具体的措施则是:
- 设立为不同尺寸长度准备的内联函数, 参考如下:
/* 为尺寸分类准备的枚举变量 */
typedef enum XLogSizeClass {
XLS_EMPTY = 0, /* length == 0 */
XLS_UINT8 = 1, /* length <= UINT8_MAX; stored in uint8 (1B) */
XLS_UINT16 = 2, /* length <= UINT16_MAX; stored in uint16 (2B) */
XLS_UINT32 = 3 /* length <= UINT32_MAX; stored in uint32 (4B) */
} XLogSizeClass;
/* 根据数据块的长度, 判断使用什么规格的尺寸 */
static inline XLogSizeClass XLogLengthToSizeClass(uint32 length, const XLogSizeClass maxSizeClass)
{
XLogSizeClass sizeClass;
if (length == 0)
sizeClass = XLS_EMPTY;
else if (length <= UINT8_MAX && maxSizeClass >= XLS_UINT8)
sizeClass = XLS_UINT8;
else if (length <= UINT16_MAX && maxSizeClass >= XLS_UINT16)
sizeClass = XLS_UINT16;
else
{
Assert(maxSizeClass == XLS_UINT32);
sizeClass = XLS_UINT32;
}
Assert(sizeClass <= maxSizeClass);
return sizeClass;
}
- 增添
XLogReadLength
与XLogReadLength
两个函数, 根据不同的尺寸, 展开对应的数据复制
/* 以 XLogReadLength 为案例 */
/* 该宏与一个 switch 配合使用, 根据不同尺寸, 复制数据 */
#define READ_OP(caseSizeClass, field_type) \
case caseSizeClass: \
if ((caseSizeClass) <= maxSizeClass) \
{ \
field_type typedLength; \
if (inSize < sizeof(field_type)) \
return -1; \
memcpy(&typedLength, input, sizeof(field_type)); \
readSize = sizeof(field_type); \
*length = typedLength; \
} \
break
/* 具体所对应的代码 */
switch (sizeClass) {
case XLS_EMPTY:
readSize = 0;
*length = 0;
break;
READ_OP(XLS_UINT8, uint8);
READ_OP(XLS_UINT16, uint16);
READ_OP(XLS_UINT32, uint32);
}
由此, 在这种分类处理, 分情况拷贝字节的思考下, 实现了对于 wal 日志的精简, 根据作者的统计, tps 实现了小幅度提升:
社区的反馈意见 --- 希望测试更多场景下的性能表现
社区初步的反馈意见是, 希望作者调整不同的参数, 实验更多场景下的性能改进, 对应的参数配置为:
synchronous_commit = off # 关闭同步流提交
fsync = off # 不同意 fsync 调用
full_page_writes = off # 关闭全页写入
checkpoint_timeout = 1d # 检查点时间设置为 1 天
autovacuum = off # 关闭自动 vacuum
而实现的改进效果:
社区的反馈意见 --- 细节优化
- 希望作者改进代码的美观性, 因为一部分地方被不自然地截断了
- Size / size_t 这种将尺寸直接除以 size_t 的做法是没有对齐的, 会引发警告
- WRITE_OP 宏使用场景较少, 且可以直接展开, 可以考虑精简, 同理适用于 READ_OP
if (read < 0)
这种操作意义不大, 因为 read 是无符号整数, 不存在 < 0 的情况(本文没有整合相关函数定义的代码, 读者可以自己阅读相关的定义)
作者的二次改进
在得到反馈意见之后, 作者使用 int
代替了 uint32
, 就此解决 if (read < 0)
的问题, 同时作者就 Size / size_t
的对齐问题, 补充了一则很有意思的小知识点:
I've been working with Rust recently, where unsigned size is usize
and size
is signed
我最近使用 Rust 展开工作, 它的 usize
和 size
是对齐的.
可见一种知识, 乃至于一种见解, 在不同的场景下面, 就可能会成为谬误, 这就需要我们不断地去汲取经验与教训, 自更多的角度去审视与看待问题.
社区的反馈意见 --- Patch 遭一票否决, 被事实上抛弃
I think it's a bad tradeoff to write lots of custom varint encodings, just to eek out a bit more space savings. The increase in code complexity IMO makes it a bad tradeoff.
我认为编写这么多自定义长度的编码, 只为节约空间, 是一个糟糕的考虑. 因为它增加了代码复杂度.
Andres Freund, PostgreSQL 社区核心成员
作者对此表示不理解, 指出:
we just going to ignore any potential improvements because they "increase code complexity"?
我们难道就因为 "增加了代码复杂性" 这条理由, 就选择性忽略这么多潜在的优化点吗?
Matthias van de Meent
而这条反问没有获得社区的理解与支持, 随即 Patch 就进入了事实上的 "被抛弃" 状态, 同时也引发了社区的争论.
This would probably be a good option for extendable TOAST pointers, compression dictionaries, and perhaps other patches.
这种做法可能更适合为 TOAST 使用, 或者是压缩文件夹, 或者是其它的地方.
We don't use rbtrees for everything that needs a map from x to y. Hash tables have other compromises. Sometimes one container is a better fit, sometimes the other, sometimes none and we implement a fine tuned container for the given case. Same here.
我们并不需要为所有自 x 到 y 的场景使用红黑树.哈希表往往更加便利.很多时候使用一个容器往往是更好的选择, 有时候则是其它, 或者有时候我们会根据情况使用一个更加合适的容器, 在这也是一样.
Aleksander Alekseev
I'm seeing that there has been no activity in this thread for nearly 4 months.
我注意到这则讨论已经将近4个月没有任何新的活动了(即没有人回复, 也没有人提出新的意见).
Vignesh
we need to stop worrying about WAL overhead and learn how to work together better.
我们需要停止担忧 wal 的负载问题, 同时应当学会如何更好的协作.
Robert Haas