【译】PostgreSQL 14 B-Tree Index:通过自下而上删除减少膨胀

前言

对 PostgreSQL 中数据的并发访问由多版本并发控制 (MVCC) 模型管理。 为每个 SQL 语句维护数据快照,以便它们始终获得一致的数据,即使其他事务正在同时对其进行修改。 当行已被一个或多个事务修改时,这将导致管理同一行的多个版本。 从用户的角度来看,可能只有一行数据,但 PostgreSQL 内部可能维护该行的一个或多个版本。

行版本是否对事务可见是通过堆中的行数据来维护的。 为了优化可见性信息的获取,PostgreSQL 还维护了一个“_vm”关系分支,它跟踪那些只包含对所有活跃事务可见的元组的页面。

对任何事务不再可见的死版本将由 vacuum 进程清除。 在此之前,索引和堆页面可能包含大量死元组(这实际上取决于您的工作负载的性质)。 对于更新密集型工作负载,这可能是一个巨大的数字!

乍一看似乎无害,但这种死索引元组的累积会产生级联效应,从而导致性能显着下降。 在 PostgreSQL 13 中完成重复数据删除工作后,下一个合乎逻辑的步骤是通过减少页面拆分来防止 btree 索引膨胀。

物理数据存储

PostgreSQL 将数据保存在被称为页面的固定大小存储单元中。 页面的大小是在 PostgreSQL 服务器编译过程中定义的。 默认页面大小为 8k,但可以将其更改为更高的值。 虽然更改页面大小会使事情变得复杂,因为其他工具可能也需要重新编译或重新配置。

每个表和索引都存储在页数组中。 将数据插入表中时,会将数据写入具有足够可用空间的页面。 否则,将创建一个新页面。

然而,索引有点不同。 索引中的第一页是一个元页面,其中包含有关索引的控制信息。 也有一些特殊的页面来维护索引相关的信息。 对于 btree 索引,数据必须根据索引列和堆元组 ID(元组在表中的物理位置)进行排序。 因此,插入和更新必须在正确的页面上进行,以保持排序顺序。 如果页面没有足够的空间容纳传入的元组,则必须创建新页面,并将溢出页面中的一些项目移动到新页面。 如果需要,这些叶子页面的父页面会被递归拆分。

避免页面分裂

当新的元组或新的非 HOT 元组版本要添加到索引中时,会发生 B-Tree 索引页拆分。 HOT 是“heap only tuple”的缩写。 基本而言,它是一种删除给定页面上的死行(碎片整理)并因此为新行腾出空间的方法。 通过避免或延迟页面拆分,我们可以避免或减慢索引扩展,从而减少膨胀。 现在这很令人兴奋!

虽然对新元组没有太多可做的事情,但可以管理更新,以便可以增量删除逻辑上未更改的索引元组(即未更改的索引列)的过时版本,以保持新版本的可用空间。 这个过程得到了规划器的帮助,规划器向索引方法提供了一个提示,“索引未更改”。 如果没有任何索引列由于此更新而更改,则为 true。

自下而上的删除是在索引操作期间完成的,当预期“版本搅动页面拆分”时(“索引未更改”提示为真)。 逻辑上未更改的索引元组的过时版本将被删除,从而在页面上为较新版本腾出空间。 这种方法潜在地避免了页面拆分。

自下而上的删除操作

为了看到这种自下而上的删除方法的实际好处,让我们更深入地研究一下 B-Tree 索引。 我们将比较 PostgreSQL 版本 13 和 14 之间的 btree 索引大小。为了更详细地检查索引数据,我将使用 contrib 模块中提供的“pageinspect”扩展。 “pageinspect”扩展允许我们查看索引或表的底层页面内容。

让我们从创建 pageinspect 扩展开始。您可能需要安装 contrib 模块,或者如果您是从源代码构建,请安装它然后继续。

CREATE EXTENSION IF NOT EXISTS pageinspect;

现在让我们创建一个包含两列的表“foo”,创建两个包含一个覆盖索引的索引,并分析该表。

DROP TABLE IF EXISTS foo;
CREATE TABLE foo WITH (autovacuum_enabled = false) AS (SELECT GENERATE_SERIES(1, 1000) AS col1, SUBSTR(MD5(RANDOM()::TEXT), 0, 25) AS value);
CREATE INDEX ON foo(col1);
CREATE INDEX ON foo(col1) INCLUDE(value);

是时候检查“foo”表的页面、元组和关系大小了。

SELECT  relname
        , relkind
        , relpages
        , reltuples
        , PG_SIZE_PRETTY(PG_RELATION_SIZE(oid))
FROM    pg_class
WHERE   relname LIKE '%foo%'
ORDER
BY      relkind DESC;

      relname       | relkind | relpages | reltuples | pg_size_pretty 
--------------------+---------+----------+-----------+----------------
 foo                | r       |        8 |      1000 | 64 kB
 foo_col1_idx       | i       |        5 |      1000 | 40 kB
 foo_col1_value_idx | i       |        9 |      1000 | 72 kB
(3 rows)

14.1 和 13.5 都为上述查询提供完全相同的输出。

禁用顺序扫描和位图扫描以强制进行索引扫描。这将强制此示例中的查询使用索引扫描

SET enable_seqscan = false;
SET enable_bitmapscan = false;

创建四个新版本的元组

UPDATE foo SET value = value || 'x';
UPDATE foo SET value = value || 'x';
UPDATE foo SET value = value || 'x';
UPDATE foo SET value = value || 'x';

上述语句每次更新 1000 行。 ANALYZE 表以确保我们的统计数据准确无误。还让我们回顾一下“foo”表的页数、元组和关系大小。

ANALYZE foo;

SELECT  relname
        , relkind
        , relpages
        , reltuples
        , PG_SIZE_PRETTY(PG_RELATION_SIZE(oid))
FROM    pg_class
WHERE   relname LIKE '%foo%'
ORDER
BY      relkind DESC;

--PostgreSQL 14.1
      relname       | relkind | relpages | reltuples | pg_size_pretty 
--------------------+---------+----------+-----------+----------------
 foo                | r       |        8 |      1000 | 288 kB
 foo_col1_idx       | i       |        5 |      1000 | 88 kB
 foo_col1_value_idx | i       |        9 |      1000 | 216 kB
(3 rows)


--PostgreSQL 13.5
--------------------+---------+----------+-----------+----------------
 foo                | r       |        8 |      1000 | 288 kB
 foo_col1_idx       | i       |        5 |      1000 | 104 kB
 foo_col1_value_idx | i       |        9 |      1000 | 360 kB
(3 rows)

两个版本的表大小都增加了相同的数量,但是 14.1 中的索引与 13.5 相比明显更小。 很好,让我们检查页面内容以了解幕后发生的事情。

查看第一个索引页面(不是元页面)的内容清楚地显示了自下而上的删除如何使索引大小保持较小。

SELECT  itemoffset
        , ctid
        , itemlen
        , nulls
        , vars
        , dead
        , htid
FROM    bt_page_items('foo_col1_value_idx', 1)
LIMIT   15;

PostgreSQL 14.1
 itemoffset |  ctid   | itemlen | nulls | vars | dead |  htid   
------------+---------+---------+-------+------+------+---------
          1 | (7,1)   |      16 | f     | f    |      | 
          2 | (7,181) |      40 | f     | t    | f    | (7,181)
          3 | (7,225) |      48 | f     | t    | f    | (7,225)
          4 | (7,182) |      40 | f     | t    | f    | (7,182)
          5 | (7,226) |      48 | f     | t    | f    | (7,226)
          6 | (7,183) |      40 | f     | t    | f    | (7,183)
          7 | (7,227) |      48 | f     | t    | f    | (7,227)
          8 | (7,184) |      40 | f     | t    | f    | (7,184)
          9 | (7,228) |      48 | f     | t    | f    | (7,228)
         10 | (7,185) |      40 | f     | t    | f    | (7,185)
         11 | (7,229) |      48 | f     | t    | f    | (7,229)
         12 | (7,186) |      40 | f     | t    | f    | (7,186)
         13 | (7,230) |      48 | f     | t    | f    | (7,230)
         14 | (7,187) |      40 | f     | t    | f    | (7,187)
         15 | (7,231) |      48 | f     | t    | f    | (7,231)
(15 rows)


PostgreSQL 13.5
 itemoffset |  ctid   | itemlen | nulls | vars | dead |  htid   
------------+---------+---------+-------+------+------+---------
          1 | (0,1)   |      16 | f     | f    |      | 
          2 | (0,1)   |      40 | f     | t    | f    | (0,1)
          3 | (7,49)  |      40 | f     | t    | f    | (7,49)
          4 | (7,137) |      40 | f     | t    | f    | (7,137)
          5 | (7,181) |      40 | f     | t    | f    | (7,181)
          6 | (7,225) |      48 | f     | t    | f    | (7,225)
          7 | (0,2)   |      40 | f     | t    | f    | (0,2)
          8 | (7,50)  |      40 | f     | t    | f    | (7,50)
          9 | (7,138) |      40 | f     | t    | f    | (7,138)
         10 | (7,182) |      40 | f     | t    | f    | (7,182)
         11 | (7,226) |      48 | f     | t    | f    | (7,226)
         12 | (0,3)   |      40 | f     | t    | f    | (0,3)
         13 | (7,51)  |      40 | f     | t    | f    | (7,51)
         14 | (7,139) |      40 | f     | t    | f    | (7,139)
         15 | (7,183) |      40 | f     | t    | f    | (7,183)
(15 rows)

查看 14.1 的 2 到 3 和 13.5 的 2 到 6 的“itemoffset”可以告诉我们整个故事。 13.5 携带了整套元组版本,而 14.1 清理了死元组以腾出空间。 版本越少,页面就越少,从而减少膨胀,并为我们提供更小的索引大小。

结论

在 PostgreSQL 14 版本中,由于底部删除而减少索引大小是一个巨大的优势。Btree 索引具有一种机制,其中普通索引扫描设置 LP_DEAD 标志。 这不是为位图索引扫描设置的。 一旦设置好,就可以在不需要真空的情况下回收空间。 然而,这是完全不同的一类死元组。 从长远来看,这种自下而上的删除策略有助于显着减少特定类别的重复项。 它不仅减少了vacuum 的负载,还有助于保持索引更健康,从而提高访问速度。 因此,如果您的更新工作量很大,那么在提供更好性能的同时,肯定会节省资源利用率和成本。

原文地址

PostgreSQL 14 B-Tree Index: Reduced Bloat with Bottom-Up Deletion

你可能感兴趣的:(【译】PostgreSQL 14 B-Tree Index:通过自下而上删除减少膨胀)