若要拒絕過時的文件儲存,請在更新條件中加入編輯器最初載入的修訂版本,然後以原子方式遞增。單靠 SERIALIZABLE 無法表達這項要求,因為文件是在較早的交易中載入。
本教學適用於在 Mac 上使用 Terminal、需要讓 PostgreSQL 文章編輯器拒絕過時替換文字的開發人員。內容使用合成的文章資料列,並提供完整的 node-postgres 示範。目標很明確:只有在儲存的修訂版本仍與提交草稿所依據的修訂版本相符時才儲存。
為什麼 SERIALIZABLE 可能允許過時的替換?
PostgreSQL 將可序列化執行定義為:其效果等同於以某種順序逐一執行已提交的交易。這項保證涉及資料庫交易及其操作。PostgreSQL:交易隔離。
請看以下示意流程:
- 編輯器以修訂版本 1 載入文章 1,讀取交易隨後結束。
- 另一筆儲存替換文章文字,並提交修訂版本 2。
- 原編輯器提交以修訂版本 1 為基礎的替換文字。
- 新的可序列化交易只使用文章 ID 更新文章 1。
最後一筆交易可能成功:這些交易已依序執行。若更新條件只有 id = 1,其中並未表明提交的文字必須以修訂版本 1 為基礎。這是對已記載之可序列化定義的應用,並非該定義的例外。
交易重疊時情況不同。當並行更新與交易的快照衝突,或交易間的相依關係會妨礙序列執行時,PostgreSQL 可能拒絕可序列化交易。快照會在第一個非交易控制陳述式執行時建立;較早發出 BEGIN 並不會保留更早的編輯器讀取結果。PostgreSQL:交易隔離。
因此,診斷時應問:**儲存操作在哪裡明確要求使用編輯器載入的修訂版本?**若完全沒有這項要求,變更隔離等級也不會補上缺少的條件。
修訂版本檢查必須保證什麼?
將每份已載入的草稿視為一組資料:可編輯文字及其基礎修訂版本。儲存時帶上文章 ID、替換文字及未變更的基礎修訂版本。資料庫更新必須同時比對 ID 和該修訂版本、替換文字,並在同一個陳述式中遞增儲存的修訂版本。
例如,條件 WHERE id = $1 AND revision = $2 表達了前提,而 SET body = $3, revision = revision + 1 執行狀態轉換。PostgreSQL 會更新符合條件的資料列,而 RETURNING 會提供實際更新之資料列的值。更新零筆資料列仍是成功的 SQL 命令,因此應用程式必須明確判讀此結果。PostgreSQL:UPDATE。
這種模式通常稱為樂觀並行控制或比較並交換。就本文的設計而言,所有會變更文章可編輯內容的操作都必須遞增修訂版本。修訂版本應設為非空值、不可重設,也不可讓舊修訂版本權杖沿用到重複使用的文章 ID 上。否則,相等比對可能會錯誤地認定舊草稿仍相符。
先讀取資料,再只用 ID 更新,並未在寫入本身保留這項要求。同樣地,若在儲存前先取得最新修訂版本,並以它取代提交的修訂版本,就改變了請求的含義:這會允許替換編輯器並未用來作為基礎的版本。
在 Mac 上執行完整的合成範例
請準備一個正在執行的 PostgreSQL 執行個體、一個現有的暫存資料庫,以及目前版本或受支援的 LTS 版本 Node.js 與 npm。開啟 Terminal,建立工作資料夾,然後使用文件所述的 npm install pg 命令安裝驅動程式。node-postgres:簡介與安裝。
mkdir revision-write-demo
cd revision-write-demo
npm install pg
將以下完整程式儲存到該資料夾的 revision-demo.mjs。程式使用文件所述、與 ESM 相容的預設匯入。Client 和 Pool 都可以從環境變數讀取 PostgreSQL 連線設定;執行前,請為現有的暫存資料庫設定這些值。node-postgres:ESM、node-postgres:連線。
程式會比較兩篇合成文章。文章 1 會進行兩次不帶條件的可序列化儲存。文章 2 則會以相同的原始修訂版本進行兩次修訂版本檢查儲存。臨時資料表讓示範不會影響永久文章資料表。JavaScript 語法已用 Node.js 檢查;研究環境中並未執行資料庫示範。
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();
async function saveArticle(id, expectedRevision, body) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let active = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
active = true;
const result = await client.query(
`UPDATE revision_demo_articles
SET body = $3, revision = revision + 1
WHERE id = $1 AND revision = $2
RETURNING id, body, revision`,
[id, expectedRevision, body]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
active = false;
return { status: 'stale-or-missing' };
}
await client.query('COMMIT');
active = false;
return { status: 'saved', article: result.rows[0] };
} catch (error) {
if (active) await client.query('ROLLBACK');
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE revision_demo_articles (
id integer PRIMARY KEY,
body text NOT NULL,
revision integer NOT NULL CHECK (revision > 0)
)
`);
await client.query(
`INSERT INTO revision_demo_articles (id, body, revision)
VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
);
const loaded = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
const expectedRevision = loaded.rows[1].revision;
// Illustrative blind saves: each is a separate transaction.
for (const body of ['First edit', 'Stale replacement']) {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
try {
await client.query(
`UPDATE revision_demo_articles
SET body = $1, revision = revision + 1 WHERE id = 1`,
[body]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
const first = await saveArticle(2, expectedRevision, 'First edit');
const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
assert.equal(first.status, 'saved');
assert.equal(first.article.revision, 2);
assert.equal(stale.status, 'stale-or-missing');
const final = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
assert.deepEqual(final.rows, [
{ id: 1, body: 'Stale replacement', revision: 3 },
{ id: 2, body: 'First edit', revision: 2 }
]);
console.table(final.rows);
console.log(stale.status);
} finally {
await client.end();
}
若現有的暫存資料庫名稱是 revision_demo,請執行以下命令。請代入實際的資料庫名稱,並保留本機執行個體所需的任何連線環境設定。node-postgres:連線。
PGDATABASE=revision_demo node revision-demo.mjs
預期的最終值如下:
| 文章 | 內文 | 修訂版本 | 說明 |
|---|---|---|---|
| 1 | Stale replacement |
3 | 兩次不帶條件的儲存都已提交 |
| 2 | First edit |
2 | 第二次修訂版本檢查儲存遭到拒絕 |
列印出的最終狀態應為 stale-or-missing。
這個流程刻意避免受時序影響的並行操作,直接呈現核心界線:即使每筆儲存交易都是可序列化的,過時的替換仍可能成功,因為這些儲存可以按有效的序列順序執行。
node-postgres 應如何處理每種結果?
程式使用驅動程式文件所述的參數化查詢 API,將值與 SQL 文字分開傳入。程式也會檢查 rowCount,它會回報更新處理了多少筆資料列;單靠空的 rows 陣列,並不能普遍取代對更新筆數的檢查。node-postgres:查詢、node-postgres:pg.Result。
調整函式時,請依照以下決策表處理:
| 結果 | 應用程式動作 |
|---|---|
| 更新一筆資料列且成功提交 | 接受儲存,並保留傳回的修訂版本 |
| 未更新任何資料列 | 將此儲存視為過時或目標不存在而拒絕;保留提交的草稿 |
SQLSTATE 40001 |
回復交易,並在有界限的重試策略下重試完整交易 |
| 其他錯誤,或已達重試上限 | 顯示錯誤;不可回報儲存成功 |
零筆資料列的結果無法判定文章是已刪除,還是修訂版本已變更。此示範刻意將這兩種結果合併處理。若介面需要更詳細的資訊,請之後載入目前的文章,並將結果呈現為稍後觀察到的狀態。不要聲稱它能證明更新失敗當下的確切狀態。
序列化失敗使用 SQLSTATE 40001。PostgreSQL 要求重試完整交易,包括決定要執行哪些陳述式及使用哪些值的邏輯。重試可能需要多次嘗試,也不保證最終一定完成。範例以三次嘗試作為應用程式策略的示意。PostgreSQL:處理序列化失敗。
這些嘗試期間,請保持 expectedRevision 不變。它代表提交草稿所需的前提。若另一筆儲存已遞增修訂版本,新的交易便可透過零筆更新拒絕舊的權杖。自動以最新修訂版本取代該權杖,會取消過時寫入保護。
只有在 COMMIT 完成後,函式才會回傳成功。所有交易陳述式都使用同一個 Client,這是 node-postgres 的要求。若要改用連線池,請透過 pool.connect() 取得用戶端,在整個交易期間都使用該用戶端,並於之後釋放它。node-postgres:交易、node-postgres:pg.Pool。
還需要 SERIALIZABLE 或 SELECT FOR UPDATE 嗎?
對於這種範圍有限的單列條件更新,Read Committed 也能實施修訂版本前提:若正在等待已提交的並行更新,PostgreSQL 會針對更新後的資料列重新評估更新條件。但這不會讓 Read Committed 對複雜的多列規則提供保證。PostgreSQL:交易隔離。
若周邊交易依賴更廣泛的讀寫操作,且需要 SERIALIZABLE 的保證,請保留 SERIALIZABLE。修訂版本條件表達編輯器所需的前提;隔離等級則規範交易資料庫操作之間的互動。請依實際需求選擇兩者。
若交易需要檢查資料列並執行多個相依操作,SELECT FOR UPDATE 可派上用場。它取得的資料列鎖會阻擋衝突的寫入者,直到交易結束。在 Repeatable Read 或 Serializable 隔離等級下,若鎖定的資料列自交易快照建立後已變更,可能會導致錯誤。PostgreSQL:明確鎖定。
若只在收到儲存請求時才取得鎖,應用程式仍須負責比對提交的基礎修訂版本。鎖定目前的資料列並不能告訴資料庫產生草稿時所依據的是哪個較早的修訂版本。本文展示的單列替換操作,已由條件更新同時完成比對與變更。
整合此模式前,請檢查所有寫入內容的路徑,在儲存遭拒時保留編輯器草稿,並且只在確認儲存成功後更新編輯器的基礎修訂版本。若過時的儲存遭到拒絕,請同時提供目前文章與保留的草稿,讓讀者核對文字,再根據實際檢視過的修訂版本提交新的儲存。
來源
- 13.2. 交易隔離 — PostgreSQL 全球開發小組。
- UPDATE — PostgreSQL 全球開發小組。
- 13.5. 處理序列化失敗 — PostgreSQL 全球開發小組。
- 13.3. 明確鎖定 — PostgreSQL 全球開發小組。
- node-postgres:簡介與安裝 — node-postgres。
- ESM — node-postgres。
- 連線 — node-postgres。
- 查詢 — node-postgres。
- pg.Result — node-postgres。
- 交易 — node-postgres。
- pg.Pool — node-postgres。