若要讓 node-postgres 交易維持完整,請使用 pool.connect() 取得一個用戶端,透過該用戶端執行每個陳述式,並在提交或回復後釋放它。也要將該用戶端傳入輔助函式。官方的 Transactions 指南要求採用此模式。
本教學適用於使用 Mac 上 Terminal 的開發者,示範如何讓多筆文章資料列的變更一同成功或失敗。內容包括一個使用合成文章資料列的完整程式、一項刻意觸發回復的測試,以及一套用來診斷隱藏在輔助函式中的查詢的步驟。
為什麼 pool.query() 會破壞交易範圍?
連線池負責管理連線,但不會讓每個查詢都屬於同一個交易。每次呼叫 pool.query() 都會在內部取得一個可用的用戶端。因此,無法保證連續呼叫會使用同一個連線。node-postgres Pool API明確警告,不要用這個方法執行交易。
例如,透過連線池送出 BEGIN,再透過連線池送出插入操作,並不能保證該插入會處於你啟動的交易中。一般而言,PostgreSQL 會將明確交易區塊以外的陳述式各自執行於獨立交易中。之後在另一個連線上執行回復,無法復原這些已各自提交的變更。請參閱 PostgreSQL 的 BEGIN 說明文件。
明確的界線是已取出的用戶端物件。輔助函式的名稱、所屬模組,以及呼叫端正在等待它完成,這些都不能決定它是否屬於某個交易。只有當輔助函式的查詢使用啟動交易的那個用戶端時,它才會參與該交易。這正是 Transactions 指南中「使用同一個用戶端」要求的直接含意。
在 Mac 上執行完整的提交與回復範例
你需要安裝含 npm 的 Node.js、執行中的 PostgreSQL 伺服器,以及現有的本機開發資料庫。本教學假設資料庫名稱為 article_demo,且你的一般本機連線設定已可正常運作。安裝資料庫不在本交易教學的範圍內。
在 Terminal 中建立新的工作目錄並安裝驅動程式:
mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg
將以下完整程式儲存為 transaction-demo.mjs。其中的暫存資料表只包含合成的文章 ID 和標題。PostgreSQL 暫存資料表通常會在提交後保留,並在工作階段結束時移除;這些特性讓程式能在第二批資料回復後檢查第一批成功的資料。請參閱 CREATE TABLE。
以下交易輔助函式遵循文件所述的 BEGIN、COMMIT 和 ROLLBACK 順序。程式也明確處理回復失敗的情況,保留兩個錯誤,而不會取代原始錯誤。查詢值採用 Queries所記載的文字與值 API。
import pg from 'pg';
import assert from 'node:assert/strict';
const { Pool } = pg;
async function inTransaction(client, work) {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
try {
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
'Transaction failed; rollback also failed'
);
}
throw error;
}
}
async function insertArticle(client, article) {
const result = await client.query(
`INSERT INTO pg_temp.transaction_demo_articles (id, title)
VALUES ($1, $2)
RETURNING id, title`,
[article.id, article.title]
);
return result.rows[0];
}
async function insertBatch(client, articles) {
const inserted = [];
for (const article of articles) {
inserted.push(await insertArticle(client, article));
}
return inserted;
}
async function main() {
const pool = new Pool({ connectionTimeoutMillis: 5000 });
let client;
let discardClient = false;
pool.on('error', (error) => {
console.error('Idle client error:', error);
process.exitCode = 1;
});
try {
client = await pool.connect();
await client.query(`
CREATE TEMP TABLE transaction_demo_articles (
id text PRIMARY KEY,
title text NOT NULL
) ON COMMIT PRESERVE ROWS
`);
const saved = await inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-001', title: 'A guide to paper shapes' },
{ id: 'article-002', title: 'Choosing a notebook layout' }
])
);
assert.equal(saved.length, 2);
await assert.rejects(
() => inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-003', title: 'Arranging a reading desk' },
{ id: 'article-001', title: 'Deliberate duplicate ID' }
])
),
(error) => error.code === '23505'
);
const remaining = await client.query(`
SELECT id, title
FROM pg_temp.transaction_demo_articles
ORDER BY id
`);
assert.deepEqual(
remaining.rows.map((row) => row.id),
['article-001', 'article-002']
);
console.log('Commit and rollback checks passed.');
console.table(remaining.rows);
} catch (error) {
discardClient = true;
throw error;
} finally {
if (client) client.release(discardClient);
await pool.end();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
在同一個目錄中執行:
PGDATABASE=article_demo node transaction-demo.mjs
連線池會讀取 PostgreSQL 連線環境變數。如果你的本機設定需要不同的連線設定,請搭配 PGDATABASE 提供適當的 PGHOST 和 PGPORT。官方的 Connecting 指南說明了這些變數及其預設值。
執行成功時,預期會看到 Commit and rollback checks passed.,後面接著兩筆原始文章資料列。第二批資料會插入 article-003,接著嘗試重複使用 article-001。重複值會產生 SQLSTATE 23505,在 Serialization Failure Handling中記載為 unique_violation。回復這批資料也會移除較早插入的資料;斷言會檢查 article-003 是否不存在。
交易輔助函式的成功、工作錯誤和回復錯誤控制路徑,已透過確定性模擬用戶端檢查。本範例未在此處執行資料庫操作,因為環境中缺少 pg 套件和 PostgreSQL 測試連線;上述輸出描述的是預期結果。
輔助函式應如何共用交易用戶端?
在整個呼叫鏈中明確傳入用戶端參數。在程式中,main() 會取得用戶端,inTransaction() 會將同一個物件傳給回呼函式,而 insertBatch() 則會將它轉傳給 insertArticle()。兩個插入輔助函式都不會取出連線。
在此實作中,請明確劃分職責:
| 函式 | 職責 |
|---|---|
main() |
取得用戶端、執行示範設定、釋放用戶端,並關閉指令碼的連線池。 |
inTransaction() |
在提供的用戶端上啟動並完成一個交易。 |
insertBatch() |
等待每項文章操作完成,並轉傳提供的用戶端。 |
insertArticle() |
透過提供的用戶端執行插入操作。 |
輔助函式的約定刻意保持精簡:只有在該用戶端沒有進行中的交易時才呼叫 inTransaction(),並讓錯誤從回呼函式向外傳遞。在現有的 PostgreSQL 交易中呼叫 BEGIN,不會建立獨立的巢狀交易;PostgreSQL 說明巢狀工作應使用儲存點。請參閱 BEGIN。
從回呼函式返回前,請等待所有屬於該交易的操作完成。在此範例中,循序迴圈明確指出操作完成的時點。若啟動非同步插入,卻沒有傳回或等待它們的 Promise,回呼函式就可能在取得所有結果前先行結束。
查詢或回復失敗時會發生什麼事?
一般查詢失敗會傳到交易輔助函式的 catch 區塊。該區塊會嘗試在同一個用戶端上回復,然後重新拋出原始錯誤。只有在回復完成後,才會接受刻意觸發的重複值錯誤。如果回復失敗,輔助函式會拋出 AggregateError;重複值錯誤檢查不會接受它,因此執行流程會進入外層錯誤處理程序。
範例使用 client.release(true),在任何未預期的失敗後保守地銷毀用戶端。正常路徑則使用 client.release(false) 歸還用戶端。銷毀參數記載於 Pool API。
成功取得用戶端後,務必釋放它。否則,後續呼叫端可能會一直等待永遠不會空出的連線。這個獨立指令碼完成後,請關閉連線池;在應用程式中,應由應用程式關閉程序負責關閉連線池。Pooling 指南說明了釋放用戶端的要求,以及 pool.end() 如何等待已取出的用戶端歸還。
使用一個用戶端能否保證快照一致?
用戶端連續性和隔離層級回答的是不同問題。用戶端決定你的陳述式會交由哪個交易處理。隔離層級則決定其他交易執行期間,這些陳述式能看見什麼。PostgreSQL 的交易隔離說明文件定義了這些限制:
| 隔離層級 | 相關限制 |
|---|---|
READ COMMITTED |
預設值。連續的一般讀取可能看見其他交易先後提交的不同資料。每個陳述式都會取得新的快照。 |
REPEATABLE READ |
使用第一個非交易控制陳述式建立的快照。PostgreSQL 會防止幻讀,但仍可能發生序列化異常。 |
SERIALIZABLE |
已提交的可序列化交易,其行為與某種序列執行一致;交易仍可能失敗並需要重試。 |
READ UNCOMMITTED |
在 PostgreSQL 中,其行為與 READ COMMITTED 相同。 |
讀取也能看見同一交易先前所做的變更。因此,看見自己插入的資料,不代表之後的讀取都能以固定視圖看見其他交易的變更。請參閱交易隔離。
範例會在開頭的陳述式中明確選用 READ COMMITTED。若要使用其他層級,請在執行交易前修改該陳述式。PostgreSQL 允許在 BEGIN 中指定交易特性;若改用 SET TRANSACTION,則必須在第一個查詢或資料修改陳述式之前設定隔離層級。請參閱 BEGIN和 SET TRANSACTION。
如果選擇 REPEATABLE READ 或 SERIALIZABLE,請準備處理 SQLSTATE 40001。請重試整個交易,包括決定要執行哪些陳述式及使用哪些值的邏輯,而不是只重放失敗的查詢。本範例刻意直接傳遞這類錯誤,沒有實作重試。PostgreSQL 的 Serialization Failure Handling說明了這項要求,並指出重試仍可能再次失敗。
如何找出脫離交易的查詢?
從連線取出開始追蹤某個預期批次,直到最後提交或回復。依照以下步驟檢查程式碼:
- 找出
pool.connect()呼叫,並確認它傳回哪個用戶端。 - 逐一檢查
BEGIN和交易完成之間呼叫的每個輔助函式。確認每個函式都會收到並使用該用戶端。 - 檢查共用查詢包裝函式中是否藏有
pool.query()呼叫或額外的連線取出操作。 - 確認每項操作都受到等待,而且輔助函式會將錯誤向外傳遞。
- 將合成的重複資料列測試改成適用於你的批次,並確認較早的插入操作會在後續陳述式失敗後消失。
通過回復測試,可為受測路徑提供有用的證據,但無法證明應用程式的每個分支都使用正確的用戶端。也要檢查條件式輔助函式。如果查詢都使用同一個用戶端,但重複讀取的結果仍不同,請先依照所選的隔離層級檢視這種行為,再調整連線處理方式。
明確呈現用戶端的取得與釋放、明確傳入用戶端,並確保所有預期操作完成後才結束交易。如此便能建立一個具體且可檢查的結構,並在文章工作流程擴展時重複執行回復測試。
來源
- Transactions — node-postgres。
- pg.Pool — node-postgres。
- Queries — node-postgres。
- Connecting — node-postgres。
- Pooling — node-postgres。
- BEGIN — PostgreSQL Global Development Group。
- CREATE TABLE — PostgreSQL Global Development Group。
- 13.2. Transaction Isolation — PostgreSQL Global Development Group。
- SET TRANSACTION — PostgreSQL Global Development Group。
- 13.5. Serialization Failure Handling — PostgreSQL Global Development Group。