電商庫存扣減的問題:

# 偽碼
def purchase(product_id, quantity):
    stock = db.get_stock(product_id)    # 取得現有庫存
    if stock >= quantity:
        db.set_stock(product_id, stock - quantity)  # 扣減
        create_order(...)

兩個請求同時進來,都讀到 stock = 10,都檢查通過,兩個都把庫存設成 10 - 1 = 9。你的庫存只被扣了一次,但訂單建了兩筆。

這是 race condition——結果取決於執行的時序,不是程式的邏輯。

這個問題在所有並行系統裡都存在,解法有幾種不同的哲學。


哲學一:不共享(Share Nothing)

最根本的解法:如果兩個執行單元沒有共享任何資料,就沒有衝突。

Actor model(Erlang/Elixir)的思路:每個 actor 管理自己的狀態,外部只能透過訊息存取,actor 本身是單 thread 處理訊息的。庫存管理是一個 actor,庫存的讀寫只在這個 actor 裡發生,沒有並發問題。

Go 的 channel 慣用法

// 用一個 goroutine 獨占管理庫存
func inventoryManager(ch <-chan Request) {
    stock := 100
    for req := range ch {
        if stock >= req.Quantity {
            stock -= req.Quantity
            req.Response <- true
        } else {
            req.Response <- false
        }
    }
}

一個 goroutine 管庫存,所有請求透過 channel 排隊,不可能有兩個請求同時修改 stock


哲學二:鎖(Mutex)

允許共享狀態,但任何時候只讓一個執行單元修改。

var mu sync.Mutex
var stock = 100
 
func purchase(quantity int) bool {
    mu.Lock()
    defer mu.Unlock()
    if stock >= quantity {
        stock -= quantity
        return true
    }
    return false
}

mu.Lock() 讓第二個 goroutine 在第一個完成之前等待。

Deadlock 的風險:兩個 goroutine 各自持有一把鎖,又在等對方的鎖:

Goroutine A: 持有 lock1,等 lock2
Goroutine B: 持有 lock2,等 lock1
→ 永遠等下去

避免 deadlock 的規則:

  • 鎖的持有時間越短越好
  • 多個鎖要按固定順序取得(lock ordering)
  • 用 channel 代替 mutex(更難出現 deadlock)

Read-Write Lock(RWMutex)

var rwmu sync.RWMutex
 
// 讀取:多個 goroutine 可以同時讀
func getStock() int {
    rwmu.RLock()
    defer rwmu.RUnlock()
    return stock
}
 
// 寫入:獨占
func setStock(n int) {
    rwmu.Lock()
    defer rwmu.Unlock()
    stock = n
}

讀多寫少的場景,RWMutex 比 Mutex 效率高很多。


哲學三:原子操作(Atomic)

對於單一數值的讀寫,CPU 提供了不需要鎖的原子指令:

import "sync/atomic"
 
var stock int64 = 100
 
func tryPurchase(quantity int64) bool {
    for {
        current := atomic.LoadInt64(&stock)
        if current < quantity {
            return false
        }
        // Compare-And-Swap:如果 stock 還是 current,就把它設為 current - quantity
        if atomic.CompareAndSwapInt64(&stock, current, current - quantity) {
            return true
        }
        // 失敗了(stock 被別人改了),重試
    }
}

CAS(Compare-And-Swap):如果記憶體的值和預期一樣,才寫入新值。如果別人在我讀和我寫之間改了值,CAS 失敗,我重試。

原子操作沒有鎖的 overhead,在低衝突場景比 mutex 快。但它只適合單一數值的操作——複雜的多步驟操作還是要用 mutex。


哲學四:設計成衝突不可能發生

有些場景可以重新設計資料結構,讓衝突在邏輯上不可能出現。

Immutable data(不可變資料):如果資料一旦建立就不修改,沒有任何寫操作,就沒有 race condition。函數式語言(Haskell、Erlang)大量依賴這個。

CRDT(Conflict-free Replicated Data Types)

設計一種資料結構,讓來自不同副本的修改可以自動合併,不需要協調:

  • G-Counter(只增的計數器):每個副本維護自己的計數,合併時取每個副本的最大值,永遠不衝突
  • LWW-Register(Last Write Wins):每個寫操作帶時間戳,合併時取最新的

Riak、Redis 的 CRDT 功能、Apple 的 Notes 同步——都用了 CRDT。


資料庫層的並行衝突

回到庫存問題,在資料庫層有兩個常見解法:

樂觀鎖(Optimistic Locking)

-- 讀取時記下 version
SELECT stock, version FROM inventory WHERE product_id = ?;
 
-- 更新時確認 version 沒變
UPDATE inventory
SET stock = stock - ?, version = version + 1
WHERE product_id = ? AND version = ?;
-- 如果 affected_rows = 0,表示有人搶先改了,重試

適合衝突率低的場景(衝突不常發生,重試成本可接受)。

悲觀鎖(Pessimistic Locking)

BEGIN;
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;  -- 鎖住這行
-- 其他 transaction 在這裡等
UPDATE inventory SET stock = stock - ? WHERE product_id = ?;
COMMIT;

FOR UPDATE 讓這個 row 在 transaction 結束前不能被其他 transaction 讀或寫。適合衝突率高的場景。


選策略的判斷依據

場景推薦策略
衝突極少,重試成本低樂觀鎖 / CAS
衝突頻繁,資料一致性嚴格悲觀鎖 / Mutex
狀態自然屬於某個實體Actor / channel 獨占
分散式、最終一致可接受CRDT
資料只讀Immutable,不需要任何鎖

下一篇:錯誤處理模式比較