案例:限時搶購
幾十萬人同時搶一百件商品:不能超賣、也不能讓資料庫被打垮。用快取原子地扣庫存、用佇列把訂單的尖峰攤平。
流程
資料庫前面的每一層,都是為了不讓人潮直接撞上資料庫:CDN 擋住重新整理、限流擋住狂按、Redis 決定誰搶到、佇列讓資料庫只寫搶到的人。選「搶到」或「賣完了」一步步看。
亮起來的是這一步執行的程式碼
type BuyResult = "queued" | "sold-out" | "duplicate" | "throttled"; class FlashSale { private buyers = new Set<string>(); private hits = new Map<string, number>(); queue: string[] = []; orders = new Set<string>(); constructor(private stock: number, private perUserLimit = 3) {} buy(user: string): BuyResult { const hits = (this.hits.get(user) ?? 0) + 1; this.hits.set(user, hits); if (hits > this.perUserLimit) return "throttled"; if (this.buyers.has(user)) return "duplicate"; this.stock -= 1; if (this.stock < 0) { this.stock += 1; return "sold-out"; } this.buyers.add(user); this.queue.push(user); return "queued"; } // Wrong with more than one server: both can read 1 and both sell it. buyNaive(user: string): BuyResult { const left = this.stock; if (left <= 0) return "sold-out"; this.stock = left - 1; this.queue.push(user); return "queued"; } drain(): number { let written = 0; while (this.queue.length > 0) { const user = this.queue.shift()!; if (this.orders.has(user)) continue; this.orders.add(user); written++; } return written; } release(user: string): boolean { if (!this.orders.delete(user)) return false; this.buyers.delete(user); this.stock += 1; return true; }}扣庫存的方式
20,000
100
所有人都在開賣第一秒內按下購買。假設:資料庫讀和寫之間隔 2 毫秒、鎖住一列要 4 毫秒、等超過 500 毫秒就放棄、佇列每秒寫入 200 筆訂單。
賣出/庫存
4,017 / 100
超賣
3,917
等太久放棄
0
知道結果的時間(p99)
2.0 ms
資料庫查詢
24,017
資料庫尖峰每秒
39,240
100 件賣出了 4,017 件,超賣 3,917 件。只要兩個人在 2 毫秒內先後讀到庫存,兩人看到的是同一個數字、也都寫回減一後的值。而且 20,000 個人每一個都打到資料庫,尖峰每秒 39,240 次查詢。
模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 少量庫存與短時間到達流量,比較競爭、排隊和原子扣庫存;未涵蓋付款逾時、退貨、反機器人與跨區庫存。
什麼時候用
- 誰搶到,在記憶體裡用一個原子操作決定(Redis DECR 或 Lua 腳本);資料庫只負責記下結果。
- 用佇列把尖峰攤平:一秒內的幾百筆訂單,讓資料庫用它負擔得起的速度慢慢寫。
- 在最外層就擋掉大部分流量:頁面放 CDN、按鈕開賣才啟用、限流或排隊室控制進來的人數。
和其他主題的關係
- 延伸閱讀
- 負載平衡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 搶一次(原子扣減) | O(1) | O(1) |
| 資料庫負擔:快取+佇列 k 是庫存件數,和搶購人數 n 無關 | O(k) | O(k) |
| 資料庫負擔:直接扣資料庫 | O(n) | O(n) |
| 最後一人的等待:鎖住那一列 大家排隊等同一把鎖 | O(n) | O(n) |
空間:O(n),限流與一人一單要記住每個來過的人
Big O 實測:n 變大時步數怎麼長
數的是:資料庫查詢次數(庫存 100 件,n 是搶購人數)
| Big O | n = 1,000 | n = 5,000 | n = 25,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 資料庫:先讀再寫 | O(n) | 1,311 | 6,116 | 29,991 | ×23 (×25) |
| 快取原子扣減+佇列 | O(1) | 100 | 100 | 100 | ×1.0 (×1.0) |
人數變成 25 倍,直接扣資料庫的查詢跟著變多;快取擋在前面時,資料庫只寫庫存那麼多筆,來多少人都一樣。
和其他做法比
| 賣出 | 超賣 | 等太久放棄 | p99 回應 | 資料庫查詢 | 資料庫尖峰每秒 | |
|---|---|---|---|---|---|---|
| 資料庫:先讀再寫 | 4,017 | 3,917 | 0 | 2.0 ms | 24,017 | 39,240 |
| 資料庫:鎖住那一列 | 100 | 0 | 19,625 | 504 ms | 375 | 250 |
| 快取原子扣減+佇列 | 100 | 0 | 0 | 0.2 ms | 100 | 200 |
20,000 人搶 100 件,同一組到達時間跑三種做法。只有快取原子扣減同時做到不超賣、不讓人久等、資料庫負擔和人數無關。p99 是第 99 百分位(percentile):把所有請求的延遲由快到慢排好,排在 99% 位置的那個值。
真實世界裡的它
- 演唱會搶票:開賣前先讓所有人進排隊室,再依序放行進選位頁。
- 電商的限時特賣與雙 11 秒殺,熱門商品的庫存都放在快取裡扣。
- 限量球鞋、新手機預購常加上抽籤,乾脆把「搶」換成「先登記、再隨機」。
取捨與陷阱
- 先讀再寫一定會超賣:讀和寫中間只要有別人插進來,就有兩個人看到同一個「還剩 1 件」。
- 鎖住資料庫那一列雖然正確,但所有人排隊等同一把鎖,等太久的人會放棄,連還有貨時來的人都可能買不到。
- 快取和資料庫的庫存會分歧:要有付款逾時回補,也要定期用資料庫的訂單校正快取裡的數字。
- 一人一單要在扣庫存之前檢查,不然同一個人狂按會吃掉好幾件庫存。