交易 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 維持不變,凍結屬性是由兩個提示位元的組合定義的:committedaborted 同時被設定

側註: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_age5,000 萬凍結開始
vacuum_freeze_table_age1.5 億執行積極凍結
autovacuum_freeze_max_age2 億強制凍結
vacuum_failsafe_age16 億凍結取得優先權(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_ageautovacuum_freeze_table_ageautovacuum_freeze_max_age(及其 toast. 對應版本)。

失效保護凍結的年齡(PostgreSQL 14 起)#

若 autovacuum 已在苦苦防止交易 ID 迴繞、明顯是在跟時間賽跑,就會拉下安全開關:

  • autovacuum 忽略 autovacuum_vacuum_cost_delayvacuum_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 並插入新列(若唯讀交易先前已存取過 tfreezeTRUNCATE 會被阻塞):

=> 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 表格,以便在所有頁首中設定可見性屬性。