頁面修剪#
在 heap 頁面被讀取或更新的過程中,PostgreSQL 可以順手做一次快速的頁面清理,稱為修剪(pruning)。它發生在以下情況:
- 先前的
UPDATE操作在同一頁面中找不到足夠空間放置新元組(這個事件會記錄在頁首) - heap 頁面所含的資料超過
fillfactor儲存參數所允許的量
頁面修剪移除的是再也無法在任何快照中可見的元組(即位於資料庫水平線之外者)。它的特性:
- 從不跨越單一 heap 頁面,作為回報,執行得非常快
- 被修剪元組的指標仍留在原處,因為它們可能被索引參照——而索引已經是另一個頁面了
- 基於同樣理由,可見性映射與可用空間映射都不會被更新(因此回收的空間是留給更新用,而非插入用)
由於頁面可能在讀取時被修剪,任何
SELECT敘述都可能造成頁面修改。這是除了延後設定資訊位元之外的另一種情況。
實驗:修剪如何運作#
建立雙欄位表格,兩欄各建一個索引,並把 fillfactor 設為 75%:
=> CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
=> CREATE INDEX hot_id ON hot(id);
=> CREATE INDEX hot_s ON hot(s);若 s 欄位只含拉丁字母,每個 heap 元組固定為 2004 位元組加 24 位元組標頭。頁面空間足夠放四個元組,但因 fillfactor 為 75% 而只能插入三個。
插入一列並更新數次後,頁面含有四個元組,已超過 fillfactor 門檻:
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−
(0,1) | normal | 801 c | 802 c
(0,2) | normal | 802 c | 803 c
(0,3) | normal | 803 c | 804
(0,4) | normal | 804 | 0 a
(4 rows)下一次頁面存取就會觸發修剪,移除所有過期元組,新元組隨即寫入釋出的空間:
=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
(0,1) | dead | |
(0,2) | dead | |
(0,3) | dead | |
(0,4) | normal | 804 c | 805
(0,5) | normal | 805 | 0 a
(5 rows)- 剩餘的 heap 元組被實體移往高位址,使所有可用空間聚合成單一連續區塊;元組指標也相應修改,頁面中不會出現可用空間碎片化
- 被修剪元組的指標還不能移除(仍被索引參照),PostgreSQL 只把它們的狀態從
normal改為dead
索引端的連帶處理#
索引掃描可能回傳 (0,1)、(0,2)、(0,3) 這些元組識別碼。伺服器嘗試讀取對應的 heap 元組,卻發現指標處於 dead 狀態——這表示該元組已不存在、應予忽略。
順帶一提,伺服器同時也會修改索引頁面中的指標狀態,以避免重複存取 heap 頁面。
第一次索引掃描發生後,索引項目的 dead 旗標就被設起來:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
−−−−−−−−−−−−+−−−−−−−+−−−−−−
1 | (0,1) | t
2 | (0,2) | t
3 | (0,3) | t
4 | (0,4) | t
5 | (0,5) | f
(5 rows)第四個指標所參照的 heap 元組雖然尚未被修剪、仍為
normal狀態,但它已經在資料庫水平線之外——因此該指標在索引中同樣被標記為 dead。
HOT 更新#
把所有 heap 元組的參照都保留在索引中是非常沒效率的:
- 每次列修改都會觸發表格上所有索引的更新——只要新的 heap 元組出現,每個索引都必須納入指向它的參照,即使被修改的欄位根本沒被索引
- 索引會累積指向歷史 heap 元組的參照,因此必須與這些元組一起被清理
- 表格上的索引越多,情況越糟
但若被更新的欄位不屬於任何索引,就沒有理由再建立一筆鍵值相同的索引項目。為避免這種冗餘,PostgreSQL 提供 HOT(Heap-Only Tuple)更新最佳化。
執行 HOT 更新時:
- 索引頁面中每一列只有一筆項目,指向最初的列版本
- 位於同一頁面的所有後續版本,透過元組標頭中的
ctid指標串成一條鏈 - 未被任何索引參照的列版本,標記 Heap-Only Tuple 位元(
hot) - 被納入 HOT 鏈的版本,標記 Heap Hot Updated 位元(
hhu)
索引掃描存取 heap 頁面時若發現某列版本標有 Heap Hot Updated,代表掃描應繼續,於是沿著 HOT 更新鏈往下走。當然,所有取得的列版本在回傳給客戶端之前都會經過可見性檢查。
實驗:HOT 鏈的成長#
刪掉 hot_s 索引並清空表格後,插入一列並更新:
=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−+−−−−−+−−−−−+−−−−−−−−
(0,1) | normal | 812 c | 813 | t | | (0,2)
(0,2) | normal | 813 | 0 a | | t | (0,2)
(2 rows)繼續更新,鏈會持續成長——但只在頁面範圍內:
=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−+−−−−−+−−−−−+−−−−−−−−
(0,1) | normal | 812 c | 813 c | t | | (0,2)
(0,2) | normal | 813 c | 814 c | t | t | (0,3)
(0,3) | normal | 814 c | 815 | t | t | (0,4)
(0,4) | normal | 815 | 0 a | | t | (0,4)
(4 rows)而索引始終只有一筆參照,指向鏈的頭部:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
−−−−−−−−−−−−+−−−−−−−+−−−−−−
1 | (0,1) | f
(1 row)由於 HOT 鏈只能在單一頁面內成長,走訪整條鏈永遠不需要存取其他頁面,因此不會拖累效能。
HOT 更新鏈的修剪#
頁面修剪有一個特殊但重要的情況:HOT 更新鏈的修剪。
鏈的頭部必須永遠留在原位(因為索引參照它),但其他指標可以被釋放(它們確定沒有外部參照)。
為避免移動頭部,PostgreSQL 使用雙重定址:被索引參照的那個指標會取得
redirect狀態,指向目前作為鏈起點的元組。
=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−+−−−−−−+−−−−−+−−−−−+−−−−−−−−
(0,1) | redirect to 4 | | | | |
(0,2) | normal | 816 | 0 a | | t | (0,2)
(0,3) | unused | | | | |
(0,4) | normal | 815 c | 816 | t | t | (0,2)
(4 rows)- 元組
(0,1)、(0,2)、(0,3)已被修剪 - 頭部指標 1 保留下來作重導向之用
- 指標 2、3 被釋放(取得
unused狀態),因為它們保證沒有來自索引的參照 - 新元組寫入釋出的空間,成為
(0,2)
再更新數次後,下一次更新會再度觸發修剪,鏈頭指標也隨之調整(例如變成 redirect to 5)。
若未被索引的欄位經常被修改,把
fillfactor調低是合理的做法,可在頁面中預留空間給更新使用。但必須記得:
fillfactor越低,頁面中留下的可用空間越多,表格的實體大小也就越大。
HOT 鏈斷裂#
若頁面再也沒有空間容納新元組,鏈就會被切斷。PostgreSQL 必須新增一筆獨立的索引項目,用來參照位於另一頁面的元組。
實驗:用並行快照阻擋修剪,逼出鏈斷裂
先啟動一筆並行交易,其快照會阻擋頁面修剪:
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1;在第一個 session 中連續更新,頁面被填滿:
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−+−−−−−−−+−−−−−+−−−−−+−−−−−−−−
(0,1) | redirect to 2 | | | | |
(0,2) | normal | 819 c | 820 c | t | t | (0,3)
(0,3) | normal | 820 c | 821 c | t | t | (0,4)
(0,4) | normal | 821 c | 822 | t | t | (0,5)
(0,5) | normal | 822 | 0 a | | t | (0,5)
(5 rows)下一次更新時,頁面無法再容納元組,而修剪也騰不出任何空間:
=> UPDATE hot SET s = 'L';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−+−−−−−−−+−−−−−+−−−−−+−−−−−−−−
(0,1) | redirect to 2 | | | | |
...
(0,5) | normal | 822 c | 823 | | t | (1,1)
(5 rows)元組 (0,5) 含有指向第 1 頁的 (1,1) 參照。但這個參照不會被使用:(0,5) 的 Heap Hot Updated 位元並未設定。至於 (1,1),它可從索引存取——索引現在有兩筆項目,各自指向自己那條 HOT 鏈的頭部:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
−−−−−−−−−−−−+−−−−−−−+−−−−−−
1 | (0,1) | f
2 | (1,1) | f
(2 rows)索引的頁面修剪#
前面說過頁面修剪局限於單一 heap 頁面、不影響索引。不過索引有自己的修剪機制,同樣只清理單一頁面——這次是索引頁面。
索引修剪發生在即將因空間不足而把 B-tree 頁面一分為二的時候。
問題在於:即使日後刪除了某些索引項目,兩個分裂後的索引頁面也不會再合併回一個。這會導致索引膨脹,而一旦膨脹,即使刪掉大部分資料索引也無法縮小。
但若修剪能移除部分元組,頁面分裂就可以被延後。
索引中有兩類元組可被修剪:
- 已被標記為
dead的元組。如前所述,PostgreSQL 在索引掃描時若偵測到某索引項目指向「已不在任何快照中可見、或根本不存在」的元組,就會設上這個標記。 - 指向同一表格列不同版本的索引項目(PostgreSQL 14 起)。由於 MVCC,更新操作可能產生大量列版本,其中許多很快就會落到資料庫水平線之後。HOT 更新能緩解這個效應,但並非總是適用:若待更新的欄位屬於某索引,對應的參照就會被傳播到所有索引。
在頁面分裂之前,去找那些「尚未被標記為 dead、但其實已可修剪」的列是划算的。要做到這點,PostgreSQL 必須檢查 heap 元組的可見性——這需要存取表格,因此只對「有希望的」(promising)索引元組執行,也就是那些為 MVCC 目的而從既有項目複製出來的元組。
做這種檢查,比放任一次多餘的頁面分裂發生要便宜。