Vacuum#
頁面修剪執行得非常快,但它只釋放部分可回收空間:它在單一 heap 頁面內運作,不觸及索引(反之亦然,索引修剪也不影響表格)。
例行清理(routine vacuuming)是主要的清理程序,由 VACUUM 指令執行。它處理整張表格,同時消除過期的 heap 元組與所有對應的索引項目。
清理與資料庫系統中的其他行程並行進行。被清理期間,表格與索引仍可正常用於讀寫操作(但不允許並行執行
CREATE INDEX、ALTER 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_truncate與toast.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參數才會蒐集。不要關閉autovacuum與track_counts這兩個參數,否則自動清理將無法運作。
運作方式:
- 每隔
autovacuum_naptime(預設 1 分鐘),launcher 為清單中每個活躍資料庫啟動一個 autovacuum worker(一如既往由 postmaster 衍生) - 因此若叢集中有 N 個活躍資料庫,
autovacuum_naptime區間內會衍生 N 個工作者 - 但並行運行的工作者總數不能超過
autovacuum_max_workers(預設 3)
工作者啟動後連上指定資料庫,建立兩份清單:
- 所有待清理的表格、物化視圖與 TOAST 表格
- 所有待分析的表格與物化視圖(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_enabled 與 toast.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_threshold/autovacuum_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_hit | 1 |
| 頁面不在快取中 | vacuum_cost_page_miss | 2 |
| 乾淨頁面被 vacuum 弄髒 | vacuum_cost_page_dirty | 20(額外) |
若保留 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_workers與autovacuum_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_vacuumed對heap_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 | 150956VACUUM 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_vacuum與need_analyze視圖。若這份清單持續增長,代表 autovacuum 跟不上負載,必須加速:
- 縮短間隔(調低
autovacuum_vacuum_cost_delay)- 增加間隔之間完成的工作量(調高
autovacuum_vacuum_cost_limit)- 很可能還得提高平行度(
autovacuum_max_workers)