訂位系統如何避免超賣
問題
在高併發的訂購系統裡(秒殺、搶票、飯店訂房),多個請求可能同時讀到同一個剩餘數量,然後全部往下走 —— 結果就是超賣/超訂。
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),避免一窩蜂同時再撲上來。