跳至主要內容
Skip to content

交易與 ACID 原則

交易(Transaction)是資料庫保證資料一致性的核心機制。本篇將深入解析 ACID 原則,並介紹 PostgreSQL 的隔離等級。


一、 什麼是交易?

交易是一組 SQL 操作的邏輯單元,要麼全部成功,要麼全部失敗。

經典範例:轉帳

sql
-- A 帳戶轉 1000 元給 B
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE user_id = 'B';
COMMIT;

如果第二個 UPDATE 失敗,第一個也必須回滾。否則錢就「消失」了。

基本語法

sql
BEGIN;              -- 或 START TRANSACTION
-- SQL 操作...
COMMIT;             -- 成功,永久儲存

BEGIN;
-- SQL 操作...
ROLLBACK;           -- 失敗,全部撤銷

二、 ACID 原則

ACID 是交易必須滿足的四個特性:

A - Atomicity(原子性)

交易內的操作要麼全部執行,要麼全部不執行。

sql
BEGIN;
INSERT INTO orders (id, total) VALUES (1, 1000);
INSERT INTO order_items (order_id, product) VALUES (1, 'iPhone');
-- 如果第二個 INSERT 失敗...
ROLLBACK;  -- 第一個 INSERT 也會撤銷

C - Consistency(一致性)

交易完成後,資料庫必須從一個一致狀態轉換到另一個一致狀態。

sql
-- 約束確保一致性
ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE user_id = 'A';
-- 如果這導致 balance < 0,交易會失敗
COMMIT;

I - Isolation(隔離性)

並行交易之間互不干擾。

sql
-- Session 1
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 尚未 COMMIT

-- Session 2(同時執行)
SELECT stock FROM products WHERE id = 1;
-- 看到的是舊值還是新值?取決於隔離等級

D - Durability(持久性)

交易 COMMIT 後,資料永久保存,即使系統崩潰也不會丟失。

PostgreSQL 透過 WAL(Write-Ahead Log)確保持久性。


三、 隔離等級

PostgreSQL 支援三種隔離等級(SQL 標準定義四種):

隔離等級髒讀不可重複讀幻讀
Read Uncommitted⚠️⚠️
Read Committed⚠️⚠️
Repeatable Read⚠️
Serializable

TIP

PostgreSQL 的 Read Uncommitted 實際上等同於 Read Committed(不會真的允許髒讀)。

設定隔離等級

sql
-- 設定當前交易
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 或
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 設定 Session 預設
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

四、 隔離問題詳解

髒讀 (Dirty Read)

讀取到其他交易尚未 COMMIT 的資料。

sql
-- Session 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE user_id = 'A';
-- 尚未 COMMIT

-- Session 2
SELECT balance FROM accounts WHERE user_id = 'A';
-- 髒讀:看到 0
-- 如果 Session 1 ROLLBACK,這個 0 是無效的

-- PostgreSQL 不允許髒讀,Session 2 看到的是修改前的值

不可重複讀 (Non-Repeatable Read)

同一交易內,兩次讀取同一資料結果不同。

sql
-- Session 1 (Read Committed)
BEGIN;
SELECT balance FROM accounts WHERE user_id = 'A';  -- 結果:1000

-- Session 2
UPDATE accounts SET balance = 500 WHERE user_id = 'A';
COMMIT;

-- Session 1(繼續)
SELECT balance FROM accounts WHERE user_id = 'A';  -- 結果:500(不同了!)
COMMIT;

幻讀 (Phantom Read)

同一交易內,兩次查詢結果的「列數」不同。

sql
-- Session 1 (Repeatable Read)
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 結果:10

-- Session 2
INSERT INTO orders (status) VALUES ('pending');
COMMIT;

-- Session 1(繼續)
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- Repeatable Read 下仍是 10
                                                        -- Read Committed 下變成 11
COMMIT;

五、 Read Committed(預設)

每個 SELECT 都會看到該瞬間已 COMMIT 的資料。

sql
-- 設定(預設就是這個)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

特點

  • 每個 SELECT 建立新的快照
  • 可能遇到不可重複讀
  • 效能最好
  • 適合:大多數 OLTP 場景

六、 Repeatable Read

整個交易看到的是開始時的快照。

sql
BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT * FROM accounts WHERE user_id = 'A';  -- balance = 1000

-- 即使其他交易修改了 A 的 balance 並 COMMIT
-- 這裡看到的仍然是 1000

COMMIT;

特點

  • 整個交易共用一個快照
  • 不會遇到不可重複讀
  • 可能遇到序列化失敗
  • 適合:報表、資料匯出

序列化失敗

sql
-- Session 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';

-- Session 2(同時)
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'A';
COMMIT;

-- Session 1
COMMIT;  -- 錯誤:could not serialize access due to concurrent update

處理方式:捕捉錯誤並重試。

javascript
async function transferWithRetry() {
  for (let i = 0; i < 3; i++) {
    try {
      await db.query("BEGIN ISOLATION LEVEL REPEATABLE READ");
      await db.query(
        "UPDATE accounts SET balance = balance - 100 WHERE user_id = $1",
        ["A"]
      );
      await db.query("COMMIT");
      return;
    } catch (e) {
      await db.query("ROLLBACK");
      if (e.code !== "40001") throw e; // 40001 = serialization_failure
    }
  }
  throw new Error("Max retries exceeded");
}

七、 Serializable

最嚴格的隔離等級,保證交易串行執行的效果。

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 操作...
COMMIT;

特點

  • 完全避免所有並行問題
  • 序列化失敗機率更高
  • 效能開銷較大
  • 適合:金融交易、需要完美一致性的場景

八、 SAVEPOINT

在交易內設置「存檔點」,可以部分回滾。

sql
BEGIN;

INSERT INTO orders (id) VALUES (1);
SAVEPOINT sp1;

INSERT INTO order_items (order_id, product) VALUES (1, 'A');
SAVEPOINT sp2;

INSERT INTO order_items (order_id, product) VALUES (1, 'B');
-- 發現錯誤,只回滾到 sp2
ROLLBACK TO SAVEPOINT sp2;

-- 繼續其他操作
INSERT INTO order_items (order_id, product) VALUES (1, 'C');

COMMIT;  -- order 1 和 item A, C 被保存

九、 鎖等待與超時

sql
-- 設定鎖等待超時
SET lock_timeout = '5s';

BEGIN;
UPDATE accounts SET balance = 0 WHERE user_id = 'A';
-- 如果 5 秒內拿不到鎖,報錯
COMMIT;

查看鎖等待

sql
SELECT
    blocked_locks.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks
    ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
    AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
    AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
    AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
    AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
    AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
    AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
    AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
    AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
    AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

十、 最佳實踐

1. 保持交易簡短

sql
-- 不好:交易內做大量處理
BEGIN;
-- 複雜計算...
-- API 呼叫...
-- 更多處理...
COMMIT;

-- 好:只在必要時開啟交易
-- 先做計算
-- 然後
BEGIN;
INSERT INTO results ...;
COMMIT;

2. 避免在交易中等待使用者輸入

javascript
// 不好
await db.query("BEGIN");
await waitForUserConfirmation(); // 使用者可能離開
await db.query("COMMIT");

// 好
const confirmed = await waitForUserConfirmation();
if (confirmed) {
  await db.query("BEGIN");
  await db.query("INSERT ...");
  await db.query("COMMIT");
}

3. 處理錯誤時記得 ROLLBACK

javascript
try {
  await db.query("BEGIN");
  await db.query("INSERT ...");
  await db.query("COMMIT");
} catch (e) {
  await db.query("ROLLBACK"); // 重要!
  throw e;
}

4. 選擇適當的隔離等級

場景推薦隔離等級
一般 CRUDRead Committed(預設)
報表、資料匯出Repeatable Read
金融交易Serializable

總結

概念說明
原子性全部成功或全部失敗
一致性約束永遠滿足
隔離性並行交易互不干擾
持久性COMMIT 後永久保存
Read Committed每個 SELECT 看最新 COMMIT 的資料
Repeatable Read整個交易看開始時的快照
Serializable完全串行化執行

進階挑戰

  1. 模擬一個「轉帳」場景,確保交易的原子性(要嘛全成功,要嘛全失敗)
  2. 在 Repeatable Read 隔離等級下,觸發一個序列化失敗(Serialization Failure),並實作重試邏輯
  3. 比較 Read Committed 和 Serializable 隔離等級在高併發下的效能差異

延伸閱讀與資源


← 上一章:查詢調優實戰 | 返回專題首頁 | 下一章:鎖機制與死結處理 →