處理競爭 (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)也都支援。
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)──失敗──▶
鎖整張表會殺死並發;持有 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 自動偵測衝突,兩個可能互相干擾就中止其中一個,被中止者必須重試。
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; -- 期望的當前值
值從 A → B → 又回 A,你的 compare-and-swap 看到「還是 A」以為什麼都沒變,其實漏掉了中間的狀態轉換。解法:用一個你知道永遠單調遞增的欄位(如 review_count),不要用會繞回來的值。詳見下方 Deep Dive。
最適合:衝突不常發生。多數電商兩人同一瞬間買同一件的機率很低,偶爾重試比 pessimistic 的持續 overhead 划算得多。
第四層:多節點解法(跨 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
- 持有開啟中的 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)
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 |
不讓用戶競爭同一資源,而是創造中間狀態給暫時排他存取:
- Ticketmaster 座位預留:選座立刻進「已預留」狀態 → contention 窗口從整個購票流程(5 分鐘)縮到只剩預留那一步(毫秒)。
- Uber 把司機設
pending_request、電商把商品 hold 進購物車、會議室建立暫時 hold。
缺點:高 contention 下 distributed lock 本身可能成瓶頸,且要處理 lock timeout 與故障。
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。簡單、可預測、最壞情況也妥當,之後永遠有機會改進。能解決問題的最簡單方案幾乎永遠是對的。
面試話術
不要等面試官問。梳理非功能性需求、確定系統需要強一致性時就主動提出協調機制:
- 「拍賣多個競標者同時搶 → 我用 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
經典場景:Terry 轉 $100 給 Bohr,同時 Bohr 轉 $50 給 Terry。Transaction A 先鎖 Terry 再鎖 Bohr;Transaction B 先鎖 Bohr 再鎖 Terry → 互等對方釋放 = deadlock。根因是兩個 transaction 以不同順序取得 lock。
標準解法:有序加鎖(ordered locking) —— 永遠按一致的確定性 key(user ID / primary key)排序取得 lock,不管業務邏輯流程。要鎖 123 與 456 就永遠先鎖 123,即使業務先處理 456。所有 transaction 遵循同樣順序 → 沒有循環等待。
後備網:transaction timeout(等太久殺掉重試)+ DB 內建自動 deadlock 偵測(偵測循環就殺一個)—— 這是後備,不是主策略。
經典 2PC 故障:DB 帶著已 prepare 的 transaction 等指令,但 commit/abort 再也不來,鎖住資源無限阻塞。
解法:coordinator failover + transaction recovery —— 新 coordinator 啟動時讀持久化 log,確認進行中的 transaction 再把它們完成。企業級 transaction manager 多半自動處理,但你仍要設計 coordinator 高可用並維護 transaction 狀態。
Saga 在這方面更有韌性:它不跨網路呼叫持有 lock,coordinator 故障只是讓進度暫停,不會讓 participant 陷入不確定狀態。
Yelp 餐廳 4.0 星、100 則評論。同時進來一個 5 星、一個 3 星,兩者都讀到平均 4.0、各自算新平均。因為數學巧合最終平均可能還是 4.0、但現在該有 102 則 —— 若只用平均評分當版本,兩個更新都看到 4.0 都成功,漏掉一則評論。
解法:用一個你知道永遠會變(單調遞增)的欄位 —— 用 review_count 而非平均評分:
UPDATE restaurants SET avg_rating = 4.1, review_count = review_count + 1
WHERE restaurant_id = 'pizza_palace' AND review_count = 100; -- 期望當前 count
找不到自然欄位就退而加一個明確的 version 欄位,每次更新都遞增(不管業務資料有沒有實際變)。
名人加入 Twitter、稀有品 eBay 上架、Taylor Swift 開唱搶票 —— 需求集中單一點,一般擴展策略全失效:sharding 沒用(沒法把一場演唱會拆成多 DB)、load balancing 沒用(server 最終都打同一 row)、read replica 沒用(瓶頸在寫入)。
第一策略:質疑能不能改變問題本身,而不是砸基礎設施 —— 也許 10 件一樣的東西就拆 10 場拍賣;社群互動不需立即一致(追蹤數延遲幾秒沒人發現)。
真需要熱資源強一致 → queue-based serialization:把對該資源的所有請求丟進專用 queue,由單一 worker thread 循序處理,徹底消除 contention;queue 當緩衝吸收峰值。取捨是延遲(用戶等更久),但好過整個系統在 contention 下停擺。詳見 09-Messaging-Coord/02-Queue、10-Design-Patterns/02-Scaling-Writes。
自我測驗重點
| 問題 | 重點 |
|---|---|
| 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 就夠時別硬上,避免新故障模式 |
Related Notes
- 05-Database-Advanced/01-Transactions — Atomicity / ACID / isolation level 的本源,contention 第一層解法的基礎
- 05-Database-Advanced/02-Sharding — 一旦 shard,相關資料散落多 DB 才被迫進分散式協調
- 05-Database-Advanced/03-Replication — read replica 解不了寫入端 contention;leader 故障與 fencing 觀念相通
- 09-Messaging-Coord/04-Distributed-Lock — distributed lock 的健壯實作(Redis TTL / fencing token)細節
- 09-Messaging-Coord/05-Zookeeper — ZooKeeper/etcd 用 ephemeral node + consensus 提供強一致協調
- 09-Messaging-Coord/02-Queue — hot partition 的 queue-based serialization、單 worker 序列化
- 07-Caching-Storage/04-Redis — Redis
SET NX+ TTL 實作 distributed lock 的原子操作 - 06-Database-Tech/03-DynamoDB — DynamoDB transaction 與條件寫入(OCC)原生支援 contention 處理
- 10-Design-Patterns/02-Scaling-Writes — 寫入端 contention 與 load shedding / queue 緩衝的擴展模式
- 02-Distributed-Systems/02-CAP-Theorem — Saga 的最終一致性、2PC 在 partition 下的取捨即 CAP 體現
- 01-Networking/07-Fault-Handling — coordinator failover、timeout、重試與冪等的可靠性基礎