前言
對 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