訂位系統如何避免超賣

後端 更新於 Oct 9, 2026

問題

在高併發的訂購系統裡(秒殺、搶票、飯店訂房),多個請求可能同時讀到同一個剩餘數量,然後全部往下走 —— 結果就是超賣/超訂。

text
Thread A: reads stock=1 → proceeds
Thread B: reads stock=1 → proceeds   ← race condition
Both confirm → stock goes to -1

幾種做法

1. 原子遞減 DECR(Redis)

用 Redis 的 DECR 或 DECRBY —— 它本身就是原子操作,不需要鎖。

lua
-- Single command, atomic
result = redis.call('DECR', 'stock:item_123')
if result < 0 then
    redis.call('INCR', 'stock:item_123')  -- rollback
    return 0  -- out of stock
end
return 1  -- success

流程:

text
Client → Redis DECR → if result >= 0: proceed to DB write
                     if result < 0:  rollback INCR, reject

**優點:**吞吐量極高、延遲極低、沒有鎖競爭
**缺點:**只能處理單純的邏輯 —— 無法限制每人數量,也做不了複雜條件
**適用場景:**秒殺、單純的庫存扣減(例如「100 個,先搶先贏」)


2. Lua 腳本(Redis)

把多步驟的檢查放進 Redis 裡原子地執行。Lua 腳本會被當成單一個原子操作跑完 —— 其他指令插不進來。

lua
-- lua script: limit 1 booking per user
local stock_key  = KEYS[1]   -- e.g. "stock:event_42"
local user_key   = KEYS[2]   -- e.g. "booked:event_42:user_99"

-- check if user already booked
if redis.call('EXISTS', user_key) == 1 then
    return -1  -- duplicate booking
end

-- check and decrement stock
local stock = tonumber(redis.call('GET', stock_key))
if stock <= 0 then
    return 0  -- out of stock
end

redis.call('DECR', stock_key)
redis.call('SET', user_key, 1, 'EX', 86400)  -- mark user as booked (TTL 1 day)
return 1  -- success

Java 呼叫方式:

java
String lua = "...";  // script above
List<String> keys = Arrays.asList("stock:event_42", "booked:event_42:" + userId);
Long result = jedis.eval(lua, keys, Collections.emptyList());
// -1 = duplicate, 0 = sold out, 1 = success

**優點:**多重條件一次原子檢查,可以處理「同一使用者去重」
**缺點:**腳本執行期間 Redis 是單執行緒,所以腳本要短;也比較難除錯
適用場景:「每人限一份」、優惠券兌換、活動報名


3. 分散式鎖(Redis / Zookeeper)

進入臨界區之前先拿鎖,確保同一時間只有一個行程在跑訂購邏輯。

java
String lockKey = "lock:booking:event_42";
String lockValue = UUID.randomUUID().toString();
int expireMs = 3000;

// try acquire lock (SET NX PX)
boolean acquired = redis.set(lockKey, lockValue, "NX", "PX", expireMs) != null;
if (!acquired) {
    throw new BusyException("System busy, please retry");
}

try {
    // safe to run complex logic now
    int stock = db.queryStock(eventId);
    if (stock <= 0) throw new SoldOutException();

    boolean alreadyBooked = db.hasBooked(userId, eventId);
    if (alreadyBooked) throw new DuplicateException();

    db.deductStock(eventId);
    db.createOrder(userId, eventId);
} finally {
    // release lock only if we still own it (Lua for atomicity)
    String releaseLua = "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";
    redis.eval(releaseLua, Collections.singletonList(lockKey), Collections.singletonList(lockValue));
}

**優點:**彈性最大 —— 任意商業邏輯、可跨多個資料庫的交易
**缺點:**吞吐量被序列化(同時只有一個持鎖者)、鎖的逾時需要調、持鎖者中途掛掉會有風險
**適用場景:**驗證邏輯複雜的訂房/訂機票、要寫多張表、付款流程


三種做法的比較

做法 吞吐量 複雜度 最適合
原子 DECR 極高 低 高併發秒殺(單純庫存)
Lua 腳本 高 中 有邏輯條件的訂購(例如每人限一份)
分散式鎖 中 高 跨多個資料庫的複雜商業邏輯

分層架構(實務做法)

實務上是三層一起用:

text
Request
  │
  ▼
[Rate Limiter]          ← reject obvious abuse early
  │
  ▼
[Redis Lua Script]      ← fast atomic pre-check (stock + dedup)
  │ success
  ▼
[DB Optimistic Lock]    ← final guard with version/CAS
  │
  ▼
[Order Created]

資料庫的樂觀鎖當成最後一道防線:

sql
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE item_id = ? AND version = ? AND stock > 0;
-- if affected rows = 0 → lost the race, retry or reject

這個組合讓 99% 的請求享受 Redis 的速度,同時由資料庫擋掉 Redis 出狀況時的極端超賣(例如 DECR 完、還沒寫進資料庫就當機)。


常見的坑

  • 忘記回補:Redis 已經 DECR、但資料庫寫入失敗時,要把 Redis 的庫存加回去 —— 不然庫存會一直漏。
  • 鎖的有效期設太短:商業邏輯跑得比鎖的 TTL 還久,另一條執行緒就會在你還在跑的時候拿到鎖 → 用非同步 heartbeat 續約,或把 TTL 拉長。
  • Redis cluster:Lua 腳本與原子操作都是以 slot 為單位;如果庫存 key 和使用者 key 被雜湊到不同 slot,要用 hash tag {event_42}:stock / {event_42}:user:99 把它們放在同一個 slot。
  • 搶鎖的驚群效應:搶不到鎖時,重試前加一點隨機抖動(jitter),避免一窩蜂同時再撲上來。