跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:限時搶購

案例:限時搶購

幾十萬人同時搶一百件商品:不能超賣、也不能讓資料庫被打垮。用快取原子地扣庫存、用佇列把訂單的尖峰攤平。

流程

資料庫前面的每一層,都是為了不讓人潮直接撞上資料庫: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 On = 1,000n = 5,000n = 25,000成長倍數:實測(理論)
資料庫:先讀再寫O(n)1,3116,11629,991×23 (×25)
快取原子扣減+佇列O(1)100100100×1.0 (×1.0)

人數變成 25 倍,直接扣資料庫的查詢跟著變多;快取擋在前面時,資料庫只寫庫存那麼多筆,來多少人都一樣。

和其他做法比

賣出超賣等太久放棄p99 回應資料庫查詢資料庫尖峰每秒
資料庫:先讀再寫4,0173,91702.0 ms24,01739,240
資料庫:鎖住那一列100019,625504 ms375250
快取原子扣減+佇列100000.2 ms100200

20,000 人搶 100 件,同一組到達時間跑三種做法。只有快取原子扣減同時做到不超賣、不讓人久等、資料庫負擔和人數無關。p99 是第 99 百分位(percentile):把所有請求的延遲由快到慢排好,排在 99% 位置的那個值。

真實世界裡的它

  • 演唱會搶票:開賣前先讓所有人進排隊室,再依序放行進選位頁。
  • 電商的限時特賣與雙 11 秒殺,熱門商品的庫存都放在快取裡扣。
  • 限量球鞋、新手機預購常加上抽籤,乾脆把「搶」換成「先登記、再隨機」。

取捨與陷阱

  • 先讀再寫一定會超賣:讀和寫中間只要有別人插進來,就有兩個人看到同一個「還剩 1 件」。
  • 鎖住資料庫那一列雖然正確,但所有人排隊等同一把鎖,等太久的人會放棄,連還有貨時來的人都可能買不到。
  • 快取和資料庫的庫存會分歧:要有付款逾時回補,也要定期用資料庫的訂單校正快取裡的數字。
  • 一人一單要在扣庫存之前檢查,不然同一個人狂按會吃掉好幾件庫存。