在 PostgreSQL 中,再送出一次 BEGIN 不會建立巢狀交易。它會發出警告並維持目前的交易;第一個成功的 COMMIT 會結束該交易。若輔助函式需要部分復原,請使用儲存點。PostgreSQL:BEGIN

本指南協助在 Mac 終端機工作的開發者,診斷某個輔助函式過早提交文章編輯操作的問題。範例使用合成的文章資料列,說明如何維持單一交易負責者,同時允許輔助函式復原自己批次中的變更。內容依據目前的 PostgreSQL 與 node-postgres 文件。

BEGIN 和 COMMIT 出現在另一個交易中時會發生什麼事?

SQL 交易命令會在連線目前的交易上運作。PostgreSQL 不會計算重複呼叫 BEGIN 的次數,並等待相符的 COMMIT 呼叫。第二個 BEGIN 不會改變交易;成功執行一般的 COMMIT 會提交目前的交易。之後再送出另一個一般的 COMMIT 時,因為沒有開啟中的交易而會產生警告。PostgreSQL:BEGIN、PostgreSQL:COMMIT

在正常的交易中,邊界追蹤如下:

在同一連線上送出的命令 產生的邊界
第一個 BEGIN 開啟交易
第一筆文章插入 屬於該交易
第二個 BEGIN 發出警告;不會建立內層邊界
第二筆文章插入 屬於同一個交易
第一個 COMMIT 提交兩筆插入並結束交易
另外送出的語句 在一般自動提交行為下,於自己的交易中執行
之後的 ROLLBACK 無法復原已提交的插入

這說明了常見的應用程式錯誤:呼叫端開啟交易後,呼叫了一個自行送出 BEGIN 和 COMMIT 的輔助函式。輔助函式中的 COMMIT 也會結束呼叫端的交易。呼叫端之後若發生例外,就無法還原原本全有或全無的操作。此結論依據 PostgreSQL 文件所述的交易邊界。PostgreSQL:交易

在 Mac 終端機重現過早提交

你需要一個正在執行的 PostgreSQL 資料庫,以及已安裝並具備可用連線設定的 psql 命令。開啟 Terminal,使用 psql -X 啟動全新工作階段。-X 選項會略過啟動檔,讓此示範較不受本機自訂設定影響。在該工作階段執行以下腳本;它會明確啟用自動提交。PostgreSQL:psql

此資料表是暫存表,只包含合成的文章標題。ON COMMIT PRESERVE ROWS 會讓資料列在交易邊界之間仍可供檢視;工作階段結束時,PostgreSQL 會刪除暫存表。這示範的是工作階段內的復原行為,而非中斷連線後仍持續儲存的資料。PostgreSQL:CREATE TABLE

\set AUTOCOMMIT on

CREATE TEMP TABLE article_boundary_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_boundary_demo VALUES (1, 'Opening draft');

-- Deliberately incorrect attempt to open an inner transaction.
BEGIN;
INSERT INTO article_boundary_demo VALUES (2, 'Middle draft');
COMMIT;

-- The previous COMMIT has already ended the transaction.
INSERT INTO article_boundary_demo VALUES (3, 'Closing draft');
ROLLBACK;

SELECT id, title FROM article_boundary_demo ORDER BY id;

預期結果會包含全部三筆資料列。第二個 BEGIN 會發出警告,指出交易已在進行中。COMMIT 會提交資料列 1 和 2;接著資料列 3 會透過自動提交完成提交。最後的 ROLLBACK 沒有可供復原的開啟中交易。這些是根據文件所述行為推導出的預期結果,並非資料庫執行結果的報告。PostgreSQL:BEGIN、PostgreSQL:交易

請像範例所示,在互動式 psql 中執行此重現操作。用戶端如何提交命令很重要:若透過一個 psql -c 字串一次傳入多個 SQL 語句,除非明確交易命令將它們分開,這些語句可能會共用一個隱含交易。文件建議,若要分開執行,請使用個別命令或標準輸入。PostgreSQL:psql

儲存點如何保留外層交易?

儲存點會標記現有交易中的一個位置。ROLLBACK TO SAVEPOINT 會丟棄該位置之後的變更,同時保留先前的工作。RELEASE SAVEPOINT 會移除標記,並將仍保留的變更留在包含它的交易中。它不會獨立提交那些變更。PostgreSQL:SAVEPOINT、PostgreSQL:RELEASE SAVEPOINT

在同一個工作階段或另一個全新的 psql 工作階段中執行以下完整範例。它會建立自己的暫存表:

CREATE TEMP TABLE article_savepoint_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_savepoint_demo VALUES (1, 'Opening draft');

SAVEPOINT optional_article;
INSERT INTO article_savepoint_demo VALUES (2, 'Optional draft');
ROLLBACK TO SAVEPOINT optional_article;
RELEASE SAVEPOINT optional_article;

INSERT INTO article_savepoint_demo VALUES (3, 'Closing draft');
COMMIT;

SELECT id, title FROM article_savepoint_demo ORDER BY id;

預期資料列為 1 和 3。資料列 2 會在儲存點處被丟棄,而開頭與結尾的資料列會一直屬於同一個交易,直到最後的 COMMIT。若將最後的 COMMIT 換成 ROLLBACK,兩筆仍保留的插入也會一併丟棄。PostgreSQL:SAVEPOINT、PostgreSQL:交易

組合輔助函式時,儲存點的存續時間也很重要。復原到某個儲存點後,該命名儲存點仍可使用,但之後建立的儲存點會被銷毀。釋放儲存點則會移除它,以及之後建立的所有使用中儲存點。範例會在復原後明確釋放標記。PostgreSQL:ROLLBACK TO SAVEPOINT、PostgreSQL:RELEASE SAVEPOINT

node-postgres 輔助函式應如何共用交易?

node-postgres 要求交易中的每個語句都使用同一個用戶端執行,且不提供自動巢狀交易抽象。將呼叫端的用戶端傳給輔助函式。若應用程式使用連線池,請使用 pool.connect() 取得用戶端,整個過程都使用該用戶端,並在完成後釋放;pool.query() 可能會將連續的查詢分派到不同用戶端。node-postgres:交易、node-postgres:連線池

以下完整程式使用一個直接建立的 Client。呼叫端負責 BEGIN、COMMIT 和完整的 ROLLBACK。輔助函式會在可選的兩筆資料列批次周圍建立儲存點。它的第二次插入刻意重複文章 ID 1,因此會造成唯一性錯誤;復原時會丟棄整個可選批次,包括先前成功的插入。

設定並執行完整範例

使用目前受支援且已安裝 npm 的 Node.js 版本。在工作資料夾中執行 npm install pg,再將下方程式儲存為 article-transactions.mjs。此程式會使用你現有的 PostgreSQL 連線環境,包括已設定的 PGHOST、PGPORT、PGDATABASE 和 PGUSER。執行範例前,這些設定必須指向可用的資料庫。node-postgres:歡迎、node-postgres:連線

import pg from 'pg'
const { Client } = pg

async function addOptionalArticles(client) {
  await client.query('SAVEPOINT optional_articles')
  try {
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [2, 'Optional draft']
    )
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [1, 'Duplicate draft']
    )
  } catch (error) {
    if (error.code !== '23505') throw error
    await client.query('ROLLBACK TO SAVEPOINT optional_articles')
    await client.query('RELEASE SAVEPOINT optional_articles')
    return false
  }
  await client.query('RELEASE SAVEPOINT optional_articles')
  return true
}

async function main() {
  const client = new Client({ connectionTimeoutMillis: 5000 })
  await client.connect()
  try {
    await client.query(`
      CREATE TEMP TABLE article_demo (
        id integer PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `)
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED')
    try {
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [1, 'Opening draft']
      )
      const optionalKept = await addOptionalArticles(client)
      console.log('Optional batch kept:', optionalKept)
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [3, 'Closing draft']
      )
      await client.query('COMMIT')
    } catch (error) {
      try {
        await client.query('ROLLBACK')
      } catch (rollbackError) {
        throw new AggregateError(
          [error, rollbackError], 'Transaction and rollback failed'
        )
      }
      throw error
    }
    const result = await client.query(
      'SELECT id, title FROM article_demo ORDER BY id'
    )
    console.table(result.rows)
  } finally {
    await client.end()
  }
}

main().catch(error => {
  console.error(error)
  process.exitCode = 1
})

執行 node article-transactions.mjs。預期輸出會顯示 Optional batch kept: false,並列出文章 ID 1 和 3。數值使用文件所述的 query(text, values) 參數介面。JavaScript 已使用 Node.js 進行語法檢查;資料庫情境則依據文件檢視,未在此處執行。node-postgres:查詢

只針對明確接受的錯誤進行復原

PostgreSQL 將 23505 指派給 unique_violation,並建議檢查錯誤代碼,而非錯誤訊息文字。在此範例中,接受該代碼是針對可選合成批次的示範規則。其他錯誤會傳遞給呼叫端,由呼叫端嘗試復原整個交易。輔助函式會傳回明確結果,讓呼叫端知道可選工作已遭丟棄。PostgreSQL:PostgreSQL 錯誤代碼

僅捕捉 JavaScript 例外並不能還原失敗的 PostgreSQL 交易。復原需要回復到先前建立的儲存點,或完整回復。尤其是,已中止的交易無法單靠釋放儲存點來復原。PostgreSQL:交易、PostgreSQL:RELEASE SAVEPOINT

此輔助函式假設交易已啟動,且此用戶端會循序使用。它不是通用的遞迴交易包裝器:可重用的巢狀抽象還需要明確的所有權管理和儲存點名稱管理。

修正邊界後,還有哪些隔離限制?

儲存點可在目前交易中提供部分復原,但不會建立獨立的隔離政策。請在外層邊界選擇隔離級別。PostgreSQL 不允許在交易中的第一個查詢或資料修改語句之後變更隔離級別,因此輔助函式無法透過送出另一個 BEGIN,提升已使用中交易的隔離級別。PostgreSQL:SAVEPOINT、PostgreSQL:SET TRANSACTION

一般查詢請保留以下區別:

隔離級別 相關限制
READ COMMITTED 後續查詢可能會看到其他交易新提交的變更。同一個交易不代表使用固定快照。
REPEATABLE READ 查詢會共用第一個非交易控制語句建立的快照,同時仍可看見自己先前的變更。仍可能發生序列化失敗。
SERIALIZABLE 成功提交的交易,其效果與某種序列執行順序一致。應用程式仍須處理序列化失敗。
READ UNCOMMITTED PostgreSQL 將其視為 READ COMMITTED。

PostgreSQL:交易隔離、PostgreSQL:SET TRANSACTION

發生序列化失敗時,必須重試完整交易,包括產生 SQL 的決策。只重試輔助函式的儲存點工作,不會更新外層交易的快照。PostgreSQL 使用 SQLSTATE 40001 標示序列化失敗。示範程式會傳遞這些失敗,不會實作自動重試。PostgreSQL:交易隔離

如何選擇合適的輔助函式邊界?

檢視呼叫端及其文章輔助函式時,請依以下順序判斷:

  1. **所有操作必須一起成功:**由呼叫端負責交易,並將用戶端傳給只執行查詢的輔助函式。
  2. **呼叫端可繼續執行,但允許丟棄其中一個批次:**在該批次前建立儲存點,只針對接受的錯誤進行復原,並明確傳回結果。
  3. **批次必須獨立提交:**使用另一個用戶端和交易。這樣提交後,即使外層交易回復,該批次仍會保留,因此無法滿足原本全有或全無的任務需求。
  4. **並行執行造成序列化失敗:**重新執行完整交易,不要將輔助函式視為獨立的重試邊界。

這些選擇符合 PostgreSQL 的儲存點語意,以及 node-postgres 對交易必須使用單一用戶端的要求。PostgreSQL:SAVEPOINT、node-postgres:交易、PostgreSQL:交易隔離

對於此處展示的文章編輯任務,請維持單一交易負責者,讓整個呼叫鏈傳遞同一個用戶端,並為可選工作提供儲存點。檢查每個輔助函式中的交易命令:意料之外的 COMMIT 會改變整個連線的邊界,而釋放儲存點後,最終結果仍由呼叫端負責。

來源