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

圖 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 保持不變,只需修改同樣位於該頁面內的指標即可。
每個指標剛好佔四個位元組,包含:
- 元組距頁面起點的偏移量
- 元組長度
- 若干定義元組狀態的位元
可用空間#
指標與元組之間可能留有一些可用空間(這會反映在可用空間映射中)。
列版本的佈局#
每個列版本由標頭後接實際資料構成。標頭包含多個欄位,其中包括:
| 欄位 | 用途 |
|---|---|
xmin、xmax | 交易 ID,用來區分同一列的這個版本與其他版本 |
infomask | 一組定義版本屬性的資訊位元 |
ctid | 指向同一列下一個更新版本的指標 |
| null bitmap | 標記哪些欄位可能含 NULL 值的位元陣列 |
標頭因此相當龐大:每個元組至少需要 23 個位元組,而且由於 null bitmap 與資料對齊所需的填補(padding),這個值經常被超過。在「窄」表格中,各種中繼資料的大小很容易超過實際儲存的資料大小。
為何資料檔跨平台不相容#
資料在磁碟上的佈局與在 RAM 中的表示完全一致。頁面連同其元組會原封不動地讀進緩衝快取,不做任何轉換——這正是資料檔在不同平台之間不相容的原因。
不相容的來源有兩個:
- 位元組順序(byte order)。例如 x86 架構是 little-endian,z/Architecture 是 big-endian,而 ARM 的位元組順序可設定。
- 資料對齊(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 為每個版本標記兩個值:xmin 與 xmax。這些值定義了每個列版本的「有效期」,但依據的不是實際時間,而是持續遞增的交易 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_committed/xmin_aborted位元(t_infomask的 256/512)顯示xmin交易是否已提交或中止;xmax_committed/xmax_aborted(1024/2048)則對應xmax交易。輸出中以c/a表示
從 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_committed與xmin_aborted位元都尚未設定 xmax為 0,這是個虛設數字,表示該元組尚未被刪除、代表該列的目前版本。交易會忽略這個數字,因為xmax_aborted位元已被設定
「尚未發生的交易卻被設上『已中止』位元」看似奇怪,但從隔離的角度來看兩者沒有差別:中止的交易不留痕跡,因此等同從未存在過。
也可以直接從表格查詢 xmin、xmax 偽欄位得到類似(但較簡略)的資訊:
=> 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 與元組標頭一樣,為每筆交易保留兩個位元:committed 與 aborted。
當任何其他交易存取 heap 頁面時,它必須回答:xmin 交易是否已經結束?
- 尚未結束 → 建立的元組不可見。要檢查交易是否仍在進行,PostgreSQL 使用共享記憶體中另一個稱為 ProcArray 的結構,其中包含所有活躍行程的清單,以及每個行程對應的目前(活躍)交易。
- 已結束 → 是提交還是中止?若為中止,對應的元組同樣不可見。這項檢查需要 CLOG。
提示位元#
即使最新的 CLOG 頁面存放在記憶體緩衝區中,每次都做這項檢查仍然昂貴。因此一旦判定完成,交易狀態會被寫入元組標頭——具體來說是寫進
xmin_committed與xmin_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 位元未被設定,因為目前交易的狀態仍屬未知。
索引#
不論類型為何,索引都不使用列版本控制:每一列剛好由一個元組代表。換言之,索引列標頭中不含
xmin與xmax欄位。索引項目指向對應表格列的所有版本。要判斷哪個列版本可見,交易必須存取表格(除非所需頁面出現在可見性映射中)。
=> 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;但已提交的子交易會同時取得
committed與aborted兩個位元 - 最終判定取決於主交易的狀態:若主交易中止,其所有子交易也視為中止
- 相關資訊存放在
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 在此模式下只是在每個指令前隱式加上儲存點,失敗時就發起回滾。
這個模式預設不啟用,因為發出儲存點(即使從未回滾到它們)會帶來可觀的開銷。