🎯 什麼情境該想到我

當多個請求同時讀寫同一批資料、出現「錯亂/覆蓋/看到中間狀態」的併發問題時。

⚙️ 怎麼用(依你要防的異常挑隔離等級)

  • 讀已提交(Read Committed):防髒讀、髒寫。最低堪用等級。
  • 快照隔離 / 可重複讀(Snapshot Isolation):整個交易看同一份快照(MVCC),防不可重複讀。
  • 可序列化(Serializable):最強,行為等同交易一個個依序執行,防所有異常(含寫偏斜 write skew / 幻讀)。實作:真序列化、2PL、或 SSI。

步驟:先想清楚你要防哪種併發異常,再選剛好夠的等級(越強越慢)。

★ 四種異常與對應的防線(背這張表)

異常長什麼樣Read CommittedSnapshot / RRSerializable
髒讀讀到別人未提交的資料✅ 防✅✅
不可重複讀同一交易內讀兩次,值不同❌✅ 防✅
幻讀同一查詢,第二次多出幾列❌⚠️ 看實作✅ 防
★ 寫偏斜 write skew兩個交易各自讀了對方要改的東西,各自的決定都合法,合起來違反不變式❌❌ 防不了✅ 防

★ 寫偏斜:最陰險的一種(快照隔離擋不住)

規則:任何時刻至少要有一位醫生值班。目前 Alice 和 Bob 都值班。

交易 A(Alice 請假)          交易 B(Bob 請假)
  SELECT count(*) → 2  ✅       SELECT count(*) → 2  ✅
  「還有 2 個,我可以走」        「還有 2 個,我可以走」
  UPDATE 自己 off_duty          UPDATE 自己 off_duty
  COMMIT                        COMMIT
                    ★ 結果:0 個醫生值班

★ 兩個交易改的是「不同的列」,所以沒有寫衝突——
快照隔離只偵測「寫同一列」,偵測不到「讀了一個會被對方改掉的集合」。

三種修法:

方法做法
① 升到 SerializablePostgreSQL 的 SSI 會偵測到並讓其中一個失敗(★ 必須寫重試邏輯)
② 明確加鎖SELECT ... FOR UPDATE 把讀到的列鎖住(★ 但鎖不住「還不存在的列」)
③ ★ 實體化衝突建一張代表「衝突對象」的表(例如每個班次一列),把「集合的競爭」變成「單列的競爭」

★ 實務上最重要的一件事:各家的命名不一致

標準 SQLPostgreSQL 實際行為MySQL (InnoDB)
READ COMMITTED✅ 一致(預設)一致
REPEATABLE READ★ 實際上是 Snapshot Isolation(比標準強,防幻讀)★ 預設;用 gap lock 防幻讀,但仍有寫偏斜
SERIALIZABLE★ SSI(樂觀,衝突時 abort)2PL(悲觀,會鎖)
READ UNCOMMITTED★ 等同 READ COMMITTED(PG 根本不實作髒讀)真的允許髒讀

★ 「我用的是 REPEATABLE READ」這句話在 PG 和 MySQL 是不同的意思。
換資料庫時這是最容易出事的地方——一定要查你那一家的文件,不要憑標準 SQL 的名字推論。

★ 樂觀 vs 悲觀:怎麼選

-- 悲觀:先鎖住(適合衝突頻繁、交易短)
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;   -- ★ 其他人要等
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
 
-- ★ 樂觀:不鎖,靠版本號偵測(適合衝突罕見、交易長)
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = 1 AND version = ?;    -- ★ 一定要檢查 rows affected!
-- rows affected = 0 → 被別人改過了 → 重試
悲觀鎖★ 樂觀鎖
適合衝突頻繁、交易短衝突罕見、交易長、跨請求
代價阻塞、死鎖風險要寫重試邏輯
跨 HTTP 請求❌ 不可行(不能握著交易等使用者)★ 唯一可行的做法

→ 完整說明見 樂觀離線鎖、工具-狀態變數的設計訣竅。

★ 單一 UPDATE 通常就夠了

-- ❌ 讀-改-寫:有競態
SELECT balance FROM accounts WHERE id=1;   -- 100
UPDATE accounts SET balance = 90 WHERE id=1;
 
-- ✅ 讓資料庫做運算:原子的,不需要任何隔離等級的幫忙
UPDATE accounts SET balance = balance - 10 WHERE id=1 AND balance >= 10;

★ 能用一句原子的 UPDATE 解決,就不要用交易 + 鎖。 這是最便宜也最可靠的做法。

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 隔離等級只管單一資料庫內部。跨服務、跨資料庫的一致性是完全不同的問題(見 工具-分散式事務的取捨)。
  • ★ Serializable 一定要寫重試。PG 的 SSI 會回 40001 serialization_failure——
    沒有重試邏輯就等於把錯誤丟給使用者。 重試要有上限與退避。
  • ★ 樂觀鎖一定要檢查 rows affected。UPDATE ... WHERE version = ? 影響 0 列代表衝突,
    不檢查就是靜默地丟失更新——這是很難發現的資料錯誤。
  • 長交易是萬惡之源:持鎖久、阻塞別人、PG 裡會讓 vacuum 無法清理舊版本(表膨脹)。
    交易裡不要有網路呼叫、不要等使用者輸入。
  • 死鎖是正常現象不是 bug。資料庫會偵測並殺掉其中一個——應用端必須能重試。
    降低機率的做法:所有交易都用同樣的順序存取資源。
  • ORM 會隱藏交易邊界。要知道你的框架什麼時候開/關交易,以及 lazy loading 會不會在交易外觸發查詢。
  • 隔離等級越強越慢,但**「慢」的形態不同**:悲觀鎖是阻塞,SSI 是 abort + 重試。要量的是 goodput 不是單次延遲。

🔗 相關工具