交易 ID 迴繞#
在 PostgreSQL 中,交易 ID 佔 32 位元。四十億看似不小,但系統若被積極使用就可能很快耗盡:以平均每秒 1,000 筆交易(不含虛擬交易)計算,大約六週連續運轉就會用完。
號碼用完後計數器必須歸零、開始下一輪(這稱為迴繞,wraparound)。但「較小 ID 的交易比較舊」這個判準,只有在配發號碼永遠遞增時才成立——因此計數器不能在重設後單純地重複使用相同號碼。
PostgreSQL 確實實作了 64 位元交易 ID(以 32 位元的 epoch 擴充一般 ID),但它們只在內部使用,永遠不會進入資料頁面。
要正確處理迴繞,PostgreSQL 必須比較交易的年齡(定義為該交易開始以來出現的後續交易數量),而非交易 ID 本身。因此我們該用較舊(precedes)與較新(follows)的概念,取代「小於」「大於」。
在程式碼中,這個比較單純以 32 位元算術實作:先求兩個 32 位元交易 ID 的差,再把結果與零比較。
時鐘面的隱喻與它的災難性陷阱#
可以把交易 ID 序列想像成一個鐘面:對任一交易而言,順時針方向的半圈屬於未來,另外半圈屬於過去。

圖 7-1:以鐘面表示交易 ID 序列——隨著新交易出現,舊交易逐漸落向過去的半圈
然而這個視覺化藏著一個討厭的陷阱:一筆舊交易相對於較新的交易位於遙遠的過去,但遲早會有一筆新交易把它看成落在「未來」那半圈。
若真如此,影響將是災難性的:從那一刻起,所有更新的交易都看不到那筆舊交易所做的變更。
元組凍結與可見性規則#
為防止這種「時間旅行」,清理除了頁面清除之外還執行另一項任務:搜尋位於資料庫水平線之外(因此在所有快照中皆可見)的元組,並以特殊方式標記它們——也就是凍結(freeze)。
對凍結元組而言,可見性規則不必再考慮
xmin,因為這類元組已知在所有快照中可見——該交易 ID 因此可被安全地重複使用。
你可以想像凍結元組中的 xmin 交易 ID 被一個假想的「負無限大」(以雪花符號表示)取代:它標示著這個元組是由一筆遙遠到 ID 已無關緊要的過去交易所建立的。

圖 7-2:被凍結的交易(雪花符號)永遠留在過去的半圈,不再受迴繞影響
但實際上 xmin 維持不變,凍結屬性是由兩個提示位元的組合定義的:committed 與 aborted 同時被設定。
側註:FrozenTransactionId = 2
許多資料來源(包括官方文件)提到 FrozenTransactionId = 2。這就是前面所說的「負無限大」——在 9.4 版之前這個值會真的取代 xmin,但現在改用提示位元。
結果是原始的交易 ID 仍留在元組中,這對除錯與技術支援都很方便。老系統即使升級到較新版本,仍可能含有這種過時的 FrozenTransactionId。
實驗準備#
=> CREATE TABLE tfreeze(
id integer,
s char(300)
)
WITH (fillfactor = 10, autovacuum_enabled = off);fillfactor 設為最低值,讓每頁只能容納兩個元組,追蹤進度更容易;同時關閉 autovacuum 以確保表格只在需要時才被清理。
插入 100 列並執行 VACUUM 後,前兩個 heap 頁面在可見性映射中被標記(all_visible),但凍結映射中沒有(all_frozen),因為它們仍含未凍結的元組:
=> VACUUM tfreeze;
=> SELECT *
FROM generate_series(0,1) g(blkno),
pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
blkno | all_visible | all_frozen
−−−−−−−+−−−−−−−−−−−−−+−−−−−−−−−−−−
0 | t | f
1 | t | f
(2 rows)凍結的管理#
有四個主要參數控制凍結,全部都以交易年齡表示,定義下列事件何時發生:
| 參數 | 預設 | 觸發的事件 |
|---|---|---|
vacuum_freeze_min_age | 5,000 萬 | 凍結開始 |
vacuum_freeze_table_age | 1.5 億 | 執行積極凍結 |
autovacuum_freeze_max_age | 2 億 | 強制凍結 |
vacuum_failsafe_age | 16 億 | 凍結取得優先權(PostgreSQL 14 起) |
最小凍結年齡#
vacuum_freeze_min_age 定義 xmin 交易的最小凍結年齡。
實驗(把該參數降到 1)顯示了一個關鍵限制:
=> UPDATE tfreeze SET s = 'BAR' WHERE id = 1;
=> VACUUM tfreeze;
=> SELECT * FROM heap_page('tfreeze',0,1);
ctid | state | xmin | xmin_age | xmax
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−+−−−−−−−−−−+−−−−−−
(0,1) | redirect to 3 | | |
(0,2) | normal | 856 f | 2 | 0 a
(0,3) | normal | 857 c | 1 | 0 a
(1,1) | normal | 856 c | 2 | 0 a
(1,2) | normal | 856 c | 2 | 0 a
(5 rows)- 更新使第 0 頁的可見性位元被清除,因此該頁中年齡合適的元組被凍結(
856 f) - 第 1 頁仍在可見性映射中,被整個跳過——即使其中的元組年齡同樣符合條件
積極凍結的年齡#
如上所示,若某頁只含在所有快照中可見的目前元組,清理就不會凍結它們。為突破此限制,PostgreSQL 提供 vacuum_freeze_table_age:它定義的交易年齡一旦達到,清理就會忽略可見性映射,任何 heap 頁面都可被凍結。
系統目錄為每張表格保存一個交易 ID,已知所有比它更舊的交易都必定已被凍結,存放為 relfrozenxid:
=> SELECT relfrozenxid, age(relfrozenxid)
FROM pg_class
WHERE relname = 'tfreeze';
relfrozenxid | age
−−−−−−−−−−−−−−+−−−−−
854 | 4
(1 row)拿這個交易的年齡與 vacuum_freeze_table_age 比較,來決定是否該進行積極凍結。
拜凍結映射(PostgreSQL 9.6 起)之賜,清理期間不需要全表掃描:只要檢查未出現在映射中的頁面即可。
除了這項重要最佳化,凍結映射也帶來容錯:若清理被中斷,下次執行不必再回頭處理那些已完成並在映射中標記的頁面。
PostgreSQL 每當系統中的交易數量達到 vacuum_freeze_table_age − vacuum_freeze_min_age 就會對表格所有頁面進行一次積極凍結(採用預設值時,即每 1 億筆交易一次)。
因此若
vacuum_freeze_min_age設得太大,會導致過度凍結與開銷增加。
積極凍結完成後(VACUUM VERBOSE 會顯示 aggressively vacuuming),relfrozenxid 得以前進——因為 heap 頁面保證不再有更舊的未凍結 xmin 交易;相關頁面也會在凍結映射中被標記(all_frozen = t)。
強制自動清理的年齡#
有時光設定上述兩個參數不足以及時凍結元組:
- autovacuum 可能被關閉,而一般的
VACUUM也完全沒被呼叫(這是非常糟糕的主意,但技術上做得到) - 某些不活躍的資料庫(如
template0)可能沒被清理
PostgreSQL 以強制執行積極模式的 autovacuum 來處理這類狀況。
當資料庫中某些未凍結交易 ID 的年齡有超過 autovacuum_freeze_max_age 之虞時,autovacuum 就會被強制執行——即使它已被關閉。判定依據是所有表格中最舊的 pg_class.relfrozenxid 交易的年齡(因為所有更舊的交易都保證已被凍結)。該 ID 存放在系統目錄中:
=> SELECT datname, datfrozenxid, age(datfrozenxid) FROM pg_database;
datname | datfrozenxid | age
−−−−−−−−−−−+−−−−−−−−−−−−−−+−−−−−
postgres | 726 | 132
template1 | 726 | 132
template0 | 726 | 132
internals | 726 | 132
(4 rows)一旦如此,伺服器必須立即停止以防止可能的問題,並且得由管理者手動重啟。
修改
autovacuum_freeze_max_age需要重啟伺服器。不過上述所有凍結設定都能透過對應的儲存參數在表格層級調整——注意這些參數名稱全都以auto開頭:autovacuum_freeze_min_age、autovacuum_freeze_table_age、autovacuum_freeze_max_age(及其toast.對應版本)。
失效保護凍結的年齡(PostgreSQL 14 起)#
若 autovacuum 已在苦苦防止交易 ID 迴繞、明顯是在跟時間賽跑,就會拉下安全開關:
- autovacuum 忽略
autovacuum_vacuum_cost_delay(vacuum_cost_delay)的值 - 停止清理索引,以盡快凍結 heap 元組
當資料庫中未凍結交易的年齡有超過 vacuum_failsafe_age 之虞時,失效保護凍結模式即被啟用。這個值理應高於 autovacuum_freeze_max_age。
手動凍結#
有時手動管理凍結比依賴 autovacuum 更方便。
以 VACUUM FREEZE 凍結#
呼叫 VACUUM FREEZE 指令即可發起凍結。它會凍結所有 heap 元組,不論其交易年齡為何,效果等同於 vacuum_freeze_min_age = 0。
若這樣呼叫的目的是盡快凍結 heap 元組,那麼像失效保護模式一樣完全停用索引清理是合理的。做法有兩種:
- 明確執行
VACUUM (freeze, index_cleanup false)- 透過
vacuum_index_cleanup儲存參數
顯然這不該常規性地執行,否則
VACUUM將無法妥善完成它「頁面清除」的主要任務。
初始載入時凍結資料#
預期不會改變的資料,可以在載入資料庫時就一併凍結,做法是執行帶 FREEZE 選項的 COPY 指令。
初始載入時要能凍結元組,目標表格必須在同一筆交易內被建立或截斷——因為這兩項操作都會取得表格的排他鎖。
這個限制是必要的:凍結元組被預期在所有快照中可見,不論隔離層級為何;否則交易會在資料正被上傳的當下,突然看見剛剛被凍結的元組。而取得鎖之後,其他交易就無法存取該表格。
技術上仍可能破壞隔離的情境
在另一個 session 以 Repeatable Read 開啟交易並建立快照:
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1; -- the snapshot is built在同一筆交易內截斷 tfreeze 並插入新列(若唯讀交易先前已存取過 tfreeze,TRUNCATE 會被阻塞):
=> BEGIN;
=> TRUNCATE tfreeze;
=> COPY tfreeze FROM stdin WITH FREEZE;
1 FOO
2 BAR
3 BAZ
\.
=> COMMIT;現在那筆讀取交易也看到了新資料:
=> SELECT count(*) FROM tfreeze;
count
−−−−−−−
3
(1 row)這確實破壞了隔離,但由於資料載入不太可能經常發生,多數情況下不會造成問題。
以凍結方式載入資料時(PostgreSQL 14 起),可見性映射會立刻被建立,頁首也取得可見性屬性:
=> SELECT * FROM pg_visibility_map('tfreeze',0);
all_visible | all_frozen
−−−−−−−−−−−−−+−−−−−−−−−−−−
t | t
(1 row)因此若資料是以凍結方式載入的,只要資料維持不變,該表格就不會被 vacuum 處理。
可惜這項功能尚未支援 TOAST 表格:若載入了超大值,vacuum 仍必須重寫整張 TOAST 表格,以便在所有頁首中設定可見性屬性。