頁面結構#

每個頁面都有特定的內部佈局,通常由以下部分構成:

圖 3-1:資料頁面的內部佈局——頁首、項目指標陣列、可用空間、項目與特殊空間

頁首#

頁首(page header)位於最低位址,大小固定。它存放頁面的各種資訊,例如校驗和(checksum)以及頁面其他各部分的大小。

這些大小可用 pageinspect 擴充輕鬆查看(頁面編號從零開始):

=> CREATE EXTENSION pageinspect;
=> SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts',0));
 lower | upper | special | pagesize
−−−−−−−+−−−−−−−+−−−−−−−−−+−−−−−−−−−−
   152 | 6904 |     8192 |     8192
(1 row)

特殊空間#

特殊空間(special space)位於頁面另一端,佔據最高位址。某些索引用它存放輔助資訊;在其他索引與表格頁面中,這塊空間大小為零。

索引頁面的佈局相當多樣,內容大幅取決於特定索引類型。即使是同一種索引也可能有不同種類的頁面:例如 B-tree 有結構特殊的中繼資料頁(第零頁),以及與表格頁面非常相似的一般頁面。

元組#

(row)包含資料庫中實際儲存的資料以及一些附加資訊,位置就在特殊空間之前。

  • 表格:我們面對的是列版本(row version)而非列,因為多版本並行控制意味著同一列會有多個版本存在
  • 索引:不使用 MVCC 機制;索引必須參照所有可用的列版本,再依可見性規則挑出合適的那個

表格列版本與索引項目常被統稱為元組(tuple)。這個術語借自關聯理論——又是 PostgreSQL 學術背景的一項遺產。

項目指標#

指向元組的指標陣列,扮演頁面「目錄」的角色,位置緊接在頁首之後。

索引項目必須以某種方式參照特定的 heap 元組。PostgreSQL 為此採用六位元組的元組識別碼(tuple identifier,TID),每個 TID 由主分支的頁面編號、加上對該頁面內特定列版本的參照所組成。

理論上元組可以用「距頁面起點的偏移量」來參照。但那樣一來,元組就無法在頁面內移動而不破壞這些參照——這會導致頁面碎片化及其他討厭的後果。

因此 PostgreSQL 採用間接定址:元組識別碼指向對應的指標編號,該指標再指出元組目前的偏移量。元組在頁面內移動時 TID 保持不變,只需修改同樣位於該頁面內的指標即可。

每個指標剛好佔四個位元組,包含:

  • 元組距頁面起點的偏移量
  • 元組長度
  • 若干定義元組狀態的位元

可用空間#

指標與元組之間可能留有一些可用空間(這會反映在可用空間映射中)。

列版本的佈局#

每個列版本由標頭後接實際資料構成。標頭包含多個欄位,其中包括:

欄位用途
xminxmax交易 ID,用來區分同一列的這個版本與其他版本
infomask一組定義版本屬性的資訊位元
ctid指向同一列下一個更新版本的指標
null bitmap標記哪些欄位可能含 NULL 值的位元陣列

標頭因此相當龐大:每個元組至少需要 23 個位元組,而且由於 null bitmap 與資料對齊所需的填補(padding),這個值經常被超過。在「窄」表格中,各種中繼資料的大小很容易超過實際儲存的資料大小。

為何資料檔跨平台不相容#

資料在磁碟上的佈局與在 RAM 中的表示完全一致。頁面連同其元組會原封不動地讀進緩衝快取,不做任何轉換——這正是資料檔在不同平台之間不相容的原因。

不相容的來源有兩個:

  1. 位元組順序(byte order)。例如 x86 架構是 little-endian,z/Architecture 是 big-endian,而 ARM 的位元組順序可設定。
  2. 資料對齊(data alignment)到機器字組邊界,這是許多架構的要求。例如在 32 位元 x86 系統上,integer(四位元組)依四位元組字組邊界對齊,double precision(八位元組)亦然;但在 64 位元系統上,double 依八位元組字組邊界對齊。

欄位順序會影響元組大小#

資料對齊使得元組大小取決於表格中欄位的順序。這個效應通常可忽略,但某些情況下會導致顯著的大小增加:

=> CREATE TABLE padding(
   b1 boolean,
   i1 integer,
   b2 boolean,
   i2 integer
);
=> INSERT INTO padding VALUES (true,1,false,2);
=> SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
 lp_len
−−−−−−−−
     40
(1 row)

列的大小是 40 位元組:標頭 24 位元組、integer 欄位各 4 位元組、boolean 欄位各 1 位元組——合計 34 位元組,另有 6 個位元組浪費integer 欄位的四位元組對齊上。

重建表格、調整欄位順序後,空間利用更有效率:

=> CREATE TABLE padding(
   i1 integer,
   i2 integer,
   b1 boolean,
   b2 boolean
);
=> INSERT INTO padding VALUES (1,2,true,false);
=> SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
 lp_len
−−−−−−−−
     34
(1 row)

另一個可能的微最佳化:把定長且不可為 NULL 的欄位放在表格開頭。存取這類欄位會更有效率,因為它們在元組中的偏移量可以被快取。

側註:為什麼表格叫做 heap?

在 PostgreSQL 中,表格常被稱為 heap。這又是一個晦澀的術語,暗示元組的空間配置與動態記憶體配置之間的相似性。確實看得出某種類比,但表格是由完全不同的演算法管理的。

我們可以把這個術語理解為「一切都堆在一堆裡」——與有序的索引形成對比。

元組上的操作#

為了識別同一列的不同版本,PostgreSQL 為每個版本標記兩個值:xminxmax。這些值定義了每個列版本的「有效期」,但依據的不是實際時間,而是持續遞增的交易 ID

  • 列被建立時,其 xmin 設為 INSERT 指令的交易 ID
  • 列被刪除時,其目前版本的 xmax 設為 DELETE 指令的交易 ID
  • UPDATE 在某種抽象層次上可視為兩個獨立操作:先把目前版本的 xmax 設為 UPDATE 指令的交易 ID,再建立該列的新版本,其 xmin 與前一版本的 xmax 相同

以下實驗使用一張雙欄位表格,其中一欄建有索引:

=> CREATE TABLE t(
   id integer GENERATED ALWAYS AS IDENTITY,
   s text
);
=> CREATE INDEX ON t(s);

PostgreSQL 用 xact 一詞表示交易的概念,這在 SQL 函式名稱與原始碼中都能見到。因此交易 ID 可被稱為 xact ID、xid,或簡稱 ID——這些縮寫會反覆出現。

輔助函式:以易讀格式顯示 heap 頁面

heap_page_items 函式能提供所需的全部資訊,但它「原樣」顯示資料,格式不易理解。把常用查詢包成函式,並把資訊位元與交易狀態合併顯示:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
      CASE lp_flags
        WHEN 0 THEN 'unused'
        WHEN 1 THEN 'normal'
        WHEN 2 THEN 'redirect to '||lp_off
        WHEN 3 THEN 'dead'
      END AS state,
      t_xmin || CASE
        WHEN (t_infomask & 256) > 0 THEN ' c'
        WHEN (t_infomask & 512) > 0 THEN ' a'
        ELSE ''
      END AS xmin,
      t_xmax || CASE
        WHEN (t_infomask & 1024) > 0 THEN ' c'
        WHEN (t_infomask & 2048) > 0 THEN ' a'
        ELSE ''
      END AS xmax
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

其中:

  • lp 指標被轉換為元組 ID 的標準格式:(頁面編號, 指標編號)
  • lp_flags 狀態被展開;normal 表示它確實指向一個元組
  • xmin_committedxmin_aborted 位元(t_infomask 的 256/512)顯示 xmin 交易是否已提交或中止;xmax_committedxmax_aborted(1024/2048)則對應 xmax 交易。輸出中以 ca 表示

從 PostgreSQL 13 起,pageinspect 提供 heap_tuple_infomask_flags 函式可解釋所有資訊位元。

插入#

開啟交易並插入一列(交易 ID 為 776):

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−+−−−−−−
 (0,1) | normal | 776 | 0 a
(1 row)
  • INSERT 在 heap 頁面加上指標 1,指向目前唯一的元組
  • 元組的 xmin 設為目前交易 ID。該交易仍在進行中,因此 xmin_committedxmin_aborted 位元都尚未設定
  • xmax0,這是個虛設數字,表示該元組尚未被刪除、代表該列的目前版本。交易會忽略這個數字,因為 xmax_aborted 位元已被設定

「尚未發生的交易卻被設上『已中止』位元」看似奇怪,但從隔離的角度來看兩者沒有差別:中止的交易不留痕跡,因此等同從未存在過

也可以直接從表格查詢 xminxmax 偽欄位得到類似(但較簡略)的資訊:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id | s
−−−−−−+−−−−−−+−−−−+−−−−−
  776 |    0 | 1 | FOO
(1 row)

提交與 CLOG#

交易成功完成後,其狀態必須被記錄下來。PostgreSQL 為此採用一種稱為 CLOG(commit log)的特殊結構。它以檔案形式存放在 PGDATA/pg_xact 目錄,而非系統目錄表格。

這些檔案原本位於 PGDATA/pg_clog,但在 PostgreSQL 10 中該目錄被更名——不熟悉 PostgreSQL 的資料庫管理者為了找回磁碟空間而刪掉它的情況並不罕見,他們以為「log」是不必要的東西。

CLOG 與元組標頭一樣,為每筆交易保留兩個位元:committedaborted

當任何其他交易存取 heap 頁面時,它必須回答:xmin 交易是否已經結束?

  • 尚未結束 → 建立的元組不可見。要檢查交易是否仍在進行,PostgreSQL 使用共享記憶體中另一個稱為 ProcArray 的結構,其中包含所有活躍行程的清單,以及每個行程對應的目前(活躍)交易。
  • 已結束 → 是提交還是中止?若為中止,對應的元組同樣不可見。這項檢查需要 CLOG。

提示位元#

即使最新的 CLOG 頁面存放在記憶體緩衝區中,每次都做這項檢查仍然昂貴。因此一旦判定完成,交易狀態會被寫入元組標頭——具體來說是寫進 xmin_committedxmin_aborted 資訊位元,這兩者也稱為提示位元(hint bit)。

只要其中一個位元被設定,xmin 交易的狀態就被視為已知,下一筆交易既不必存取 CLOG 也不必存取 ProcArray。

為什麼不由執行插入的那筆交易來設定這些位元?

  • 插入的當下還不知道該交易是否會成功完成
  • 等到提交時,已經不清楚哪些元組與頁面被改動過。若交易影響許多頁面,追蹤它們可能太昂貴
  • 此外部分頁面可能已不在快取中;為了單純更新提示位元而重讀它們,會嚴重拖慢提交

這種成本削減的副作用是:任何交易(即使是唯讀的 SELECT)都可能開始設定提示位元,從而在緩衝快取中留下一串被弄髒的頁面。

實際觀察:提交後頁面本身沒有變化(但我們知道交易狀態已寫入 CLOG);直到第一筆以「標準」方式存取該頁面的交易出現,才會判定 xmin 狀態並更新提示位元:

=> COMMIT;
=> SELECT * FROM t;      -- 觸發提示位元更新

=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
 (0,1) | normal | 776 c | 0 a
(1 row)

刪除#

列被刪除時,其目前版本的 xmax 設為執行刪除的交易 ID,且 xmax_aborted 位元被取消設定:

=> BEGIN;
=> DELETE FROM t;        -- 交易 ID 777

=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
 (0,1) | normal | 776 c | 777
(1 row)

只要這筆交易仍在進行,xmax 值就扮演列鎖的角色。若另一筆交易要更新或刪除這一列,它必須等到 xmax 交易完成為止。

中止#

中止交易的機制與提交類似、速度一樣快,只是在 CLOG 中設定的是 aborted 位元。

儘管對應的指令叫做 ROLLBACK,但沒有任何實際的資料回滾發生:被中止交易在資料頁面中所做的所有變更都原地保留。

頁面被存取時交易狀態會被檢查,元組因而取得 xmax_aborted 提示位元。xmax 數字本身仍留在頁面中,但再也沒人會理會它:

=> ROLLBACK;
=> SELECT * FROM t;      -- 觸發提示位元更新

=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−
 (0,1) | normal | 776 c | 777 a
(1 row)

更新#

更新的執行方式,就像是先刪除目前元組、再插入一個新的:

=> BEGIN;
=> UPDATE t SET s = 'BAR';   -- 交易 ID 778

=> SELECT * FROM t;          -- 查詢回傳單一列(新版本)
 id | s
−−−−+−−−−−
  1 | BAR
(1 row)

=> SELECT * FROM heap_page('t',0);   -- 但頁面保有兩個版本
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
 (0,1) | normal | 776 c | 778
 (0,2) | normal | 778   | 0 a
(2 rows)

先前被刪除版本的 xmax 欄位含有目前交易 ID。這個值直接覆寫舊值,因為先前那筆交易已被中止。xmax_aborted 位元未被設定,因為目前交易的狀態仍屬未知。

索引#

不論類型為何,索引都不使用列版本控制:每一列剛好由一個元組代表。換言之,索引列標頭中不含 xminxmax 欄位。

索引項目指向對應表格列的所有版本。要判斷哪個列版本可見,交易必須存取表格(除非所需頁面出現在可見性映射中)。

=> CREATE FUNCTION index_page(relname text, pageno integer)
RETURNS TABLE(itemoffset smallint, htid tid)
AS $$
SELECT itemoffset,
       htid -- ctid before v.13
FROM bt_page_items(relname,pageno);
$$ LANGUAGE sql;

=> SELECT * FROM index_page('t_s_idx',1);
 itemoffset | htid
−−−−−−−−−−−−+−−−−−−−
          1 | (0,2)
          2 | (0,1)
(2 rows)

該頁面同時參照兩個 heap 元組——目前的與先前的。由於 BAR < FOO,指向第二個元組的指標在索引中排在前面。

TOAST#

TOAST 表格實質上就是一般表格,擁有自己的版本控制,與主表格的列版本無關。不過 TOAST 表格的列處理方式使它們永不被更新:只會被新增或刪除,因此其版本控制帶有幾分人為性質。

每次資料修改都會在主表格中產生新元組。但若更新未觸及任何存放在 TOAST 中的長值,新元組會直接參照既有的 toasted 值。只有當長值本身被更新時,PostgreSQL 才會同時建立主表格的新元組與新的「吐司片」。

虛擬交易#

為了節約使用交易 ID,PostgreSQL 提供一項特殊最佳化。

若交易是唯讀的,它完全不影響列可見性。因此這類交易一開始只會拿到虛擬 xid,由 backend 行程 ID 與一個序號組成。配發虛擬 xid 不需要行程間同步,因此非常快。此時交易還沒有真正的 ID:

=> BEGIN;
=> SELECT pg_current_xact_id_if_assigned();
 pg_current_xact_id_if_assigned
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

(1 row)

在不同時間點,系統中可能存在已被用過的虛擬 xid,這完全正常:虛擬 xid 只存在於 RAM 中,且只在對應交易活躍期間存在;它們永遠不會被寫入資料頁面,也永遠不會落到磁碟上。

一旦交易開始修改資料,它就會收到真正的唯一 ID:

=> UPDATE accounts
SET amount = amount - 1.00;

=> SELECT pg_current_xact_id_if_assigned();
 pg_current_xact_id_if_assigned
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
                            780
(1 row)

子交易#

儲存點#

SQL 支援儲存點(savepoint),讓我們能取消交易內的部分操作而不必中止整筆交易。但這與前述的運作方式不符:交易狀態適用於它的所有操作,且不執行實體資料回滾。

為了實作這項功能,含有儲存點的交易被拆成數個子交易(subtransaction),使其狀態能被個別管理。

子交易的特性:

  • 擁有自己的 ID(大於主交易的 ID)
  • 狀態以通常方式寫入 CLOG;但已提交的子交易會同時取得 committedaborted 兩個位元
  • 最終判定取決於主交易的狀態:若主交易中止,其所有子交易也視為中止
  • 相關資訊存放在 PGDATA/pg_subtrans 目錄,檔案存取透過位於實例共享記憶體、結構與 CLOG 緩衝區相同的緩衝區進行

別把子交易與自主交易(autonomous transaction)搞混。與子交易不同,後者彼此之間毫無依賴關係。原生 PostgreSQL 不支援自主交易,這或許是件好事:它們只在極少數情況下被需要,但其他資料庫系統提供這項功能常引發誤用,造成大量麻煩。

實驗:插入 FOO(主交易 782)後建立儲存點,插入 XYZ(子交易 783),回滾到儲存點再插入 BAR(子交易 784):

=> ROLLBACK TO sp;
=> INSERT INTO t(s) VALUES ('BAR');
=> SELECT *
FROM heap_page('t',0) p
  LEFT JOIN t ON p.ctid = t.ctid;
 ctid | state | xmin | xmax | id | s
−−−−−−−+−−−−−−−−+−−−−−−+−−−−−−+−−−−+−−−−−
 (0,1) | normal | 782 | 0 a | 2 | FOO
 (0,2) | normal | 783 | 0 a |      |
 (0,3) | normal | 784 | 0 a | 4 | BAR
(3 rows)

頁面仍然保留被中止子交易新增的那一列。提交後可清楚看到每個子交易各有自己的狀態:

=> COMMIT;
=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
 (0,1) | normal | 782 c | 0 a
 (0,2) | normal | 783 a | 0 a
 (0,3) | normal | 784 c | 0 a
(3 rows)

SQL 不允許直接使用子交易——你無法在目前交易完成前開啟新交易(嘗試巢狀 BEGIN 只會得到 WARNING: there is already a transaction in progress)。子交易是隱式被使用的:實作儲存點、處理 PL/pgSQL 中的例外,以及其他更冷僻的情況。

錯誤與原子性#

敘述執行期間發生錯誤時會怎樣?

=> BEGIN;
=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:     division by zero

=> SELECT * FROM t;
ERROR: current transaction is aborted, commands ignored until end
of transaction block

=> COMMIT;
ROLLBACK

失敗之後整筆交易即視為中止,無法再執行任何操作;即使嘗試提交,PostgreSQL 也會回報交易已被回滾。

實驗中該運算子在失敗前確實已成功更新了兩列中的一列:

=> SELECT * FROM heap_page('t',0);
 ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
 (0,1) | normal | 782 c | 785
 (0,2) | normal | 783 a | 0 a
 (0,3) | normal | 784 c | 0 a
 (0,4) | normal | 785   | 0 a
(4 rows)
psql 的 ON_ERROR_ROLLBACK 模式

psql 提供一種特殊模式,讓你在失敗後仍能繼續交易,彷彿出錯的敘述被回滾了:

=> \set ON_ERROR_ROLLBACK on
=> BEGIN;
=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:     division by zero
=> SELECT * FROM t;
 id | s
−−−−+−−−−−
  2 | FOO
  4 | BAR
(2 rows)
=> COMMIT;
COMMIT

如你所料,psql 在此模式下只是在每個指令前隱式加上儲存點,失敗時就發起回滾。

這個模式預設不啟用,因為發出儲存點(即使從未回滾到它們)會帶來可觀的開銷。