主題: PostgreSQL

Transaction 的資料邊界:訂單、庫存與 ACID

一筆訂單會同時碰到庫存、訂單與付款紀錄。transaction 用全做或全不做保護資料庫狀態;它不會取消外部 email,也不取代 constraint 或並發設計。

「把庫存扣掉、建立訂單、記下付款」聽起來像三件小事。分開執行時,它們卻很容易留下半成品:庫存扣了,訂單沒寫進去;付款紀錄有了,後面一個 SQL 失敗。

transaction 把共同描述一件事的資料庫變更收在同一個結果裡:全部成立,或全部不存在。它不會替 application 補齊沒有定義的規則。

動態迷因(展開/收合)
訂單、庫存與付款若只完成一部分,最難的往往不是修 SQL,而是判斷哪一筆資料還能相信。 · 來源:GIPHY

PostgreSQL 的 transaction tutorial 用轉帳說明這個問題:多個更新必須一起成功,其他 transaction 也不該看見中間只扣款、尚未入帳的狀態。

先定義:哪些資料在說同一件事?

假設商品只剩一件。成功結帳至少要完成兩件資料庫工作:保留庫存,建立訂單。若還要記付款授權或寫出待寄送通知,它們也是同一筆「接受這次訂單」事實的一部分。

BEGIN;

UPDATE inventory
SET quantity = quantity - 1
WHERE sku = $1
  AND quantity > 0
RETURNING sku;

-- 收到一列才繼續;沒有列時 ROLLBACK。
INSERT INTO orders (customer_id, sku)
VALUES ($2, $1);

COMMIT;

這段 SQL 有兩個責任。UPDATE 直接把「尚有庫存」寫進更新條件,避免先讀到庫存、過一會兒才扣的空窗;BEGINCOMMIT 則把保留庫存與建立訂單綁在一起。若 INSERT 失敗,transaction 不能提交,應由 application 結束為 ROLLBACK,先前的扣庫存也不會留下。

沒有明寫 BEGIN 時,PostgreSQL 仍會用 transaction 執行每一個 statement;成功時各自提交。這對單一 UPDATE 很方便,但它不會把下一個 INSERT 自動併進同一個單位。多個 statement 要共進退,才需要明確的 transaction block。

ACID 的四個責任

ACID 從四個角度描述 transaction。每一項的責任不同。

  • Atomicity:一組變更全做或全不做。扣款成功但訂單沒有建立,首先是這個邊界破了。
  • Consistency:提交後仍要符合已寫下的資料規則,例如 CHECK (quantity >= 0)、foreign key 或 unique constraint。資料庫不會自動知道所有產品規則;沒有寫進 constraint 或 transaction 邏輯的規則,仍得由系統明確執行。
  • Isolation:同時工作的 transaction 不該看見另一筆交易的半完成狀態。這不代表從此沒有並發問題,而是避免把中間步驟當成已完成的事實。
  • Durability:資料庫已確認 COMMIT 後,變更應在當機後仍可恢復。它保證的是資料庫內已提交的資料,不是第三方服務也跟著可回復。

CHECKUNIQUENOT NULL 與 foreign key 是 consistency 的具體工具;這正是前一篇所談的「把不可能的資料留在資料庫外」。transaction 補的是另一件事:多個本來都合法的變更,不能只留下其中一部分。

isolation 防半成品,卻不把並發變不見

兩位使用者同時搶最後一件商品時,最危險的流程通常是:先用一條 SELECT 問「有沒有庫存」,再用另一條 UPDATE 扣庫存。兩條 statement 中間,答案已經可能改變。

動態迷因(展開/收合)
把「還有庫存」放進更新條件,資料庫就能回覆這次保留是否真的成功,而不是只回覆稍早看見的數字。 · 來源:GIPHY

上面的 UPDATE ... WHERE quantity > 0 RETURNING 把檢查與扣除放進同一條資料庫操作。它回傳一列,代表這次保留成功;沒有回傳列,就代表不該建立訂單。對這種單列、明確條件的情境,這比 application 先查再寫更可靠。

PostgreSQL 的 Transaction Isolation 文件也區分了這種簡單、預先指定 row 的更新,與需要跨多列判斷的複雜規則。後者可能需要更高 isolation level、鎖定策略,或處理 serialization failure 後的完整重試;別因為看見 BEGIN 就以為所有競爭條件已經消失。

實作時先把資料規則交給 constraint,再把同一件事的多個寫入包成 transaction。遇到讀取後再寫入的競爭,才改用條件式更新、鎖或 isolation 設計。

ROLLBACK 收不回已送出的 email

transaction 只能控制同一個資料庫 transaction 內的變更。若在 COMMIT 前已呼叫付款 API、寄出 email 或上傳檔案,後面的 ROLLBACK 不會叫外部服務倒帶。

因此「資料庫訂單寫好了,再寄一封信」通常應拆成兩個邊界。先在短 transaction 中寫入訂單與可追蹤的待處理紀錄:

BEGIN;

WITH created_order AS (
  INSERT INTO orders (customer_id, sku)
  VALUES ($1, $2)
  RETURNING id
)
INSERT INTO outbox (kind, order_id)
SELECT 'order-confirmation', id
FROM created_order;

COMMIT;

之後由 worker 讀取 outbox、傳送 email、再標記結果。這筆資料只記錄系統答應寄信的事實,不保證 email 恰好送出一次;失敗仍要能觀察與重試。外部副作用的 idempotency、retry 與失敗處理屬於下一層設計,也不該藏在長時間開啟的 transaction 裡。

我學到什麼

  • 我會先找出哪些資料庫變更共同描述一個事實,再決定 transaction 的邊界,而不是替每條 query 機械式加上 BEGIN
  • Atomicity 處理半完成的多步驟寫入;Consistency 則要求提交後仍符合已定義的資料規則,兩者不能互相取代。
  • 單一條帶條件的 UPDATE 可以縮小「先查再寫」的競爭空窗;跨多列或全域規則仍要另行設計並發控制與重試。
  • ROLLBACK 只能取消資料庫變更。外部 email、付款與檔案上傳需要自己的可觀察、可重試流程。

結語

transaction 最有價值的地方,是讓資料庫對「這件事是否真的發生」給出明確答案。先界定共同事實,再用 constraint 守住資料規則、用 transaction 收住多步驟寫入,最後才針對真正的並發情境選擇鎖定或 isolation。


外部參考連結

延伸學習