若要讓 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說明了這項要求,並指出重試仍可能再次失敗。

如何找出脫離交易的查詢?

從連線取出開始追蹤某個預期批次,直到最後提交或回復。依照以下步驟檢查程式碼:

  1. 找出 pool.connect() 呼叫,並確認它傳回哪個用戶端。
  2. 逐一檢查 BEGIN 和交易完成之間呼叫的每個輔助函式。確認每個函式都會收到並使用該用戶端。
  3. 檢查共用查詢包裝函式中是否藏有 pool.query() 呼叫或額外的連線取出操作。
  4. 確認每項操作都受到等待,而且輔助函式會將錯誤向外傳遞。
  5. 將合成的重複資料列測試改成適用於你的批次,並確認較早的插入操作會在後續陳述式失敗後消失。

通過回復測試,可為受測路徑提供有用的證據,但無法證明應用程式的每個分支都使用正確的用戶端。也要檢查條件式輔助函式。如果查詢都使用同一個用戶端,但重複讀取的結果仍不同,請先依照所選的隔離層級檢視這種行為,再調整連線處理方式。

明確呈現用戶端的取得與釋放、明確傳入用戶端,並確保所有預期操作完成後才結束交易。如此便能建立一個具體且可檢查的結構,並在文章工作流程擴展時重複執行回復測試。

來源