電商庫存扣減的問題:
# 偽碼
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,不需要任何鎖 |