Vacuum#

頁面修剪執行得非常快,但它只釋放部分可回收空間:它在單一 heap 頁面內運作,不觸及索引(反之亦然,索引修剪也不影響表格)。

例行清理(routine vacuuming)是主要的清理程序,由 VACUUM 指令執行。它處理整張表格,同時消除過期的 heap 元組與所有對應的索引項目

清理與資料庫系統中的其他行程並行進行。被清理期間,表格與索引仍可正常用於讀寫操作(但不允許並行執行 CREATE INDEXALTER TABLE 等指令)。

為避免掃描多餘頁面,PostgreSQL 會運用可見性映射

  • 映射中已標記的頁面會被跳過(它們確定只含目前的元組)
  • 頁面只有在未出現於該映射時才會被清理
  • 清理後若某頁剩下的元組全都在資料庫水平線之外,可見性映射會更新以納入該頁
  • 可用空間映射也會更新,以反映被清出的空間

實驗:VACUUM 與頁面修剪的差別#

=> CREATE TABLE vac(
  id integer,
  s char(100)
)
WITH (autovacuum_enabled = off);
=> CREATE INDEX vac_s ON vac(s);

插入一列並更新兩次後,表格含三個元組,索引也有三筆參照。執行 VACUUM 後:

=> VACUUM vac;
=> SELECT * FROM heap_page('vac',0);
 ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−+−−−−−+−−−−−+−−−−−−−−
 (0,1) | unused |       |      |     |     |
 (0,2) | unused |       |      |     |     |
 (0,3) | normal | 828 c | 0 a |      |     | (0,3)
(3 rows)

清理後該 heap 頁面出現在可見性映射中,頁首也取得「所有元組在所有快照中皆可見」的屬性:

=> CREATE EXTENSION pg_visibility;
=> SELECT all_visible FROM pg_visibility_map('vac',0);   -- t
=> SELECT flags & 4 > 0 AS all_visible
   FROM page_header(get_raw_page('vac',0));              -- t

再談資料庫水平線#

清理是依據資料庫水平線來偵測死元組的。這個概念如此根本,值得再回顧一次。

重做實驗,但這次在更新之前先開啟另一筆會把持資料庫水平線的交易(幾乎任何交易都行,除了 Read Committed 層級的虛擬交易):

    => BEGIN;
    => UPDATE accounts SET amount = 0;

=> UPDATE vac SET s = 'C';
=> VACUUM vac;
=> SELECT * FROM heap_page('vac',0);
 ctid | state | xmin | xmax | hhu | hot | t_ctid
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−+−−−−−+−−−−−+−−−−−−−−
 (0,1) | unused |       |       |     |     |
 (0,2) | normal | 833 c | 835 c |     |     | (0,3)
 (0,3) | normal | 835 c | 0 a   |     |     | (0,3)
(3 rows)

上一輪只留下一個元組,這次卻留下兩個:VACUUM 判定版本 (0,2) 還不能移除,原因就是那筆未完成交易所把持的資料庫水平線。

VERBOSE 子句可以看到究竟發生了什麼:

=> VACUUM VERBOSE vac;
INFO: table "vac": found 0 removable, 2 nonremovable row versions
in 1 out of 1 pages
DETAIL: 1 dead row versions cannot be removed yet, oldest xmin: 834

輸出的意思是:

  • VACUUM 沒偵測到任何可移除的元組(0 removable
  • 有兩個元組不可移除2 nonremovable
  • 其中一個是死元組(1 dead),另一個仍在使用中
  • VACUUM 所遵循的目前水平線(oldest xmin)就是那筆活躍交易的水平線

活躍交易一提交,資料庫水平線就往前移動,清理便能繼續——VACUUM 隨即偵測並移除新水平線之外的死元組,頁面與索引都只剩下目前版本。

Vacuum 的各階段#

清理機制看似簡單,但這印象具誤導性:畢竟表格與索引必須並行處理且不阻塞其他行程。為此,每張表格的清理分成數個階段進行。

整體流程是:掃描表格尋找死元組 → 先從索引移除 → 再從表格移除。若一次要清理的死元組太多,這個過程會重複。最後可能執行 heap 截斷。

1. Heap 掃描#

第一階段執行 heap 掃描。掃描過程會考量可見性映射:映射中已標記的頁面全部跳過(確定不含過期元組)。若某元組在水平線之外且不再被需要,其 TID 會被加入一個特殊的 tid 陣列

這些元組還不能移除,因為它們可能仍被索引參照。

tid 陣列位於 VACUUM 行程的本地記憶體中,配置大小由 maintenance_work_mem 參數定義(預設 64 MB)。整塊記憶體是一次配置完成,而非按需配置。不過配置量絕不會超過最壞情況所需,因此小表格的清理可能用到的記憶體比參數所指定的少。

2. 索引清理#

第一階段有兩種結局:表格被完整掃描,或 tid 陣列的記憶體在掃描完成前就被填滿。無論哪種情況,索引清理都會開始。

此階段會完整掃描表格上的每個索引,找出所有指向 tid 陣列中已登記元組的項目,並從索引頁面移除它們。

索引能讓你用索引鍵快速找到 heap 元組,但沒有辦法用元組 ID 快速找到索引項目。這項功能目前正在為 B-tree 實作中,但尚未完成——這正是必須完整掃描索引的原因。

平行索引清理:若有多個索引大於 min_parallel_index_scan_size(預設 512 kB),它們可由平行執行的背景工作者清理。除非以 PARALLEL N 子句明確定義平行度,VACUUM 會為每個合適的索引啟動一個工作者(受背景工作者總數的一般限制約束)。單一索引無法由多個工作者處理。

索引清理階段中,PostgreSQL 會更新可用空間映射並計算清理統計。

若列只被插入(既不刪除也不更新),表格中就沒有死元組,此階段會被跳過。此時只會在最後強制執行一次索引掃描,作為獨立的索引清理階段。

索引清理階段結束後,索引中不再有指向過期 heap 元組的參照,但元組本身仍在表格中。這完全正常:索引掃描找不到任何死元組,而表格的循序掃描則依可見性規則將它們過濾掉。

3. Heap 清理#

接著進入 heap 清理階段:再次掃描表格,移除 tid 陣列中登記的元組並釋放對應指標。既然所有相關的索引參照都已移除,這麼做是安全的。

  • VACUUM 回收的空間反映在可用空間映射
  • 現在只含「在所有快照中皆可見的目前元組」的頁面,會在可見性映射中被標記

若 heap 掃描階段未讀完整張表格,tid 陣列會被清空,heap 掃描從上次中斷處繼續。

4. Heap 截斷#

被清理過的 heap 頁面含有一些可用空間;偶爾運氣好還能清空整個頁面。若檔案末尾出現數個空頁面,清理可以「咬掉」這條尾巴,把回收的空間還給作業系統。這發生在最後的 heap 截斷階段。

Heap 截斷需要對表格取得短暫的排他鎖。為避免拖住其他行程太久,取鎖的嘗試不超過五秒

由於必須鎖表格,截斷只在空尾巴至少佔表格的 1/16、或已達到 1,000 個頁面時才執行。這些門檻寫死在程式碼中,無法設定。

若這些預防措施仍不足、表格鎖依舊造成問題,可用 vacuum_truncatetoast.vacuum_truncate 儲存參數完全停用截斷。

分析#

談清理時必須提到另一項與之密切相關的任務——雖然兩者之間並無形式上的關聯。這就是分析(analysis),也就是為查詢規劃器蒐集統計資訊。

蒐集的統計包含:

  • 關聯中的列數(pg_class.reltuples)與頁數(pg_class.relpages
  • 欄位內的資料分佈
  • 以及其他資訊

可用 ANALYZE 指令手動執行分析,或呼叫 VACUUM ANALYZE 與清理合併。

這兩項任務仍是循序執行的,因此在效能上沒有差別。

就歷史而言,VACUUM ANALYZE 先出現於 6.1 版,獨立的 ANALYZE 指令直到 7.2 版才實作。更早的版本靠一個 SQL 腳本蒐集統計。

自動清理與分析#

只要資料庫水平線沒有被長時間把持,例行清理應該足以應付工作。但我們該多久呼叫一次 VACUUM

  • 清理太少:頻繁更新的表格會膨脹到超出預期,還可能累積過多變更,導致下一次 VACUUM 必須對索引做好幾趟掃描
  • 清理太頻繁:伺服器忙於維護而非有用的工作
  • 此外典型工作負載會隨時間改變,固定的清理排程根本幫不上忙:表格更新越頻繁,就必須越常清理

這個問題由 autovacuum 解決:它依據表格更新的強度來啟動清理與分析行程。

Autovacuum 機制#

啟用 autovacuum 時(autovacuum 組態參數為 on),系統中一直運行著 autovacuum launcher 行程。它定義自動清理排程,並依使用統計維護「活躍」資料庫清單。

這類統計需啟用 track_counts 參數才會蒐集。不要關閉 autovacuumtrack_counts 這兩個參數,否則自動清理將無法運作。

運作方式:

  • 每隔 autovacuum_naptime(預設 1 分鐘),launcher 為清單中每個活躍資料庫啟動一個 autovacuum worker(一如既往由 postmaster 衍生)
  • 因此若叢集中有 N 個活躍資料庫,autovacuum_naptime 區間內會衍生 N 個工作者
  • 並行運行的工作者總數不能超過 autovacuum_max_workers(預設 3)

工作者啟動後連上指定資料庫,建立兩份清單:

  1. 所有待清理的表格、物化視圖與 TOAST 表格
  2. 所有待分析的表格與物化視圖(TOAST 表格不分析,因為它們永遠透過索引存取)

接著逐一清理或分析這些物件(或兩者都做),完成後工作者即終止。

Autovacuum 工作者與一般背景工作者非常相似,但它們出現得比這套通用任務管理機制早得多。當初決定不更動 autovacuum 的實作,因此 autovacuum 工作者不佔用 max_worker_processes 名額

自動清理與手動 VACUUM 的運作方式類似,但有幾個差異:

  • 記憶體限制:手動清理時元組 ID 累積在 maintenance_work_mem 大小的記憶體塊中。對 autovacuum 沿用同一限制並不理想——可能有多個工作者並行運行,每個都一次取走 maintenance_work_mem,導致記憶體消耗過度。因此 PostgreSQL 提供獨立的 autovacuum_work_mem 參數。
  • 平行索引處理:一張表格上多個索引的並行處理只有手動清理能做;用 autovacuum 做會產生大量平行行程,因此不被允許。

若某個工作者無法在 autovacuum_naptime 區間內完成所有排定任務,launcher 會在該資料庫中再衍生一個工作者平行運行。第二個工作者會建立自己的清單並開始處理。表格層級沒有平行性——只有不同的表格才能被並行處理。

哪些表格需要清理?#

可在表格層級停用 autovacuum(雖然很難想像為何需要),透過 autovacuum_enabledtoast.autovacuum_enabled 兩個儲存參數。

一般情況下,autovacuum 由死元組累積新列插入觸發。

死元組累積#

死元組數量由統計蒐集器持續計算,目前值顯示在 pg_stat_all_tables 系統目錄表格中。觸發條件:

pg_stat_all_tables.n_dead_tup >
autovacuum_vacuum_threshold +            -- 預設 50
autovacuum_vacuum_scale_factor × pg_class.reltuples   -- 預設 0.2

這裡的主角當然是 autovacuum_vacuum_scale_factor:它的值對大表格至關重要——而大表格正是最可能引發問題的地方。預設的 20% 看起來太大,可能需要大幅調降。

不同表格的最佳參數值可能差異很大,主要取決於表格大小與工作負載類型。合理的做法是設定還算適當的初始值,再用儲存參數為特定表格覆寫(autovacuum_vacuum_thresholdautovacuum_vacuum_scale_factor 及其 TOAST 對應版本)。

列插入(PostgreSQL 13 起)#

若列只被插入、既不刪除也不更新,表格中就沒有死元組。但這類表格同樣應該被清理,以便預先凍結 heap 元組並更新可見性映射(從而啟用唯索引掃描)。觸發條件:

pg_stat_all_tables.n_ins_since_vacuum >
autovacuum_vacuum_insert_threshold +           -- 預設 1000
autovacuum_vacuum_insert_scale_factor × pg_class.reltuples   -- 預設 0.2

哪些表格需要分析?#

自動分析只需處理被修改的列,因此計算比自動清理稍簡單:

pg_stat_all_tables.n_mod_since_analyze >
autovacuum_analyze_threshold +           -- 預設 50
autovacuum_analyze_scale_factor × pg_class.reltuples   -- 預設 0.1

由於 TOAST 表格不被分析,它們沒有對應的參數。

實作:need_vacuum 與 need_analyze 視圖

把上述規則形式化,建立兩個視圖顯示目前哪些表格需要清理與分析。輔助函式回傳參數的目前值,並考量該值可能在表格層級被重新定義:

=> CREATE FUNCTION p(param text, c pg_class) RETURNS float
AS $$
  SELECT coalesce(
    -- use storage parameter if set
    (SELECT option_value
      FROM  pg_options_to_table(c.reloptions)
      WHERE option_name = CASE
              -- for TOAST tables the parameter name is different
              WHEN c.relkind = 't' THEN 'toast.' ELSE ''
            END || param
    ),
    -- else take the configuration parameter value
    current_setting(param)
  )::float;
$$ LANGUAGE sql;

=> CREATE VIEW need_vacuum AS
WITH c AS (
  SELECT c.oid,
    greatest(c.reltuples, 0) reltuples,
    p('autovacuum_vacuum_threshold', c) threshold,
    p('autovacuum_vacuum_scale_factor', c) scale_factor,
    p('autovacuum_vacuum_insert_threshold', c) ins_threshold,
    p('autovacuum_vacuum_insert_scale_factor', c) ins_scale_factor
  FROM pg_class c
  WHERE c.relkind IN ('r','m','t')
)
SELECT st.schemaname || '.' || st.relname AS tablename,
  st.n_dead_tup AS dead_tup,
  c.threshold + c.scale_factor * c.reltuples AS max_dead_tup,
  st.n_ins_since_vacuum AS ins_tup,
  c.ins_threshold + c.ins_scale_factor * c.reltuples AS max_ins_tup,
  st.last_autovacuum
FROM pg_stat_all_tables st
  JOIN c ON c.oid = st.relid;

=> CREATE VIEW need_analyze AS
WITH c AS (
  SELECT c.oid,
    greatest(c.reltuples, 0) reltuples,
    p('autovacuum_analyze_threshold', c) threshold,
    p('autovacuum_analyze_scale_factor', c) scale_factor
  FROM pg_class c
  WHERE c.relkind IN ('r','m')
)
SELECT st.schemaname || '.' || st.relname AS tablename,
  st.n_mod_since_analyze AS mod_tup,
  c.threshold + c.scale_factor * c.reltuples AS max_mod_tup,
  st.last_autoanalyze
FROM pg_stat_all_tables st
  JOIN c ON c.oid = st.relid;

max_dead_tup 顯示觸發自動清理的死元組數,max_ins_tup 顯示插入相關門檻,max_mod_tup 則是自動分析的門檻。

實際觀察#

清空 vac 表格並插入 1,000 列(表格層級的 autovacuum 仍關閉)後:

tablename       | public.vac
dead_tup        | 0
max_dead_tup    | 50
ins_tup         | 1000
max_ins_tup     | 1000

實際門檻是 max_dead_tup = 50,而非公式算出的 50 + 0.2 × 1000 = 250。原因是這張表格還沒有統計資訊INSERT 指令不會更新它):

=> SELECT reltuples FROM pg_class WHERE relname = 'vac';
 reltuples
−−−−−−−−−−−
        1
(1 row)

開啟 autovacuum 後分析立即被觸發;分析完成後門檻被重設為合理的值——max_mod_tup 變成 150、max_dead_tup 變成 250、max_ins_tup 變成 1200。此後只要滿足下列任一條件就會啟動清理:

  • 累積超過 250 個死元組
  • 表格中插入超過 1,200 列

負載管理#

清理在頁面層級運作,不會阻塞其他行程;但它仍然會增加系統負載,可能對效能造成可觀的影響。

VACUUM 節流#

為控制清理強度,PostgreSQL 會在表格處理過程中規律地暫停:完成約 vacuum_cost_limit 單位的工作後,行程進入睡眠,閒置 vacuum_cost_delay 時間。

各項操作的成本估算:

操作參數預設
頁面在緩衝快取中被找到vacuum_cost_page_hit1
頁面不在快取中vacuum_cost_page_miss2
乾淨頁面被 vacuum 弄髒vacuum_cost_page_dirty20(額外)

若保留 vacuum_cost_limit 的預設值,VACUUM 每個週期最好情況可處理 200 個頁面(全部命中快取且未弄髒),最壞情況只有 9 個頁面(全部從磁碟讀取且都被弄髒)。

Autovacuum 節流#

Autovacuum 的節流與 VACUUM 相當類似,但它有自己一套參數,可用不同強度運行:

  • autovacuum_vacuum_cost_limit(預設 −1)
  • autovacuum_vacuum_cost_delay(預設 2 ms)

任一參數設為 −1 時,就回退到 VACUUM 的對應參數。因此 autovacuum_vacuum_cost_limit 預設依賴 vacuum_cost_limit 的值。

在 PostgreSQL 12 之前,autovacuum_vacuum_cost_delay 預設為 20 ms,這在現代硬體上導致極差的效能。

Autovacuum 的工作單位以「每週期 autovacuum_vacuum_cost_limit」為上限,而且這個額度由所有工作者共享。因此不論工作者數量多寡,對系統的整體衝擊大致相同。

所以若要加速 autovacuum,autovacuum_max_workersautovacuum_vacuum_cost_limit 必須按比例一起調高

監控#

監控清理可以偵測到一種狀況:死元組無法一次清完,因為指向它們的參照塞不進 maintenance_work_mem 記憶體塊。此時所有索引都必須被完整掃描好幾次

對大表格而言這可能耗費可觀時間,對系統造成顯著負載。即使查詢不會被阻塞,額外的 I/O 操作也會嚴重限制系統吞吐量。

修正方式:更頻繁地清理該表格(讓每次清理的元組更少),或配置更多記憶體。

監控 VACUUM#

  • VERBOSE 子句執行 VACUUM 會在完成清理後顯示狀態報告
  • pg_stat_progress_vacuum 視圖顯示已啟動行程的目前狀態
  • 分析也有類似的 pg_stat_progress_analyze 視圖,儘管分析通常執行得非常快、不太可能造成問題

pg_stat_progress_vacuum 的主要欄位:

欄位意義
phase目前清理階段的名稱
heap_blks_total表格總頁數
heap_blks_scanned已掃描頁數
heap_blks_vacuumed已清理頁數
index_vacuum_count索引掃描次數

整體進度由 heap_blks_vacuumedheap_blks_total 的比例定義,但要記得它會因索引掃描而跳躍式變化

事實上更該注意的是清理週期數:若 index_vacuum_count 大於 1,就表示配置的記憶體不足以一次完成清理。

實驗:把 maintenance_work_mem 壓到 1 MB 觀察多輪索引掃描

插入 500,000 列並全部更新,再把 tid 陣列的記憶體限制為 1 MB:

=> ALTER SYSTEM SET maintenance_work_mem = '1MB';
=> SELECT pg_reload_conf();
=> VACUUM VERBOSE vac;

執行期間查詢進度視圖:

phase              | vacuuming indexes
heap_blks_total    | 17242
heap_blks_scanned  | 17242
heap_blks_vacuumed | 6017
index_vacuum_count | 2
max_dead_tuples    | 174761
num_dead_tuples    | 150956

VACUUM VERBOSE 的完整輸出顯示總共進行了三次索引掃描,每次最多移除 174,522 個指向死元組的指標。

這個數值由「maintenance_work_mem 大小的陣列能容納多少個 tid 指標(每個 6 位元組)」決定。可能的最大值由 pg_stat_progress_vacuum.max_dead_tuples 顯示,但實際使用的空間總是略小一些——這確保讀取下一個頁面時,不論該頁有多少指向死元組的指標,都能塞進剩餘記憶體。

監控 Autovacuum#

監控 autovacuum 的主要做法,是把它的狀態資訊(類似 VACUUM VERBOSE 的輸出)印到伺服器日誌以供後續分析。把 log_autovacuum_min_duration 設為零,所有 autovacuum 執行都會被記錄:

=> ALTER SYSTEM SET log_autovacuum_min_duration = 0;
=> SELECT pg_reload_conf();

日誌會包含索引掃描次數、頁面與元組的移除/保留數量、oldest xmin、平均讀寫速率、緩衝區使用量、WAL 使用量與 CPU 時間等資訊。

要追蹤待清理與待分析的表格清單,可使用前面建立的 need_vacuumneed_analyze 視圖。

若這份清單持續增長,代表 autovacuum 跟不上負載,必須加速:

  • 縮短間隔(調低 autovacuum_vacuum_cost_delay
  • 增加間隔之間完成的工作量(調高 autovacuum_vacuum_cost_limit
  • 很可能還得提高平行度(autovacuum_max_workers