資料庫交易 (Database Transactions)

一句話定位

Transaction 把一組相關操作包成一個邏輯單元,保證全部成功或全部失敗。是資料庫可靠性的核心。

為什麼需要 Transaction

真實場景幾乎沒有「一步就完成」的操作:

沒有 transaction,任何一步失敗就會留下不一致狀態(A 扣了沒加給 B),生產環境真實 bug。


ACID 四個保證

Atomicity(原子性)

一個 transaction 的所有操作,要麼全部成功、要麼全部失敗。不存在中間狀態

實作:WAL(Write-Ahead Log) —— 真正寫資料前,先把「打算做什麼」記到 log。崩潰重啟時根據 log 判斷該繼續還是 rollback。

Consistency(一致性)

Transaction 執行完,資料庫必須從一個合法狀態轉到另一個合法狀態。所有 constraint 在 transaction 前後成立。

ACID 的 C 不是 CAP 的 C

  • ACID-C:商業邏輯正確性(如「餘額不能為負」)
  • CAP-C:分散式副本之間的同步(「所有節點同一時間看到相同資料」)
    兩者完全不同概念。

Isolation(隔離性)

並發的 transactions 之間互不干擾。

這是 ACID 裡最有取捨空間的一個 —— 完全隔離代價高,所以有不同等級可選。

Durability(持久性)

一旦 commit,資料永久保存,即使系統立刻崩潰。

實作:寫入持久化儲存(磁碟)+ WAL。重啟後可從 log 重建已 commit 但未刷到磁碟的資料。


三種並發異常

異常 範例 嚴重性
Dirty Read(髒讀) T1 寫 500 未 commit → T2 讀到 500 → T1 rollback → T2 看到「從未存在」的值 最嚴重
Non-repeatable Read T1 第一次讀 1000 → T2 commit 改成 500 → T1 再讀變 500(同一 row 值變了)
Phantom Read T1 查餘額 > 1000 得 5 筆 → T2 insert 新帳戶 → T1 再查得 6 筆(row 數變了
Non-repeatable vs Phantom 的差異

  • Non-repeatable:同一 row 的值被改了
  • Phantom:新增或刪除 row,使範圍查詢結果集改變


四種隔離等級

隔離等級 Dirty Read Non-repeatable Phantom 預設於
Read Uncommitted ✅ 可能 ✅ 可能 ✅ 可能 幾乎不用
Read Committed ✅ 可能 ✅ 可能 PostgreSQL
Repeatable Read ✅ 可能 MySQL InnoDB
Serializable 金融關鍵交易
庫存超賣的解法不是 Serializable

超賣本質是 Lost Update(不是 Phantom Read)。最簡單解法是原子 UPDATE:

UPDATE products SET qty = qty - 1 WHERE id = 1 AND qty > 0;

一條 SQL 搞定,不需要動到隔離等級。


MVCC(Multi-Version Concurrency Control)

現代資料庫(PostgreSQL、MySQL InnoDB)實現隔離的核心機制。

核心思想:對同一份資料保存多個版本。讀取看的是 transaction 開始時的快照,寫入產生新版本。

帳戶餘額歷史版本:
  v1: 1000  (T1 commit)
  v2: 500   (T2 commit)

T3 在 T2 commit 之前開始 → 看到 v1 (1000)
T4 在 T2 commit 之後開始 → 看到 v2 (500)

Lost Update(更新遺失)

ACID 三種讀取異常之外,最常見的並發問題。

餘額:1000
T1: 讀 1000 → 計算 1000 - 200 = 800 → 寫 800 commit
T2: 讀 1000 → 計算 1000 - 300 = 700 → 寫 700 commit ← 覆蓋 T1
結果應為 500,實際 700 → T1 的扣款消失

解法 A:Optimistic Locking(樂觀鎖)

帶版本號,不符就拒絕。適合衝突不常發生。

UPDATE accounts SET balance = 800, version = 2
WHERE id = 1 AND version = 1;
-- 如果 version 已被改,影響 0 筆 → application 重試

解法 B:Pessimistic Locking(悲觀鎖)

讀取就鎖住。較安全但降低並行性。

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 其他 FOR UPDATE 讀者會在這裡等
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
Pessimistic Locking 的 Deadlock 風險

多個 transaction 以不同順序鎖資源 → 可能死鎖。
解法:所有 transaction 用一致順序獲取鎖(例:永遠先鎖 account_id 小的)。
資料庫的 deadlock detection 會自動 rollback 其中一方,application 需處理重試


分散式 Transaction

單一資料庫的 ACID 保證無法跨越服務邊界。微服務拆庫之後,建訂單 + 扣庫存在不同 DB,沒辦法一個 BEGIN/COMMIT 包起來。

Two-Phase Commit(2PC)

由 coordinator 確保所有節點都同意 commit。

Phase 1 (Prepare):coordinator 問所有節點「準備好 commit 了嗎?」
  → 每節點寫好資料但不 commit,回 Yes/No
Phase 2 (Commit/Abort):所有 Yes → 發 Commit;任一 No → 全部 Abort
2PC 的致命傷

如果 coordinator 在 Phase 2 發出 Commit 前崩潰,部分節點會永遠卡在「已 prepare 未 commit」,需要人工介入。所以 2PC 在高可用場景不適用

Saga Pattern — 現代微服務主流

把分散式 transaction 拆成一系列本地 transaction + 補償操作

建立訂單流程:
  1. 訂單服務:建立訂單記錄(本地 commit)
  2. 庫存服務:扣減庫存(本地 commit)
  3. 付款服務:扣款(本地 commit)

如果步驟 3 失敗:
  - 補償 2:把庫存加回去
  - 補償 1:訂單標記為取消
Saga vs 2PC 一句話比較

2PC:強一致 + 脆弱可用性;Saga:最終一致 + 高可用。現代微服務絕大多數選 Saga


系統設計面試怎麼談 Transaction

主動帶出 Transaction 的時機

說清楚隔離等級的選擇

別只說「我用 transaction」,要說選哪個 + 為什麼

「讀用戶 profile 這種,Read Committed 就夠,不犧牲效能」
「庫存扣減本質是 Lost Update,用原子 UPDATE 一條 SQL 就解決,不需要 Serializable」

跨服務情境:坦誠取捨

「訂單和庫存用不同 DB,跨服務強一致很難保證,2PC 在高可用場景不適合。我會用 Saga,每個服務做本地 transaction,失敗就執行補償,接受最終一致性。」

主動點出 Deadlock 風險

「用 FOR UPDATE 時,不同 transaction 以不同順序鎖資源可能 deadlock。解法是一致鎖序(永遠先鎖 ID 小的)。DB deadlock detection 會自動 rollback 一方,application 層需要處理重試。」