長時間運作的 PostgreSQL 快照可能會保留它從未讀取的中間列版本。Vacuum 會使用保守的交易界限來判斷哪些內容可以移除。本指南說明原因,接著使用合成文章列與 Mac 上的 node-postgres 重現此行為。
這項任務是區分「快照阻礙清理」與「vacuum 尚未執行」。範例以 PostgreSQL 18 為目標,使用兩個資料庫連線:一個讀取端持有 Repeatable Read 快照,另一個寫入端更新單篇文章。PostgreSQL 文件目前指出 18 是現行支援的主要版本;以下實作細節明確以其 18 分支為準。PostgreSQL 18 文件。
為何要保留舊讀取端看不到的版本?
更新會建立新的列版本。只要其他交易仍可能需要舊版本,PostgreSQL 就會保留它。之後,vacuum 可以回收過時版本,並讓其儲存空間重新可用。這是例行 Vacuum所述的基本 MVCC 生命週期。
微妙之處在於,快照可見性與實體可移除性回答的是不同問題。可見性會判斷特定查詢是否應傳回某個版本。清理則使用保守的界限,不會逐一證明每個現存快照究竟能讀取哪些版本。
在 PostgreSQL 18 中,HeapTupleSatisfiesVacuum 會將使某個版本失效的交易,與 OldestXmin 比較。已提交的刪除或替換不會立刻讓舊版本變得可移除:刪除交易等於或晚於該界限的版本,仍會被分類為最近死亡。官方元組可見性實作直接記載了這項規則。
以下是示意序列。每次更新都會分別提交,且沒有其他讀取端參與:
| 步驟 | 動作 | 舊讀取端看到的內容 | 最新已提交版本 |
|---|---|---|---|
| 1 | 插入文章修訂版 0 | — | 修訂版 0 |
| 2 | 讀取端建立 Repeatable Read 快照 | 修訂版 0 | 修訂版 0 |
| 3 | 寫入端提交修訂版 1 | 修訂版 0 | 修訂版 1 |
| 4 | 寫入端提交修訂版 2 | 修訂版 0 | 修訂版 2 |
| 5 | 寫入端提交修訂版 3 | 修訂版 0 | 修訂版 3 |
讀取端直接需要修訂版 0。修訂版 1 和 2 是中間版本:由於它們是在快照建立後產生,這個讀取端看不到它們。不過,取代這些版本的交易也比保留中的清理界限新。依照文件記載的界限規則,就能解釋為何這些版本可能與修訂版 0 一起保留。它們被保留,不代表讀取端能把它們當作歷史查詢結果逐一讀取。PostgreSQL 18 元組可見性實作。
界限涵蓋的範圍比讀取端所選取的列更廣。PostgreSQL 18 會根據後端交易 ID 與快照界限計算適用於各關聯的界限;一般資料表資料通常會將同一資料庫中的工作階段納入考量。它不只追蹤特定 SELECT 傳回的列。這也解釋了為何小型查詢可能影響該資料庫其他位置的清理。共用關聯與其他關聯類型有各自的規則,因此「一個快照會停止所有地方的 vacuum」說得太廣了。PostgreSQL 18 界限實作。
哪個交易實際上會持有長時間快照?
Repeatable Read 會在第一個非交易控制陳述式執行時建立快照,之後的查詢都會使用該快照。僅執行 BEGIN 不足以完成這項示範;必須先執行初始文章查詢,建立需要保留的檢視。PostgreSQL 的預設隔離層級 Read Committed 則會為每個命令建立新快照。交易隔離。
因此,查詢過資料表的閒置 Repeatable Read 交易,在查詢結束後仍可能保留其快照。長時間執行的 Read Committed 陳述式也可能在執行期間保留快照,但該隔離層級下分別完成的陳述式不會共用同一個固定檢視。因此,僅憑交易持續時間無法判定保留的是哪個快照。交易隔離。
唯讀交易即使沒有自己的頂層交易 ID,也可能影響界限。實作會同時考量後端交易 ID 與快照界限。在此範例中,backend_xid 為 null、而 backend_xmin 非 null,與讀取端保留快照的情況相符。PostgreSQL 18 界限實作。
在 Mac 終端機重現此行為
你需要一個正在執行的本機 PostgreSQL 18 伺服器、其命令列工具,以及含 npm 的 Node.js。node-postgres 相容性文件列出 Node.js 20 或 24。本指南假設你現有的本機連線設定可正常運作。它會建立獨立資料庫,且只使用合成文章文字。node-postgres 簡介與安裝。
在 Terminal 中建立工作目錄、安裝驅動程式,並建立範例資料庫:
mkdir article-snapshot-lab
cd article-snapshot-lab
npm init -y
npm install pg
createdb snapshot_lab
將以下完整程式儲存為 snapshot-demo.cjs。其連線會沿用 PostgreSQL 環境設定,包括 PGDATABASE、PGHOST 和 PGPORT,如node-postgres 連線所述。createdb 和程式請使用相同的本機連線設定。
讀取端會在同一個專用用戶端上執行所有交易陳述式。node-postgres 要求如此:交易期間必須使用同一個用戶端,因此不適合透過 pool.query() 分派交易陳述式。第二個用戶端會獨立執行更新與 vacuum 命令。node-postgres 交易。
範例也會為 revision 建立索引,而此欄位每次更新都會變更。這會刻意讓這些更新無法使用 HOT,使示範更容易解讀。PostgreSQL 文件指出,HOT 要求更新不得變更一般索引所參照的欄位,且原始頁面必須有足夠空間。僅堆積元組。
const { Client } = require('pg');
async function main() {
const reader = new Client({
application_name: 'article_snapshot_reader',
connectionTimeoutMillis: 5000
});
const writer = new Client({
application_name: 'article_snapshot_writer',
connectionTimeoutMillis: 5000
});
let readerOpen = false;
writer.on('notice', notice => console.log(notice.message));
try {
await reader.connect();
await writer.connect();
const version = await writer.query('SHOW server_version_num');
const major = Math.floor(Number(version.rows[0].server_version_num) / 10000);
if (major !== 18) throw new Error('This walkthrough targets PostgreSQL 18.');
await writer.query(
'CREATE TABLE public.article_snapshot_demo (' +
'id integer PRIMARY KEY, revision integer NOT NULL, title text NOT NULL)'
);
await writer.query(
'CREATE INDEX article_snapshot_demo_revision_idx ' +
'ON public.article_snapshot_demo (revision)'
);
await writer.query(
'INSERT INTO public.article_snapshot_demo VALUES (1, 0, $1)',
['Article draft']
);
await reader.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
readerOpen = true;
const selectRow = 'SELECT id, revision, title FROM public.article_snapshot_demo';
console.log('Reader before:', (await reader.query(selectRow)).rows);
for (const revision of [1, 2, 3]) {
await writer.query(
'UPDATE public.article_snapshot_demo SET revision = $1, title = $2 WHERE id = 1',
[revision, 'Article revision ' + revision]
);
}
const pid = (await reader.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
console.log('Reader activity:', (await writer.query(
'SELECT state, backend_xid, backend_xmin FROM pg_stat_activity WHERE pid = $1',
[pid]
)).rows);
console.log('Writer latest:', (await writer.query(selectRow)).rows);
console.log('Reader still:', (await reader.query(selectRow)).rows);
console.log('Vacuum with snapshot open:');
await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
await reader.query('COMMIT');
readerOpen = false;
console.log('Vacuum after reader commit:');
await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
console.log('Reader fresh:', (await reader.query(selectRow)).rows);
} finally {
if (readerOpen) await reader.query('ROLLBACK').catch(console.error);
await Promise.allSettled([reader.end(), writer.end()]);
}
}
main().catch(error => {
console.error(error.message);
process.exitCode = 1;
});
明確選取資料庫後執行:
PGDATABASE=snapshot_lab node snapshot-demo.cjs
程式的 JavaScript 語法已使用 Node.js 檢查。本篇文章依據取得的文件檢視了資料庫操作序列;實際上並未在 PostgreSQL 伺服器上執行該序列。
如何解讀兩次 vacuum 的結果
預期查詢結果是:Reader before 傳回修訂版 0,Writer latest 傳回修訂版 3,而 Reader still 再次傳回修訂版 0。讀取端提交後,下一次獨立查詢應傳回修訂版 3。這些是合成操作序列的預期結果,並非擷取的測試輸出。
寫入端檢查時,活動查詢結果應顯示讀取端處於 idle in transaction 狀態,且保留著 backend_xmin。PostgreSQL 將此狀態定義為交易正在等待另一個命令,並將 backend_xmin 定義為後端目前的界限。累積統計系統。
查看第一次 vacuum 報告,找出仍不可移除的過時版本。讀取端提交後,再比較第二份報告,確認這些版本是否已移除。在其他操作都安靜的資料庫中,三次已提交的替換會產生三個可檢查的過時版本。其他保留中的界限或並行維護可能影響報告的具體內容,因此應比較前後變化模式,而不必要求輸出文字完全一致。
VACUUM (VERBOSE) 會產生詳細的活動報告。程式會透過文件記載的 node-postgres notice 事件印出伺服器通知。兩次 vacuum 呼叫都在明確的交易區塊外執行,因為 PostgreSQL 不允許在交易區塊內執行 VACUUM。PostgreSQL VACUUM、node-postgres 用戶端 API。
如果結果與預期不同,請使用以下判斷方式:
| 觀察結果 | 下一步檢查 |
|---|---|
| 讀取端在提交前看到修訂版 3 | 確認已啟動 Repeatable Read,且第一次文章查詢是在更新前執行。 |
| 第一次 vacuum 仍保留過時版本 | 與讀取端提交後的那次結果比較;快照仍開啟時保留版本是預期行為。 |
| 讀取端提交後版本仍存在 | 檢查是否還有其他保留中的界限;結束此讀取端只會移除它造成的影響。 |
| 資料表統計計數似乎不一致 | 將 n_dead_tup 視為估計值,並比較 vacuum 報告。 |
| 資料表檔案沒有變小 | 檢查儲存空間是否已可重用;檔案縮小是另一回事。 |
PostgreSQL 明確指出,n_dead_tup 是估計計數。記錄到 vacuum 時間也能證明維護曾經執行,但不能證明所有過時版本都可移除。這些欄位有助於診斷,但不能取代對保留中快照與 vacuum 報告的檢查。累積統計系統。
一般的 vacuum 通常會讓資料表內的空間重新可用,而不是將空間還給 macOS。完全空出的尾端頁面是文件記載的例外。因此,清理後檔案大小不變,不能證明清理失敗。例行 Vacuum。
應該如何調整應用程式?
如果數個文章查詢必須共用一致的資料庫檢視,就將交易範圍限制在這些查詢之內,並在查詢完成後結束交易。作為快照保留在應用程式設計上的影響,只要任務允許,就等交易結束後再進行無關的處理與桌面操作。
如果每個查詢都可以各自使用最新的已提交檢視,Read Committed 會為每個命令提供快照。變更隔離層級或在批次間提交,會改變一致性語意:後續查詢可能會看到新提交的文章修訂版。只有在這類結果變化符合讀取任務需求時,才採用這種方式。交易隔離。
增加 vacuum 頻率無法讓版本越過未變動的可移除性界限。結束不必要的快照,才能處理它對版本保留造成的影響。同樣地,VACUUM FULL 會重寫資料表並要求獨佔鎖,也需要額外空間;對這個範例而言,它不是例行的處理方式。PostgreSQL 18 元組可見性實作、PostgreSQL VACUUM。
指令碼會保留合成資料表供檢查。如果該資料表已存在,指令碼會刻意報錯,而不會取代現有內容。檢查完畢後,如果想移除範例資料表,請在連線至 snapshot_lab 的查詢工作階段中執行以下命令。如果某次執行在設定途中失敗,請先檢查同一個資料表,再將其移除並重新執行。
DROP TABLE public.article_snapshot_demo;
實用的診斷方式是觀察整個轉變:建立舊快照、提交多次替換、執行 vacuum、結束快照,再次執行 vacuum。看到原始修訂版仍可讀取,同時中間版本也無法清理,就能具體理解兩者的差異。PostgreSQL 的可見性規則決定查詢會傳回什麼;其清理界限則決定過時儲存空間何時可以重新使用。
來源
13.2. 交易隔離 — PostgreSQL Global Development Group。
24.1. 例行 Vacuum — PostgreSQL Global Development Group。
VACUUM — PostgreSQL Global Development Group。
27.2. 累積統計系統 — PostgreSQL Global Development Group。
66.7. 僅堆積元組 (HOT) — PostgreSQL Global Development Group。
heapam_visibility.c, REL_18_STABLE — PostgreSQL 專案。
procarray.c, REL_18_STABLE — PostgreSQL 專案。
簡介 — node-postgres。
連線 — node-postgres。
交易 — node-postgres。
用戶端 API — node-postgres。