「轉帳是最經典的資料庫問題:從 A 帳戶扣 1000 元,存入 B 帳戶。 如果扣款成功但存入失敗,錢就憑空消失了。交易(Transaction)就是用來防止這件事的。」
交易是資料庫中「一組操作的原子單位」。ACID 定義了交易的四個保證, 而理解隔離層級與鎖定機制,才能在高並發系統中寫出正確的程式。
什麼是交易(Transaction)?
交易是一組 SQL 操作的集合,這組操作要麼全部成功,要麼全部回滾,不會有「做一半」的中間狀態。
ACID:交易的四個保證
Atomicity(原子性)
交易中的所有操作,要麼全部完成,要麼全部不做。不存在「做一半」的狀態。
📌 轉帳:扣款和入帳是原子的,不可能只發生其中一個。
Consistency(一致性)
交易執行前後,資料庫必須保持一致性狀態(滿足所有定義的規則與約束)。
📌 帳戶餘額不能為負數(CHECK constraint),轉帳前後兩帳戶的總金額不變。
Isolation(隔離性)
並發執行的多個交易之間互不干擾,每個交易都感覺像是在「獨占」資料庫。
📌 兩個使用者同時購買最後一件商品,系統必須確保只有一個人成功。
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 才完全防止。
鎖定(Lock):隔離的實作機制
隔離層級的背後,是鎖定機制在保護資料。理解鎖定有助於排查並發問題和死鎖。
共享鎖(Shared Lock / S Lock)
讀取時獲取。多個交易可以同時持有同一列的共享鎖(多讀不互斥)。
SELECT ... FOR SHARE排他鎖(Exclusive Lock / X Lock)
寫入時獲取。只有一個交易能持有,其他交易無論讀寫都必須等待。
SELECT ... FOR UPDATE死鎖(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 錯誤並重試:死鎖是正常現象,應用層要能捕捉並自動重試交易。