什麼是快照#

一個資料頁面可以包含同一列的多個版本,但每筆交易最多只能看見其中一個。所有不同列的可見版本合起來,就構成一個快照(snapshot)。

快照只包含拍攝當下已提交的資料,從而為那個特定時刻提供一份(ACID 意義下)一致的資料視圖。

為確保隔離,每筆交易使用自己的快照。這表示不同交易可以看到在不同時間點拍攝、但各自都一致的不同快照。

隔離層級快照拍攝時機存活期間
Read Committed每個敘述開始時僅該敘述執行期間
Repeatable Read、Serializable交易第一個敘述開始時直到整筆交易完成

列版本可見性#

快照不是所有所需元組的實體副本。它由幾個數字定義,元組的可見性則由規則判定。

元組可見性由元組標頭的 xminxmax 欄位(即執行插入與刪除的交易 ID)以及對應的提示位元決定。由於 xminxmax 區間彼此不相交,每一列在任何快照中都只由其中一個版本代表

確切的可見性規則相當複雜,需考量各種情境與邊界狀況。非常粗略地說:

  • 元組可見於「包含 xmin 交易變更、但不包含 xmax 交易變更」的快照中(換言之:該元組已經出現、且尚未被刪除)
  • 交易的變更可見於某快照,若該交易在快照建立之前已提交
  • 例外:交易看得見自己未提交的變更
  • 若交易被中止,其變更在任何快照中都不可見

以三筆交易為例(線段代表交易從開始到提交):

圖 4-1:三筆交易與快照建立時刻(虛線)的相對位置

交易相對於快照變更是否可見
1快照建立前已提交可見
2快照建立時仍活躍不可見
3快照建立後才開始不可見(是否完成都一樣)

快照的結構#

比較「快照建立當下」與「稍後」系統能取得的資訊(空心圓代表活躍中的交易,實心圓代表已提交的交易):

圖 4-2:快照建立當下(左)與稍後(右)系統可得的交易狀態資訊

上述示意圖其實與 PostgreSQL 實際「看見」的畫面無關。問題在於:系統不知道交易是何時提交的。它只知道交易何時開始(由交易 ID 定義),完成時刻並未被記錄在任何地方。

啟用 track_commit_timestamp 參數(預設 off)可以追蹤提交時間,但它們完全不參與可見性檢查(追蹤它們仍可能對其他目的有用,例如外部複寫方案)。

此外 PostgreSQL 總會在對應的 WAL 條目中記錄提交與回滾時間,但這項資訊只用於時間點還原(PITR)。

我們能得知的只有交易的目前狀態,這項資訊來自伺服器共享記憶體中的 ProcArray 結構(包含所有活躍 session 與其交易)。一旦交易完成,就再也無法查出它在快照建立當時是否活躍。

因此,建立快照時光記錄「拍攝時刻」是不夠的,還必須蒐集當下所有交易的狀態。否則日後將無從理解哪些元組該在快照中可見、哪些該被排除。

正因如此,PostgreSQL 無法建立顯示過去任意時間點一致狀態的快照,即使所有需要的元組都還在 heap 頁面裡。也因此無法實作回溯查詢(retrospective query,有時也稱 temporal 或 flashback query)。

耐人尋味的是:這項功能曾是 Postgres 的目標之一,而且在最初就被實作出來,但在專案支援移交給社群時被移除了。

快照的三個組成#

快照由建立當時保存的幾個值組成:

成分意義
xmin快照的下界:最舊的活躍交易的 ID。所有 ID 更小的交易,要不是已提交(變更納入快照),就是已中止(變更被忽略)
xmax快照的上界:比最後一筆已提交交易的 ID 大 1 的值,定義了快照的拍攝時刻。所有 ID ≥ xmax 的交易要不仍在執行、要不尚不存在,其變更都不可見
xip_list所有活躍交易的 ID 清單,不含虛擬交易(虛擬交易完全不影響可見性)

快照還包含其他幾個參數,此處暫且略過。以圖形表示,快照可視為一個涵蓋 xminxmax 的矩形。

圖 4-3:快照的圖形表示——由 xmin、xmax 界定的矩形,內部以 xip_list 標出活躍交易

可見性判定規則#

變更被納入快照的條件是:由已提交交易所做,且滿足以下之一:

  • xid < xmin無條件顯示(例如建立 accounts 表格的那筆交易)
  • xmin ≤ xid < xmax只有當該交易 ID 不在 xip_list 中時才顯示
完整實驗:三筆交易與一個快照

第一筆交易插入第一列並保持開啟(交易 790);第二筆交易插入第二列並立即提交(交易 791)。此刻在另一個 session 建立快照:

    => BEGIN ISOLATION LEVEL REPEATABLE READ;
    => SELECT pg_current_snapshot();
     pg_current_snapshot
    −−−−−−−−−−−−−−−−−−−−−
     790:792:790
    (1 row)

此函式以冒號分隔顯示快照三成分:xminxmaxxip_list(此例只有一項)。

快照拍好後,第一筆交易提交。第三筆交易(792)在快照建立後才開始,它修改第二列,因此產生新元組,然後提交。

我們的快照只看到一個元組:

    => SELECT ctid, * FROM accounts;
     ctid | id | client | amount
    −−−−−−−+−−−−+−−−−−−−−+−−−−−−−−
     (0,2) | 2 | bob     | 100.00
    (1 row)

但表格裡其實有三個:

    => SELECT * FROM heap_page('accounts',0);
     ctid | state | xmin | xmax
    −−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−
     (0,1) | normal | 790 c | 0 a
     (0,2) | normal | 791 c | 792 c
     (0,3) | normal | 792 c | 0 a
    (3 rows)

逐一解釋:

  • (0,1) 不可見:由出現在 xip_list 中的交易插入(即使該交易落在快照範圍內)
  • (0,3) 不可見:對應的交易 ID 高於快照上界
  • (0,2) 可見:插入由「落在快照範圍內且不在 xip_list 中」的交易執行(插入可見),而刪除由「ID 高於快照上界」的交易執行(刪除不可見)

交易自身變更的可見性#

為交易自身變更定義可見性規則時,情況稍微複雜:某些情況下只有部分自身變更該可見。例如在某個時間點開啟的游標(cursor),不論隔離層級為何,都不該看見之後才發生的變更。

為處理這類情況,元組標頭提供一個特殊欄位(以 cmincmax 偽欄位顯示),標示該操作在交易內的序號

  • cmin 識別插入操作
  • cmax 用於刪除操作

為節省空間,這兩個值存放在元組標頭的同一個欄位中,而非兩個。這假設了「同一列幾乎不會在單筆交易內既被插入又被刪除」。若真的發生,PostgreSQL 會在該欄位寫入一個特殊的 combo identifier,實際的 cmincmax 值則由 backend 保存。

實驗:交易 793 插入一列(cmin = 0),開啟游標,再插入一列(cmin = 1):

=> SELECT xmin, CASE WHEN xmin = 793 THEN cmin END cmin, *
FROM accounts;
 xmin | cmin | id | client | amount
−−−−−−+−−−−−−+−−−−+−−−−−−−−−+−−−−−−−−−
  790 |      | 1 | alice    | 1000.00
  792 |      | 2 | bob      | 200.00
  793 |    0 | 3 | charlie | 100.00
  793 |    1 | 4 | charlie | 200.00
(4 rows)

=> FETCH c;
 count
−−−−−−−
     3
(1 row)

游標查詢只拿到三列:游標開啟後才插入的那一列不滿足 cmin < 1 條件,因此進不了快照。這個 cmin 數字自然也存放在快照中,只是無法用任何 SQL 手段顯示出來。

交易水平線#

快照的下界 xmin 是快照建立時最舊活躍交易的 ID。這個值非常重要,因為它定義了使用該快照的交易的水平線(horizon)。

  • 若交易沒有活躍快照(例如 Read Committed 層級下敘述與敘述之間),其水平線由自身 ID 定義(若已被指派)
  • 所有位於水平線之外的交易(xid < xmin)都保證已提交。這意味著交易在其水平線之外只看得到目前的列版本

如你所料,這個術語的靈感來自物理學中的事件視界(event horizon)。

PostgreSQL 追蹤所有行程目前的水平線;交易可在 pg_stat_activity 中看到自己的水平線:

=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
−−−−−−−−−−−−−−
          793
(1 row)

虛擬交易沒有真正的 ID,但它們與一般交易一樣使用快照,因此也有自己的水平線。唯一的例外是沒有活躍快照的虛擬交易:水平線的概念對它們沒有意義,就快照與可見性而言,它們對系統完全「透明」。

資料庫水平線#

以類似方式可定義資料庫水平線:取該資料庫中所有交易的水平線,選出最遙遠、xmin 最舊的那個。

在資料庫水平線之外,過期的 heap 元組永遠不會再被該資料庫的任何交易看見。這些元組可被 vacuum 安全清除——這正是水平線概念在實務上如此重要的原因

圖 4-4:資料庫水平線——落在水平線之外(陰影區)的過期元組可被安全清理

由此可得幾個結論:

  • Repeatable Read 或 Serializable 層級的長時間交易(不論真實或虛擬)會持續把持資料庫水平線,延後清理
  • Read Committed 層級的真實交易同樣把持水平線,即使它沒在執行任何運算子(處於 idle in transaction 狀態)
  • Read Committed 層級的虛擬交易只在執行運算子期間把持水平線

整個資料庫只有一個水平線。因此若它被某筆交易把持,水平線之內的任何資料都無法被清理——即使那些資料根本沒被該交易存取過

在理想世界裡,你應該避免把長交易與頻繁更新(產生新列版本)混在一起,因為那會導致表格與索引膨脹(bloat)。

實驗顯示:只要第一個 session 的交易仍活躍,backend_xmin 就停在 793;直到該交易完成,水平線才往前移動(變成 795),過期元組才能被清理。

側註:系統目錄與暫存表格的水平線

叢集層級的系統目錄表格有獨立的水平線,會納入所有資料庫中的所有交易。

暫存表格則相反:除了目前行程正在執行的交易之外,不必理會任何其他交易。

系統目錄快照#

系統目錄雖由一般表格構成,卻不能透過交易或運算子所使用的快照來存取。該快照必須「夠新」以納入所有最新變更,否則交易可能看到過期的表格欄位定義,或漏掉新加入的完整性約束。

=> BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1; -- 為此交易拍攝快照

        => ALTER TABLE accounts
          ALTER amount SET NOT NULL;

=> INSERT INTO accounts(client, amount)
  VALUES ('alice', NULL);
ERROR: null value in column "amount" of relation "accounts"
violates notnull constraint

快照建立之後才出現的完整性約束,對 INSERT 指令是可見的。

這行為看似破壞了隔離,但若插入交易在 ALTER TABLE 之前就已存取 accounts 表格,後者會被阻塞直到該交易完成。

一般而言,伺服器的行為就像為每個系統目錄查詢建立獨立快照。當然實作複雜得多:頻繁建立快照會損害效能,而且許多系統目錄物件會被快取,這也必須納入考量。

匯出快照#

某些情況下,並行交易必須看到完全相同的快照。例如 pg_dump 工具以平行模式執行時,它的所有行程都必須看到相同的資料庫狀態,才能產出一致的備份。

不能因為交易是「同時」啟動的,就假設它們的快照會相同。要確保所有交易看到相同資料,必須使用快照匯出機制

pg_export_snapshot 函式回傳一個快照 ID,可(在資料庫系統之外)傳遞給另一筆交易:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT count(*) FROM accounts;   -- 4

=> SELECT pg_export_snapshot();
 pg_export_snapshot
−−−−−−−−−−−−−−−−−−−−−
 000000040000006E1
(1 row)

另一筆交易在執行第一個敘述之前,可用 SET TRANSACTION SNAPSHOT 指令匯入該快照:

    => DELETE FROM accounts;
    => BEGIN ISOLATION LEVEL REPEATABLE READ;
    => SET TRANSACTION SNAPSHOT '00000004-0000006E-1';

    => SELECT count(*) FROM accounts;
     count
    −−−−−−−
         4
    (1 row)

第二筆交易使用第一筆交易的快照,因此看到四列(而非零列)。

隔離層級必須設為 Repeatable Read 或 Serializable,因為在 Read Committed 層級下運算子會使用自己的快照。

此外:第二筆交易看不到第一筆交易在快照匯出之後所做的任何變更(反之亦然)——一般的可見性規則仍然適用。匯出快照的存活期間與匯出它的交易相同。