資料庫交易 (Database Transactions)
Transaction 把一組相關操作包成一個邏輯單元,保證全部成功或全部失敗。是資料庫可靠性的核心。
為什麼需要 Transaction
真實場景幾乎沒有「一步就完成」的操作:
- 轉帳:A 扣款 + B 加款,缺一不可
- 下訂單:建訂單 + 扣庫存 + 產生付款請求
沒有 transaction,任何一步失敗就會留下不一致狀態(A 扣了沒加給 B),生產環境真實 bug。
ACID 四個保證
Atomicity(原子性)
一個 transaction 的所有操作,要麼全部成功、要麼全部失敗。不存在中間狀態。
實作:WAL(Write-Ahead Log) —— 真正寫資料前,先把「打算做什麼」記到 log。崩潰重啟時根據 log 判斷該繼續還是 rollback。
Consistency(一致性)
Transaction 執行完,資料庫必須從一個合法狀態轉到另一個合法狀態。所有 constraint 在 transaction 前後成立。
- 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:同一 row 的值被改了
- Phantom:新增或刪除 row,使範圍查詢結果集改變
四種隔離等級
| 隔離等級 | Dirty Read | Non-repeatable | Phantom | 預設於 |
|---|---|---|---|---|
| Read Uncommitted | ✅ 可能 | ✅ 可能 | ✅ 可能 | 幾乎不用 |
| Read Committed | ❌ | ✅ 可能 | ✅ 可能 | PostgreSQL |
| Repeatable Read | ❌ | ❌ | ✅ 可能 | MySQL InnoDB |
| 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)
- ✅ 讀寫可並行,不互相阻擋
- 🧹 舊版本由 GC 在不需要時清理(PostgreSQL 的 vacuum)
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;
多個 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
如果 coordinator 在 Phase 2 發出 Commit 前崩潰,部分節點會永遠卡在「已 prepare 未 commit」,需要人工介入。所以 2PC 在高可用場景不適用。
Saga Pattern — 現代微服務主流
把分散式 transaction 拆成一系列本地 transaction + 補償操作。
建立訂單流程:
1. 訂單服務:建立訂單記錄(本地 commit)
2. 庫存服務:扣減庫存(本地 commit)
3. 付款服務:扣款(本地 commit)
如果步驟 3 失敗:
- 補償 2:把庫存加回去
- 補償 1:訂單標記為取消
- ✅ 高可用、容錯佳
- ⚠️ 放棄強一致性,換最終一致性
2PC:強一致 + 脆弱可用性;Saga:最終一致 + 高可用。現代微服務絕大多數選 Saga。
系統設計面試怎麼談 Transaction
主動帶出 Transaction 的時機
- 多步驟原子操作:「建訂單需要同時扣庫存和新增訂單記錄,我會包進一個 transaction」
- 防止並發更新錯誤:「多人搶最後庫存,我會在讀取時
SELECT FOR UPDATE防超賣」 - 資料一致性要求高:「金融系統的帳戶餘額操作用 Serializable」
說清楚隔離等級的選擇
別只說「我用 transaction」,要說選哪個 + 為什麼:
「讀用戶 profile 這種,Read Committed 就夠,不犧牲效能」
「庫存扣減本質是 Lost Update,用原子 UPDATE 一條 SQL 就解決,不需要 Serializable」
跨服務情境:坦誠取捨
「訂單和庫存用不同 DB,跨服務強一致很難保證,2PC 在高可用場景不適合。我會用 Saga,每個服務做本地 transaction,失敗就執行補償,接受最終一致性。」
主動點出 Deadlock 風險
「用 FOR UPDATE 時,不同 transaction 以不同順序鎖資源可能 deadlock。解法是一致鎖序(永遠先鎖 ID 小的)。DB deadlock detection 會自動 rollback 一方,application 層需要處理重試。」
Related Notes
- 02-Distributed-Systems/02-CAP-Theorem — ACID-C 和 CAP-C 的關鍵差異
- 05-Database-Advanced/02-Sharding — 分片打破 ACID,跨 shard 操作的解法
- 05-Database-Advanced/03-Replication — Read-after-write 一致性與 transaction 配合
- 03-API-Design/02-REST — Idempotency-Key 與 transaction 重試