PAX:存储引擎反击

10 阅读11分钟

PAX:存储引擎反击

摘要: 本文探讨了在 PostgreSQL 中集成 PAX(Partition Attributes Across,一种缓存友好的存储格式)的实现挑战。详细介绍了定长属性和变长属性的 minipage 模式设计,分析了与 PostgreSQL MVCC 模型的基本不兼容性,并提出了元数据 minipage 解决方案以及使用 fillfactor 和 TOAST 的实际缓解措施。

2022年4月6日 · 2182 字 · 11 分钟阅读

原文链接


PAX 在理论上看起来很优雅:minipage、缓存局部性、8KB 页面内的列式访问。但一旦考虑如何在 Postgres 中实现,复杂性就迅速显现。NULL 值、变长类型、MVCC、边界移动。让我们逐一分析。

Minipage 模式

在 PAX 中,页面被分割成 minipage。以下是它们如何根据数据类型而有所不同。Postgres 有大量的类型库,但它们始终属于两个存储类别。

定长属性(F-minipage)

适用于所有行大小相同的类型。大多数 Postgres 类型都属于这一类:任何数值类型、布尔值、日期/时间族、几何类型、网络地址……

关于 F-minipages 的一点是:我们不存储 NULL 值。完全不存储。没有占位符、没有空洞。只有实际数据,紧凑排列。位数组?那就是你的映射。1 = "值存在。" 0 = "它是 NULL。"

但是,等等,如果 NULL 值不被存储,我们怎么知道位向量从哪里开始?

让我们回顾一下。PAX 页面头部包含页面上的记录总数 N,以及指向每个 minipage 开头的指针(有多少列就有多少个指针)。给定一个 minipage 的起始指针和下一个 minipage 的起始指针,你就能精确知道该 minipage 占据的字节数。

现在一切都清楚了。位向量位于 minipage 的末尾,其大小正好是 N 位,每行一个,无论是否为 NULL。从 minipage 末尾向后走 N 位,就是位向量的起点。在它之前的所有内容都是密集值数组。

提取特定行纯粹是算术运算。要获取行 R:

  • 检查位数组中的第 R 位。
    • 如果是 0,返回 NULL。
    • 如果是 1,统计位置 R 之前的 1 的数量,记为 M。
      • 该值位于值数组起始位置偏移 M x S 处,其中 S 是该类型的固定大小。就这么简单。

没有猜测。没有分隔符。只是 CPU 非常擅长的算术运算。

image.png

以上面的模式为例,获取第 3 行:

  • 检查 bit_array[2] = 1 → 不是 NULL
  • 统计位置 2 之前的 1 的数量 → popcount(1, 0) = 1,所以 M = 1
  • 该值位于值数组起始位置偏移 M x S = 1 x 4 = 4 字节处
  • 在该偏移位置读取 INT4 → 17

最大密度。零缓存浪费。CPU 加载纯压缩整数。而 popcount?那是硬件指令。基本上是免费的。

变长属性(V-minipage)

这个类别处理 TEXT 或任何变长数据类型。值从 minipage 的前端追加,而偏移数组从后端增长,每个偏移指向相应值的末尾。要找到值的起始位置,你需要查看前一个值在哪里结束。理论上很简单。让我们看看实际会发生什么。

NULL 的处理与 F-minipages 不同。这里没有位数组。NULL 值由偏移数组中的空指针表示。偏移数组每个,不管怎样都有一个固定大小的条目,所以你总是精确知道预期有多少个偏移:N,页面头部中的记录数。

最后一点很重要。因为偏移是固定长度的(指针只是一个整数),minipage 末尾的偏移数组的行为与 F-minipage 中的密集数组完全一样。固定大小、可预测、没有歧义:从 minipage 末尾向后走 N x sizeof(offset) 字节。

现在,当值太大时会发生什么?Postgres 有一个阈值(TOAST_TUPLE_THRESHOLD,默认 2KB),超过该阈值的值会被 TOAST 处理:存储在单独的表中。在这种情况下,V-minipage 存储一个固定长度的 TOAST 指针而不是原始值。这对布局实际上是好事:TOAST 指针是固定大小的,所以它在 minipage 中占用的空间比原始值少得多,并且偏移仍然像任何其他值一样指向其末尾。minipage 不需要知道或关心它是在看原始数据还是指针。

image.png

遍历第 3 行("DBA life"):

  • 读取 offset_array[2]offset_array[1]
  • 检查 offset_array[2]:不是空指针,所以不是 NULL
  • 检查 offset_array[1]:是空指针,所以检查 offset_array[0],不是空指针
  • 读取 offset_array[0]offset_array[2] 之间的字节 → "DBA life"

遍历第 2 行(NULL):

  • 读取 offset_array[1]
  • 检查 offset_array[1]:空指针 → NULL,不返回任何内容
  • (不需要读取前一个偏移:空指针本身就可以确定)

特殊情况 — 第 1 行("Hello"):

  • 读取 offset_array[0]
  • 检查 offset_array[0]:不是空指针,所以不是 NULL
  • 没有前一个偏移:数据区域的起始位置从 minipage 结构本身可知,所以偏移 0 是隐式的
  • 从数据区域起始位置到 offset_array[0] 读取字节 → "Hello"

插入、删除、更新

插入

当新记录到达时,PAX 将每个属性值复制到相应的 minipage 中。对于 F-minipages,这意味着将新的定长值追加到密集数组中,并在存在位数组中设置相应的位。对于 V-minipages,新值被追加到数据区域的前端,并在偏移数组中添加新的偏移条目。

棘手的是空间。如果一个 minipage 满了但页面仍有全局可用空间,PAX 执行边界移动:它根据到目前为止的平均值大小重新计算 minipage 大小,并物理移动 minipage 内容来重新分配可用空间。这并不便宜。它可能涉及在页面周围移动大量数据。如果页面本身满了,则分配一个新页面。

删除

论文描述删除如下:PAX 在页面开头保留一个位图来跟踪已删除的记录。删除时,PAX 立即重新组织 minipage 内容以关闭已删除记录留下的间隙,最小化碎片并保持缓存密度。

这在 Shore(论文所基于的存储管理器)的背景下是有意义的。在 Postgres 中,正如我们将在下一节看到的,事情变得复杂了。

更新

PAX 通过计算定长属性在其 F-minipage 中的偏移量并原地覆盖来更新它。对于变长属性,如果新值大于旧值且 V-minipage 没有空间,论文建议从相邻的 minipage 借用空间。

我们不同意这种做法。将不同列的数据混合在同一个 minipage 中会破坏 PAX 设计的缓存局部性。如果你的 TEXT 值溢出到整数 minipage,你会将不相关的数据与整数一起加载到缓存中。PAX 的整个意义就失去了。

更好的方法:首先尝试边界移动,在所有 minipage 之间重新分配全局可用空间。如果页面确实满了,将记录移动到新页面。绝不从邻居借用。

MVCC 问题

以上所有内容在论文所基于的 Shore 存储管理器的背景下都是有意义的。在 Postgres 中,事情更复杂。

Postgres 永远不会原地修改数据。永远不要。这是其 MVCC 实现的基础。DELETE 不会删除一行。它在旧版本上设置 xmax 以将其标记为死亡。UPDATE 不是原地修改。它是删除旧版本后插入新版本。旧版本必须物理上保留在页面上,对在更改之前启动的并发事务可见。

这与论文的删除算法根本不相容,该算法要求立即压缩 minipage 以关闭已删除行留下的间隙。如果立即压缩,你会物理销毁可能仍有活动事务需要读取的数据。在 Postgres 中你不能这样做。

老实说,我还没有一个干净的解决方案,我怀疑这是 PAX 未在 Postgres 中实现的原因之一。如果你有想法,我真的很想听。

不过,有一个值得探索的方向。

一个可能的方向:元数据 Minipage

如果我们添加一个专用的元数据 minipage,即每个 PAX 页面的第一个 minipage,包含所有每行可见性信息:xmin、xmax、ctid,以及 Postgres 已经用于编码可见性信息的 infomask 标志会怎样?

由于 xmin、xmax、ctid 和 infomask 都是固定长度的整数,这个元数据 minipage 自然是一个 F-minipage。不需要特殊处理。PAX 存储引擎像对待任何其他定长列一样对待它。

这也为我们之前留下的删除问题提供了一个干净的答案。在 Postgres 中,删除不会压缩任何东西。它只是在元数据 minipage 中的旧行版本上设置 xmax,就像 Postgres 今天所做的那样。所有其他 minipage 的数据区域不受影响。旧版本保留在原地,对在删除之前启动的并发事务可见。

image.png

在这种设计下,三个操作变得干净。DELETE 只是在元数据 minipage 中设置 xmax。所有其他 minipage 的数据区域不受影响,旧版本对并发事务可见。INSERT 将新行版本追加到每个 minipage 并在元数据 minipage 中设置 xmin。UPDATE 是 DELETE 后跟 INSERT,就像 Postgres 今天做的那样:旧版本留在原地,新版本被追加。

Autovacuum 读取元数据 minipage 来识别死亡版本(其中 xmax 集群上最旧的活动事务更旧),一旦没有活动事务需要这些版本,就安全地压缩数据区域。

要读取任何值,你总是正好加载两个 minipage:元数据 minipage 检查可见性,目标列 minipage 获取数据。干净、可预测、缓存友好。

这是一个理论设计。工程细节 — 元数据 minipage 如何与 Postgres WAL、缓冲管理器和索引机制交互 — 仍然是开放问题。但方向感觉是对的。

实际缓解措施:Fillfactor 和 Toast

即使不考虑 MVCC 问题,边界移动也很昂贵。两个 Postgres 存储参数可以提供很大帮助。

第一个是 fillfactor。Postgres 已经允许你配置在新页面分配前页面有多满。PAX 实现将受益于比 NSM 更保守的默认值,大约 80%,留下足够的空间来吸收边界移动,而不会在每次插入时触发全页重组。它还为 MVCC 行版本保留空间,使 UPDATE 操作保持在同一页上,并允许 HOT 更新工作。

第二个是 toast_tuple_target。Postgres 已经公开了这个参数来控制变长值何时被压缩或移动到 TOAST 表。对于 PAX 表降低它意味着更多值被 TOAST 脱机处理,在 V-minipage 中替换为固定大小的 TOAST 指针。充满固定大小指针的 V-minipage 行为更像 F-minipage:可预测的大小、更少的边界移动、更好的缓存密度。权衡是对大值进行更多 TOAST 表访问。但在分析工作负载中,当你扫描多行的少数几列时,你通常不会读取大的变长列。

这两个参数一起为 PAX 实现提供了对边界移动问题的有意义的控制,而不需要更改核心算法:

CREATE TABLE my_table (...)
USING pax
WITH (fillfactor = 80, toast_tuple_target = 512);

我们现在在哪里?

PAX 不是简单的即插即用优化。论文描述了一个干净优雅的概念,但将其映射到 Postgres 揭示了一层又一层的复杂性:NULL 处理、MVCC 兼容性、边界移动成本、Autovacuum 集成、WAL 影响。

然而,核心思想仍然令人信服。缓存污染问题是真实的,它是可以衡量的,而且随着每一代 CPU 变得越来越严重。PAX 在正确的层面解决了这个问题:在页面内部,无需触及存储栈的其余部分。

元数据 minipage 方向看起来很有前景。fillfactor 和 toast_tuple_target 的缓解措施是实用的,今天就可以使用。艰苦的工程工作仍有待完成,但道路是可见的。

这就是我继续写它的原因。不是因为我有了所有答案,而是因为我认为这些问题值得大声问出来。

Postgres 中缓存感知存储的旅程才刚刚开始。如果你是一名 C 开发者或存储爱好者,我很乐意听到你的想法或合作这些概念。如果你有兴趣讨论如何将 PAX 变为现实,请直接联系我!


参考文献:

Ailamaki, A., DeWitt, D. J., Hill, M. D., & Skounakis, M. (2001). Weaving Relations for Cache Performance. Proceedings of the 27th International Conference on Very Large Data Bases (VLDB).