處理競爭 (Dealing with Contention)

一句話定位

Contention(競爭)是多個 process 同時搶同一個資源,沒協調好就會 race condition、超賣、重複扣款。
核心心法是一條複雜度遞進:單一 DB(pessimistic / optimistic)→ 不行才上分散式協調(2PC / Saga / distributed lock)。能塞進同一個 database 就別走分散式,現代 PostgreSQL 撐幾十 TB、幾千連線,已覆蓋絕大多數系統。

問題長什麼樣(The Weeknd 最後一張票)

剩 1 個座位,Terry 和 Bohr 同一瞬間按下「購買」:

T0  Terry 讀取:剩 1 個座位
T0  Bohr  讀取:剩 1 個座位      ← 兩個讀取都在任何寫入之前完成
T1  Terry 判斷 1 ≥ 1 → 進付款
T1  Bohr  判斷 1 ≥ 1 → 進付款
T2  Terry 扣 $500,seats = 0
T2  Bohr  扣 $500,seats = -1
T3  兩人都收到確認信,座位號一模一樣 → 現場吵架 + 退款 + 兩個怒客

Race condition 的根本原因:讀取與寫入不是原子的。在「讀當前狀態」和「依狀態做更新」之間有一個時間窗(記憶體微秒、網路毫秒),那個窗裡一切都可能變。

規模放大窗口

窗口再小,10,000 並發打同一個 row 也會製造大量衝突。規模再大還要跨節點協調 —— 複雜度又一個量級。要做對就需要某種同步機制。


解法地圖(複雜度由低到高)

                能塞進同一個 database 嗎?
                          │
            ┌─────────────┴─────────────┐
           可以                          不行(已 shard / 跨服務)
            │                              │
      看 contention 頻率              分散式協調(昂貴、脆弱,最後手段)
      ┌─────┴─────┐                  ┌────┬──────┬──────────┐
   高頻         低頻               2PC   Saga   Distributed Lock
 Pessimistic  Optimistic        強一致  韌性    UX/簡單協調
  Locking     Concurrency

四層工具,先把單一 DB 的解法用盡再往下走:Atomicity(transaction)→ Pessimistic Locking / Isolation Level → Optimistic Concurrency → 分散式協調。


第一層:Atomicity(transaction)— 必要但不夠

Atomicity = 一組操作全部成功或全部失敗,沒有部分完成。Transaction 是 DB 提供 atomicity 的方式(BEGIN → 操作 → COMMIT / ROLLBACK)。

BEGIN TRANSACTION;
  UPDATE concerts SET available_seats = available_seats - 1
    WHERE concert_id = 'weeknd_tour';          -- 扣座位
  INSERT INTO tickets (user_id, concert_id, seat_number, purchase_time)
    VALUES ('user123', 'weeknd_tour', 'A15', NOW());  -- 建票券
COMMIT;

任何一步失敗(餘額不足、DB 崩潰)整筆 rollback,不會「座位扣了票券沒建」。ACID 的「A」就是 Atomicity,NoSQL(MongoDB multi-document、DynamoDB transaction)與分散式 SQL(CockroachDB)也都支援。

Atomicity 本身擋不住 race condition

Transaction 只保證自身內部原子,擋不住別的 transaction 同時讀到一樣的資料。Terry 和 Bohr 可以各自開 transaction、都讀到 available_seats >= 1、各自 UPDATE,兩個都成功 → 1 個座位賣出 2 張票。需要協調機制。


第二層 A:Pessimistic Locking(悲觀鎖)

對衝突悲觀:假設衝突一定發生,所以事先取得 lock。

BEGIN TRANSACTION;
  SELECT available_seats FROM concerts
    WHERE concert_id = 'weeknd_tour'
    FOR UPDATE;                  -- ← 讀取前就對這一行取得排他鎖
  UPDATE concerts SET available_seats = available_seats - 1
    WHERE concert_id = 'weeknd_tour';
  INSERT INTO tickets (...) VALUES (...);
COMMIT;                          -- ← 釋放 lock

Terry 跑這段時,Bohr 的相同 transaction 會卡在 SELECT ... FOR UPDATE,直到 Terry commit。確保同一時間只有一個人能「讀取並更新」。

Terry: ──[獲鎖]──讀──更新──commit[釋鎖]──▶
Bohr:        └─[等鎖…………阻塞…………]──[獲鎖]──讀(seats=0)──失敗──▶
鎖的效能鐵律:鎖最少的 row、持有最短的時間

鎖整張表會殺死並發;持有 lock 幾秒而非幾毫秒就製造瓶頸。範例只在購票過程短暫鎖一個 concert row。Lock 是阻止其他 connection 存取該資料直到釋放的機制;PostgreSQL/MySQL 能撐幾千並發連線,但 lock 保證同時只有一個連線能改特定 row。


第二層 B:Isolation Level(隔離等級)

不用明確 FOR UPDATE,改讓 DB 自動處理衝突 —— 調高 isolation level。它控制並發 transaction 之間能看到多少彼此的變更。這四個是平行選項,不是進階程度:

Isolation Level 看得到什麼 擋得住門票 race? 備註
READ UNCOMMITTED 別人未 commit 的變更(髒讀) ❌ 極少使用
READ COMMITTED 只看已 commit 的變更 ❌ PostgreSQL 預設
REPEATABLE READ 同 transaction 內多次讀結果一致 ❌ MySQL 預設
SERIALIZABLE transaction 看起來像逐一執行 ✅ 最強,DB 自動偵測並中止
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  UPDATE concerts SET available_seats = available_seats - 1
    WHERE concert_id = 'weeknd_tour';
  INSERT INTO tickets (...) VALUES (...);
COMMIT;

SERIALIZABLE 下 DB 自動偵測衝突,兩個可能互相干擾就中止其中一個,被中止者必須重試。

預設 isolation 擋不住超賣

READ COMMITTED / REPEATABLE READ 都允許門票 race —— Terry 與 Bohr 可同時讀到「剩 1 個座位」再各自更新。要靠 isolation 解,必須 SERIALIZABLE。

取捨:SERIALIZABLE 比明確 lock 貴得多 —— DB 要追蹤所有讀寫來偵測衝突,abort 又浪費要重做的工作。你確切知道哪些資源要協調時,明確 FOR UPDATE 更精準也更有效率。


第三層:Optimistic Concurrency Control(OCC,樂觀並發)

對衝突樂觀:假設衝突罕見,衝突後才偵測(compare-and-swap)。低 contention 下完全消除 locking 的 overhead —— transaction 不阻塞,全跑,只有真衝突的才重試。

做法:資料裡放一個版本號,更新時同時指定新值與期望的當前版本號。

-- Terry 讀:seats=1, version=42 ;Bohr 讀:seats=1, version=42
-- Terry 先更新:
UPDATE concerts SET available_seats = available_seats - 1, version = version + 1
  WHERE concert_id = 'weeknd_tour' AND version = 42;   -- 成功 → seats=0, version=43
-- Bohr 更新:
UPDATE concerts SET available_seats = available_seats - 1, version = version + 1
  WHERE concert_id = 'weeknd_tour' AND version = 42;   -- 版本已過期!影響 0 筆 → 偵測到衝突 rollback

Bohr 看到「0 筆受影響」就知道有人改過,可重讀、確認還有沒有座位、用新版本重試;售完就收到清楚的「已售完」而非莫名失敗。

版本號不必是獨立欄位 —— 用「每次更新都會變的自然欄位」

演唱會用 available_seats 本身、eBay 競標用當前最高出價、銀行轉帳用帳戶餘額、庫存系統用庫存數量。任何更新時會變的值都能當 OCC 的版本。

UPDATE concerts SET available_seats = available_seats - 1
  WHERE concert_id = 'weeknd_tour' AND available_seats = 1;  -- 期望的當前值
ABA 問題(用自然值當版本時的陷阱)

值從 A → B → 又回 A,你的 compare-and-swap 看到「還是 A」以為什麼都沒變,其實漏掉了中間的狀態轉換。解法:用一個你知道永遠單調遞增的欄位(如 review_count),不要用會繞回來的值。詳見下方 Deep Dive。

最適合:衝突不常發生。多數電商兩人同一瞬間買同一件的機率很低,偶爾重試比 pessimistic 的持續 overhead 划算得多。


第四層:多節點解法(跨 database 協調)

黃金法則:盡一切可能把有 contention 的資料放在同一個 database

十次有九次完全可行,能避開整個分散式協調。只有已 shard 或跨服務才被迫進這層。

情境:Terry 帳戶在 Database A,Bohr 帳戶在 Database B(已 shard),轉帳需要 A 扣 $100、B 加 $100 同時成功或同時失敗,否則錢憑空消失。三種選項:

4-1 Two-Phase Commit(2PC,兩階段提交)

你的 transfer service 當 coordinator,管理跨 participant 的 transaction:

Phase 1 (PREPARE):Coordinator 問所有 participant「準備好 commit 了嗎?」
  Database A: BEGIN; 確認 Terry 餘額>=100; hold $100; 不 commit(鎖住等指令)
  Database B: BEGIN; 確認 Bohr 帳戶存在; 準備加 $100; 不 commit(鎖住等指令)
Phase 2 (COMMIT/ABORT):全部 yes → 通知 commit;任一 no → 全部 rollback
2PC 昂貴又脆弱

  • 持有開啟中的 transaction 跨網路呼叫非常危險:那些 transaction 鎖住帳戶 row,擋掉所有其他操作。
  • Coordinator 必須先寫持久化 log(記哪些 participant 參與、transaction 當前狀態),否則崩潰後 participant 不知該 commit 還 abort → 永遠開著、永遠鎖住。
  • 生產系統加 30–60 秒 timeout 自動 rollback,但合法慢操作可能被誤殺。
  • 任一 DB 變慢/不可達整筆卡住;network partition 可能進入不一致。

4-2 Saga Pattern(補償交易)

不追求原子,把操作拆成一系列獨立步驟,每步可被「補償」(compensate)撤銷。每步都是完整、立刻 commit 的 transaction —— 沒有長開 transaction、沒有 coordinator 崩潰的不確定狀態。

Step1: Database A 扣 Terry $100,立刻 commit
Step2: Database B 加 Bohr $100,立刻 commit
Step3: 發送確認通知
       │
  若 Step2 失敗 → 補償 Step1:把 $100 存回 Terry
  若 Step3 失敗 → 補償 Step2(從 Bohr 扣回)+ 補償 Step1(存回 Terry)
Saga 的取捨:執行過程中暫時不一致

Step1 完成後 Terry 被扣、Bohr 還沒入帳 —— 別的 process 在這窗口會看到總金額暫時少了 $100。這種 eventual consistency 正是 saga 可行的原因:用短暫不一致換掉 2PC 的脆弱性。應用要能理解中間狀態(例如把轉帳顯示為「處理中」)。

4-3 Distributed Lock(分散式鎖)

較簡單的協調需求:不協調複雜 transaction,只確保全系統同時只有一個 process 能操作特定資源。轉帳前先取得 Terry 與 Bohr 帳戶 ID 的 lock。

實作 機制 / 清理 優點 缺點
Redis + TTL SET NX + 過期;TTL 到自動移除(NX 是關鍵) 快、簡單 Redis 單一故障點,需處理不可用
Database column status + expiration 欄位;背景工作清理過期 與現有資料一致、免額外設施 比 cache 慢、要自己維護清理邏輯
ZooKeeper / etcd ephemeral node 自動清理;consensus(ZAB/Raft) 健壯、撐複雜故障與分割 運維複雜,要另跑一個協調 cluster
Distributed Lock 還能用「預留機制」在 contention 發生前就預防它

不讓用戶競爭同一資源,而是創造中間狀態給暫時排他存取:

  • Ticketmaster 座位預留:選座立刻進「已預留」狀態 → contention 窗口從整個購票流程(5 分鐘)縮到只剩預留那一步(毫秒)。
  • Uber 把司機設 pending_request、電商把商品 hold 進購物車、會議室建立暫時 hold。

缺點:高 contention 下 distributed lock 本身可能成瓶頸,且要處理 lock timeout 與故障。

與 09-Messaging-Coord 的關係

Distributed lock 的健壯實作(fencing token、leader election、ephemeral node)細節見 09-Messaging-Coord/04-Distributed-Lock 與 09-Messaging-Coord/05-Zookeeper,本篇只談它在 contention 解法地圖裡的位置。


怎麼選(決策表)

情境 選擇 複雜度 典型延遲
單一 DB、高 contention Pessimistic Locking(FOR UPDATE) 低 低(單一 query)
單一 DB、低 contention Optimistic Concurrency(自然版本) 低 低(無衝突時)
需要 SERIALIZABLE 自動偵測 Isolation Level = SERIALIZABLE 低 中(偵測 + 重試)
多 DB、必須強一致原子 2PC 非常高 高(網路協調)
多 DB、要韌性可接受最終一致 Saga 高 高
面向用戶的競爭流程 Distributed Lock + 預留機制 中 低(簡單狀態更新)
不確定就從這裡開始

單一 database + pessimistic locking。簡單、可預測、最壞情況也妥當,之後永遠有機會改進。能解決問題的最簡單方案幾乎永遠是對的。


面試話術

最強的訊號:在被問之前就主動識別 contention

不要等面試官問。梳理非功能性需求、確定系統需要強一致性時就主動提出協調機制:

  • 「拍賣多個競標者同時搶 → 我用 OCC,以當前最高出價當版本,只接受更高的出價。」
  • 「票務我想避免用戶填完付款才發現座位被搶 → 我做帶 10 分鐘 timeout 的座位預留。」
  • 「帳戶已 shard 到不同 DB,跨 shard 轉帳 → 用 Saga 保證韌性。」

各題型的預設選擇:

面試題 主推方案
線上拍賣 OCC(當前最高出價當版本)+ 應用層「30 秒後結標」防最後一秒搶標
活動訂票 應用層座位預留(效益 > 單純 pessimistic lock)+ 10 分鐘過期
銀行 / 支付 先講 Saga(韌性),面試官要求嚴格一致才提 2PC
共乘派遣 司機設 pending_request(Redis+TTL 或 DB status 欄位 + 清理)
閃購 / 庫存 混用:庫存 OCC(庫存數當版本)+ 購物車「hold」減少結帳 contention
Yelp / 評論評分 OCC,用 review_count(單調遞增) 當版本,避免 ABA
不要過度設計

最常見的扣分:簡單 row lock 或 OCC 就夠卻硬上 distributed lock(Redis)。加元件 = 加複雜度 + 新故障模式。

  • 低 contention(只有管理員編商品描述)→ 基本 OCC + 重試就好
  • 單一用戶操作(個人 todo、私人文件、偏好設定)→ 根本沒 contention,不需協調
  • 讀多寫少 → 簡單 OCC 處理偶發寫衝突,別影響讀效能

常見 Deep Dive


自我測驗重點

問題 重點
Race condition 根因 讀取與寫入不原子,讀與寫之間有時間窗
Atomicity 為何不夠 只保證 transaction 內部,擋不住別人同時讀到一樣的資料
Pessimistic vs Optimistic 高 contention 用悲觀鎖;低 contention 用 OCC(自然欄位當版本)
預設 isolation 擋超賣嗎 不行,要 SERIALIZABLE(代價是貴 + 要重試)
OCC 版本號要選什麼 永遠單調遞增的欄位(如 review_count)避免 ABA
2PC vs Saga 2PC 強一致但脆弱(持鎖跨網路);Saga 韌性但暫時不一致
Deadlock 怎麼防 有序加鎖(一致 key 排序)+ timeout / 自動偵測當後備
Hot partition 怎麼解 先改變問題;真需強一致用 queue-based serialization 單 worker
什麼時候別用 distributed lock 簡單 row lock / OCC 就夠時別硬上,避免新故障模式