處理競爭 (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-Lock09-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 就夠時別硬上,避免新故障模式