[Concurrency] Event Ticketing System Design – 購票系統設計指南

Intro


系統設計

  1. 必要路徑:解決「快」與「不超賣」
    User -> Redis (DECR/Lua) -> MQ -> DB worker -> DB

  2. 完整路徑:解決「安全」與「穩定」
    User -> CDN/WAF -> Gateway -> Redis (DECR/Lua) -> MQ -> DB worker -> DB

層級 核心技術 目的
入口層 (Entry Layer) CDN + WAF + Queue-it 阻擋惡意流量、過濾靜態資源、排隊放行 (Virtual Waiting Room)
應用層 (Application Layer) Microservices + API Gateway 業務邏輯解耦、實施熔斷與限流策略
緩存層 (Caching Layer) Redis (Lua Scripts) 實現原子性預扣庫存、支撐高併發讀取請求
訊息層 (Messaging Layer) Kafka / RocketMQ 非同步寫入資料庫、削峰填谷、緩解 DB 瞬時壓力
數據層 (Data Layer) MySQL (Shard/Split) 訂單記錄持久化、確保最終一致性

設計要點

User 限制

實作基於 Redis 的分佈式冪等 (Idempotency) 鎖機制:搶票請求可以用 user_idevent_id 作為複合 Key,利用 SETNX (或 SET ... NX EX) 發起 Atomic 鎖定請求,時效 (TTL) 需大於業務處理的最長期望時間)。若回傳失敗,應立即觸發『快速失敗 (Fail-fast)』機制,回傳請求處理中或重複提交。鎖定成功後才可進入庫存預扣流程。
鎖的釋放建議配合業務條件設計是在 DB 處理完之後。

Redis 庫存預扣機制 (Inventory Pre-decrement)

為避免無效請求堆積於 MQ 並減輕資料庫壓力,系統建議採用「緩存優先」的過濾策略:

  1. 資料預熱 (Data Warm-up)

    • 活動開賣前,將資料庫中的「可用庫存數」與「活動狀態」同步至 Redis 叢集。
    • 若 Redis 中無此活動 Key,系統應直接攔截請求,禁止穿透至資料庫。
  2. 原子扣減 (Atomic Operations)

    • 採用 Lua 腳本 封裝庫存檢查與扣減邏輯 (if stock > 0 then redis.call('DECR', key) ...)。
    • 僅在 Redis 預扣成功後,系統方可產生「預選位」訊息並發送至 MQ。
  3. 削峰填谷 (Traffic Shaping)

    • 透過 Redis 過濾掉 90% 以上的「未搶到」請求,確保進入 MQ 的每一則訊息均為「潛在有效訂單」,大幅提升後端 DB Worker 的處理效率。
  4. 異常回補 (Inventory Compensation)

    • 建立反向回補機制:若後端訂單因支付超時、用戶取消或系統異常而失敗,必須主動執行 Redis INCR 操作,將庫存釋放回緩存池供後續用戶搶購。

配合 User 限制,Lua 腳本範例可以如下:

-- KEYS[1]: stock_key (庫存的 Key)
-- KEYS[2]: user_set_key (記錄已中籤用戶的 Set Key)
-- ARGV[1]: user_id (當前請求的用戶 ID)

local stock_key = KEYS[1]
local user_set_key = KEYS[2]
local user_id = ARGV[1]

-- 1. 檢查用戶是否重複搶購
-- 使用 SISMEMBER (Set Is Member): 
-- 作用:在 $O(1)$ 時間內檢查指定元素 (user_id) 是否存在於集合 (Set) 中。
-- 目的:確保業務冪等性,防止同一用戶重複扣減庫存。
if redis.call('SISMEMBER', user_set_key, user_id) == 1 then 
    return -1 -- 代表該用戶已存在於中籤名單中
end

-- 2. 檢查庫存量
-- 使用 GET (Get): 
-- 作用:獲取指定 Key 的字串值。
-- 目的:讀取當前剩餘的可用票數。
local stock = tonumber(redis.call('GET', stock_key) or "0")

if stock > 0 then
    -- 3. 執行扣減庫存
    -- 使用 DECR (Decrement): 
    -- 作用:將 Key 中儲存的數字值減一。此操作具備原子性。
    -- 目的:正式在緩存層佔用一個名額。
    redis.call('DECR', stock_key)

    -- 4. 記錄中籤用戶
    -- 使用 SADD (Set Add): 
    -- 作用:將一個元素加入集合中。如果元素已存在,則忽略。
    -- 目的:將成功扣減庫存的用戶 ID 永久(或直到活動結束)記錄下來。
    redis.call('SADD', user_set_key, user_id) 

    return 1 -- 成功預扣庫存
else
    -- 庫存不足
    return 0 -- 代表已售罄
end

Redis 使用單執行緒模型執行腳本。當一個腳本在執行時,Redis 會阻塞所有其他客戶端的指令,直到該腳本執行完畢。

Leave a Reply

Your email address will not be published. Required fields are marked *