EP.03資料庫與 SQL

交易與鎖定:ACID 不只是面試題
隔離層級、髒讀、幻讀與死鎖實戰

「帳戶轉帳扣了錢卻沒入帳」、「庫存超賣」——這些都是交易設計錯誤的結果。 ACID 保證的不只是理論,而是你的資料在並發情況下不會壞掉的底線。

Joseph Chen 2026 13 min read ACID · Transaction · Lock · Isolation · Deadlock

「轉帳是最經典的資料庫問題:從 A 帳戶扣 1000 元,存入 B 帳戶。 如果扣款成功但存入失敗,錢就憑空消失了。交易(Transaction)就是用來防止這件事的。」

交易是資料庫中「一組操作的原子單位」。ACID 定義了交易的四個保證, 而理解隔離層級與鎖定機制,才能在高並發系統中寫出正確的程式。

什麼是交易(Transaction)?

交易是一組 SQL 操作的集合,這組操作要麼全部成功,要麼全部回滾,不會有「做一半」的中間狀態。

交易的基本語法
-- 轉帳範例:從帳戶 A 轉 1000 元到帳戶 B
BEGIN;  -- 開始交易

UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';

-- 如果中途發生錯誤,用 ROLLBACK 回滾所有操作
-- ROLLBACK;

COMMIT;  -- 確認提交,所有變更才永久生效

-- ────────────────────────────────────────
-- 在應用層(Node.js + pg 為例)
const client = await pool.connect();
try {
    await client.query('BEGIN');
    await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [1000, 'A']);
    await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [1000, 'B']);
    await client.query('COMMIT');
} catch (err) {
    await client.query('ROLLBACK');  // 任何錯誤都回滾
    throw err;
} finally {
    client.release();
}

ACID:交易的四個保證

A

Atomicity(原子性)

交易中的所有操作,要麼全部完成,要麼全部不做。不存在「做一半」的狀態。

📌 轉帳:扣款和入帳是原子的,不可能只發生其中一個。

C

Consistency(一致性)

交易執行前後,資料庫必須保持一致性狀態(滿足所有定義的規則與約束)。

📌 帳戶餘額不能為負數(CHECK constraint),轉帳前後兩帳戶的總金額不變。

I

Isolation(隔離性)

並發執行的多個交易之間互不干擾,每個交易都感覺像是在「獨占」資料庫。

📌 兩個使用者同時購買最後一件商品,系統必須確保只有一個人成功。

D

Durability(持久性)

COMMIT 後的資料永久保存,即使系統崩潰也不會丟失。背後是 WAL(Write-Ahead Log)機制:每次資料修改先寫入日誌,再更新實際資料頁。崩潰後,DB 用 WAL 重播尚未落地的變更,回到一致狀態。

📌 訂單確認後,即使伺服器突然斷電,重啟後 PostgreSQL 會透過 WAL 恢復,訂單仍然存在。


並發問題:不隔離會發生什麼?

多個交易同時執行時,如果沒有適當的隔離,就會出現以下三類問題:

髒讀(Dirty Read)

嚴重

交易 A 讀到了交易 B 還未 COMMIT 的資料。如果 B 之後 ROLLBACK,A 讀到的就是不存在的「幽靈資料」。

📌 例子:B 正在轉帳(餘額暫時扣為 0),A 此時查詢顯示餘額為 0,但 B 最終 ROLLBACK 了。

不可重複讀(Non-repeatable Read)

中等

交易 A 在同一個交易中兩次讀取同一列,但兩次結果不同(因為 B 在中間 UPDATE 並 COMMIT 了)。

📌 例子:結帳時讀取商品價格是 100,計算完總價後再讀一次變成 120(另一個管理員改了價格)。

幻讀(Phantom Read)

輕微

交易 A 兩次執行同樣的範圍查詢,第二次出現了第一次沒有的列(B 在中間 INSERT 並 COMMIT)。

📌 例子:統計「庫存 > 0 的商品」,第一次 10 筆,計算後再查變 11 筆(新商品入庫)。


四個隔離層級:效能與安全的取捨

SQL 標準定義了四個隔離層級,隔離程度越高,並發性能越低。 沒有「最好的」層級,只有「最適合你場景的」層級。

隔離層級髒讀不可重複讀幻讀效能適用場景
READ UNCOMMITTED⚠️ 可能⚠️ 可能⚠️ 可能最快幾乎不用,僅粗略統計
READ COMMITTED (PostgreSQL 預設)✅ 防止⚠️ 可能⚠️ 可能一般 CRUD、電商訂單
REPEATABLE READ (MySQL InnoDB 預設)✅ 防止✅ 防止⚠️ 可能*報表、對帳、金融計算
SERIALIZABLE✅ 防止✅ 防止✅ 防止最慢嚴格金融交易、搶票

* MySQL InnoDB 在 REPEATABLE READ 層級用 Gap Lock 防止幻讀;PostgreSQL 則需要 SERIALIZABLE 才完全防止。

設定隔離層級
-- 為單一交易設定隔離層級
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 'A';
-- ... 其他操作 ...
COMMIT;

-- 查看當前預設隔離層級
SHOW transaction_isolation;  -- PostgreSQL

-- 修改 session 預設
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;

鎖定(Lock):隔離的實作機制

隔離層級的背後,是鎖定機制在保護資料。理解鎖定有助於排查並發問題和死鎖。

共享鎖(Shared Lock / S Lock)

讀取時獲取。多個交易可以同時持有同一列的共享鎖(多讀不互斥)。

SELECT ... FOR SHARE

排他鎖(Exclusive Lock / X Lock)

寫入時獲取。只有一個交易能持有,其他交易無論讀寫都必須等待。

SELECT ... FOR UPDATE
SELECT FOR UPDATE:防止庫存超賣(Node.js + pg)
// ❌ 錯誤做法:先讀後寫,沒有鎖(高並發時必然超賣!)
async function buyProductUnsafe(productId: number, userId: number) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query('SELECT stock FROM products WHERE id = $1', [productId]);
    const stock = rows[0].stock;  // A 讀到 1,B 同時也讀到 1
    // A 和 B 都認為有庫存,各自執行 UPDATE → stock 變 -1,超賣!
    if (stock > 0) {
      await client.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [productId]);
    }
    await client.query('COMMIT');
  } finally {
    client.release();
  }
}

// ─────────────────────────────────────────────────────────────────────
// ✅ 正確做法:SELECT FOR UPDATE 取得排他鎖,IF/ELSE 控制流在應用層
async function buyProduct(productId: number, userId: number) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // FOR UPDATE:鎖定這列,其他交易的 FOR UPDATE 請求會阻塞到此交易結束
    const { rows } = await client.query(
      'SELECT stock FROM products WHERE id = $1 FOR UPDATE',
      [productId]
    );
    const stock = rows[0].stock;

    // 控制流在 Node.js,不是在 SQL(SQL 沒有 IF/ELSE,那是 PL/pgSQL 的語法)
    if (stock <= 0) {
      await client.query('ROLLBACK');
      throw new Error('Out of stock');
    }

    await client.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [productId]);
    await client.query('INSERT INTO orders (product_id, user_id) VALUES ($1, $2)', [productId, userId]);
    await client.query('COMMIT');
  } catch (err) {
    // 主動 ROLLBACK(含 DB 錯誤、業務邏輯錯誤等所有異常)
    // 若連線已斷,ROLLBACK 本身可能失敗,但交易超時後 DB 會自動回滾
    try { await client.query('ROLLBACK'); } catch { /* 連線已斷,DB 自動回滾 */ }
    throw err;
  } finally {
    client.release();  // 無論如何都要釋放連線回 pool
  }
}

死鎖(Deadlock):兩個交易互相等待

死鎖情境:兩個交易的加鎖順序相反

交易 A

BEGIN;

-- 鎖定 帳戶 1

SELECT ... FROM accounts WHERE id=1 FOR UPDATE;

-- 等待帳戶 2 的鎖(被 B 持有)

SELECT ... FROM accounts WHERE id=2 FOR UPDATE;

⏳ 等待 B 釋放帳戶 2...

交易 B

BEGIN;

-- 鎖定 帳戶 2

SELECT ... FROM accounts WHERE id=2 FOR UPDATE;

-- 等待帳戶 1 的鎖(被 A 持有)

SELECT ... FROM accounts WHERE id=1 FOR UPDATE;

⏳ 等待 A 釋放帳戶 1...

結果:A 等 B,B 等 A,永遠等下去。
PostgreSQL 會自動偵測並終止其中一個交易(回傳 ERROR: deadlock detected)。

✅ 避免死鎖的策略

  • 固定加鎖順序:所有交易都按同樣順序鎖定資源(例如永遠先鎖 id 較小的帳戶)。
  • 縮短交易時間:交易越短,持有鎖的時間越短,死鎖機率越低。
  • 一次性取得所有鎖:用 SELECT ... FOR UPDATE OF a, b 一次鎖定多個表,而非分批鎖定。
  • 捕捉 deadlock 錯誤並重試:死鎖是正常現象,應用層要能捕捉並自動重試交易。

本篇重點回顧

⚛️ACID 是交易的四個保證:Atomicity(全或無)、Consistency(約束不違反)、Isolation(並發互不干擾)、Durability(持久化)。
👻三種並發問題:髒讀(讀未提交資料)、不可重複讀(同列讀兩次不同)、幻讀(範圍查詢多了新列)。
🏰四個隔離層級:READ UNCOMMITTED < READ COMMITTED(PG 預設)< REPEATABLE READ(MySQL 預設)< SERIALIZABLE。隔離越高效能越低。
🔒SELECT FOR UPDATE 取得排他鎖,防止並發讀後寫的競態條件(庫存超賣的核心解法)。
💀死鎖:A 等 B,B 等 A。預防方式:固定加鎖順序、縮短交易。應用層要捕捉 deadlock 錯誤並重試。

ACIDTransactionLockIsolationDeadlockSELECT FOR UPDATEDatabaseEP.03