購物車限流器
購物車建立 API 的限流器怎麼設計的
同一個會員在搶購時瘋狂連點結帳按鈕,會讓後端一連串跨服務呼叫被重複觸發,付出的成本很高。這篇整理 Shopping 服務裡的限流器 ExceededTimesLimiterService 實際怎麼設計,從識別鍵、設定來源、執行流程到快取分層,逐章拆解。
設計目的與套用位置
這個限流器不是全站流量限流,而是針對「特定服務加特定動作加特定識別鍵」這個組合做節流。它套用在 CartService.CartCreateAsync 方法的第一行,屬於呼叫 carts/create 這支 API 時最先執行的防護步驟,發生在還沒組裝購物車上下文、還沒碰任何外部系統之前。
用途:防止同一會員在搶購商品時短時間內重複呼叫 carts/create,因為這支 API 本身會觸發黑名單檢查、Coupon 中心、紅利點數、促銷代碼、CRM 會員等級查詢,最後還要跨服務呼叫 Cart 服務做完整換價與金物流計算,成本相當高。
設計方式是把 IExceededTimesLimiterService.ExamineTryOverTimesLockApiAsync 這支共用的限流服務,寫成一行明確呼叫,散落在各個需要限流的業務方法裡(例如 CartsController、CheckoutController 對應的 Service 方法)。
三層識別鍵設計
限流用的 Cache Key 由三個維度組成,缺一不可,這個組合決定了限流的粒度精準到什麼程度。
| 維度 | 對應型別 | 意義 |
|---|---|---|
| 服務分類 | ExceededTimesLimiterServiceTypeEnum | 區分是哪個業務服務,如 ShoppingCart、Pay、Auth、ECoupon、CCA、RetailStore、Brand、Catering |
| 具體動作 | ExceededTimesLimiterActionTypeEnum | 細到每一支 API 動作,涵蓋購物車與結帳流程幾乎所有端點,例如 CartsCreate、CartsCalculate、CheckoutComplete |
| 識別身份 | ExceededTimesLimiterRecognizeTypeEnum | 目前只有 ShopId 或 MemberId 兩種,carts/create 用的是 MemberId |
組出來的完整 Cache Key 大致長這樣:ExceededTimesLimiterService:ShoppingCart:CartsCreate-2023051201:MemberId:{memberId}:{時間分片}。這代表限流計數是以「這個會員對這支動作」為單位獨立累計,不同會員之間彼此不影響,也不會因為某支動作被限流而牽連到其他動作。
設定值來源,分兩層且商店優先
限流的參數(是否開啟、上限次數、時間窗秒數、鎖定秒數)不是寫死在程式碼裡,而是可以動態調整的設定值,查詢時會依序嘗試兩個來源。
依 ShopId 查詢 ShopStaticSetting 資料表底下分組名稱 ExceededTimesLimiter、Key 為服務名稱的設定值,格式是用分號分隔多組 動作.識別類型=設定字串。如果該商店有針對這支動作客製化設定,就直接採用,讓不同商店可以依自己的流量特性調整限流強度。
從 App Config 讀取 Key 為 ExceededTimesLimiter:ShoppingCart.CartsCreate.MemberId 的設定字串,若設定不存在則用寫死的預設值 false|3|1|30(代表預設關閉限流)。
設定字串本身採用固定格式,用直線分隔四個欄位:
| 欄位順序 | 欄位名稱 | 說明 |
|---|---|---|
| 1 | Enabled | 是否開啟這支動作的限流 |
| 2 | MaxTimes | 單位時間窗內允許的最高呼叫次數 |
| 3 | CacheSeconds | 時間窗的長度,單位秒 |
| 4 | LockedSeconds | 一旦超過次數,鎖定多少秒不允許再呼叫 |
目前 carts/create 的實際設定值是 true|3|1|30,代表已開啟限流,同一會員 1 秒內最多允許呼叫 3 次,一旦超過就鎖定 30 秒。這支設定本身也會被快取(記憶體 5 秒加 Redis 4 小時),避免每次呼叫都重新查一次商店設定與 App Config。
執行流程,一次呼叫做了哪些檢查
ExamineTryOverTimesLockApiAsync 這支方法的執行順序如下,每一步都可能提早結束整個檢查。實際程式碼如下。
public async Task ExamineTryOverTimesLockApiAsync( ExceededTimesLimiterRequestEntity request, long? shopId = null, bool isExamineWithoutAddTryTimes = false) { var settings = await _circuitBreakerService.GetExceededTimesLimiterSettingsAsync(request, shopId); if (settings.Enabled == false) { return; } var cacheEntity = GetCacheKey(request.ServiceName, request.ActionName, new[] { request.RecognizeType, request.RecognizeKey }); //// 嘗試次數 (不會+1 : 會+1) var tryTimes = isExamineWithoutAddTryTimes ? GetTryTimes(cacheEntity, settings) : TryTimes(cacheEntity, settings); //// 已經被鎖定 if (settings.LockedSeconds > 0 && IsLocked(cacheEntity)) { throw new ExceededTimesLimiterException(ExceededTimesLimiterExceptionTypeEnum.ActionLocked, $"{request.ServiceName}-{request.ActionName} 已限速鎖定"); } if (tryTimes > settings.MaxTimes) { //// 設定鎖定 if (settings.LockedSeconds > 0) { SetLocked(cacheEntity, settings.LockedSeconds); } throw new ExceededTimesLimiterException(ExceededTimesLimiterExceptionTypeEnum.ActionLocked, "目前商品搶購熱烈,暫時無法結帳,重新整理再試一次"); //// TODO 要請前端串 //throw new ExceededTimesLimiterException(ExceededTimesLimiterExceptionTypeEnum.ActionLocked, $"{request.ServiceName}-{request.ActionName} 已限速鎖定"); } }
這段程式碼對應到下面 7 個步驟,逐步比對可以看出每個判斷式落在哪一行。
依上一章節的兩層查詢邏輯,取得這支動作對應的 Enabled、MaxTimes、CacheSeconds、LockedSeconds。
不做任何計數或鎖定檢查,方法直接返回,後續邏輯完全不受影響。
依服務名稱、動作名稱、識別類型、識別值,再疊加目前所在的時間分片,組出這次要操作的 Redis Key。
用 Redis 的 INCR 操作把這個 Key 對應的次數加一,這是整個機制中真正累計次數的地方,取回累加後的次數。
如果設定的 LockedSeconds 大於零,且之前已經被標記為鎖定中,直接丟出 ExceededTimesLimiterException,不再往下檢查次數。
如果累加後的次數大於 MaxTimes,就把這個識別鍵標記為鎖定狀態,存活時間是 LockedSeconds 秒,然後丟出例外,附帶文案「目前商品搶購熱烈,暫時無法結帳,重新整理再試一次」。
方法正常返回,呼叫端(CartService.CartCreateAsync)繼續往下組裝購物車上下文,進入後續的 Processor Pipeline。
時間窗機制,固定窗而非滑動窗
計數用的時間分片,是把目前時間依 CacheSeconds 無條件捨去到最近的整數秒區間,當作 Cache Key 的一部分。以 carts/create 的 CacheSeconds 等於 1 秒為例,每一秒鐘都是獨立的一個計數器,時間一過該秒區間,計數器就會換成新的 Key,等於自然重置。
這種設計屬於固定時間窗(Fixed Window)計次,不是真正的滑動視窗(Sliding Window)。舉例來說,若在 17:42:52.9 呼叫一次、17:42:53.1 又呼叫兩次,因為兩者落在不同的秒區間,實際上不會被視為同一個窗口內的 3 次,各自獨立計數。這代表在窗口交界處理論上可能出現短暫的爆衝,但換來的是實作與效能都非常單純,不需要維護滑動視窗的排序結構。
兩層儲存設計,Redis 加本機 MemoryCache
不同操作使用不同的儲存策略,依用途拆成兩種模式。
次數累加
直接用 _cacheProvider.IncrBy(Redis 的 INCR)操作,確保多台 Web 主機之間的計數是共用且一致的,不會因為負載平衡打到不同機台而各自計數失準。過期時間採用設定檔 Cache.DefaultExpire,預設 10800 秒也就是 3 小時,避免 Redis Key 無限累積。
鎖定狀態查詢
走 GetMemoryOrRedisCacheData,先查本機 IMemoryCache,沒有命中才查 Redis 並回填本機快取。鎖定判斷用的本機快取存活時間很短,用意是減少高頻率查詢對 Redis 造成的往返成本。
限流設定值本身
同樣走記憶體加 Redis 兩層快取,記憶體存活 5 秒、Redis 存活 4 小時,避免每次呼叫這支 API 都重新查一次商店設定與 App Config,把設定查詢的成本壓到最低。
整體來說,Redis 負責跨機台共享、確保計數與鎖定狀態一致;本機 MemoryCache 負責在短時間內攔截重複查詢,降低 Redis 的讀取壓力,是一種典型的二級快取設計。
需要特別澄清:本機 MemoryCache 確實無法記錄到跨機台的實際累積次數,這一點程式碼裡也刻意避開了這個陷阱。真正負責累加次數的 TryTimes 方法,從頭到尾只呼叫 _cacheProvider.IncrBy,也就是 Redis 的原子遞增操作,並沒有經過 GetMemoryOrRedisCacheData 這個兩層快取讀取路徑,所以每一台 Web 主機看到的次數,都是同一份 Redis 上的最新結果,不會有各機台各自計數、加總失準的問題。
本機 MemoryCache 只用在兩個地方:一個是IsLocked 讀取「是否已鎖定」這個布林旗標,另一個是讀取限流設定值本身。這兩者的共同特性是「短時間內結果不太會變、允許有些微延遲」,所以才用本機快取先擋一層,減少對 Redis 的重複讀取。至於唯讀模式 GetTryTimes(isExamineWithoutAddTryTimes 為 true 時使用,只查詢不累加)雖然也會經過 GetMemoryOrRedisCacheData,但它本來就不是負責累加的那條路徑,讀到的次數本身還是來自 Redis 寫入的值,本機快取只是短暫延遲了讀到最新值的時間點,不影響 Redis 上真正的計數結果。
觸發後的行為與前端呈現
一旦超過次數限制,服務層會丟出以下例外:
throw new ExceededTimesLimiterException( ExceededTimesLimiterExceptionTypeEnum.ActionLocked, "目前商品搶購熱烈,暫時無法結帳,重新整理再試一次");
這個例外會被 CartsController.Create 的例外處理區塊捕捉,轉換成 HTTP 400 回應給前端,並附帶固定文案。程式碼裡目前還留了一個待辦事項,之後打算改成回傳明確的 ErrorCode,讓前端可以自行決定顯示什麼文案,而不是完全依賴後端寫死的字串。
架構定位,通用化框架而非單一 API 專屬
這支限流服務被設計成一個可重複套用的通用框架,只要滿足以下條件,任何 API 都能套用同一套機制。
- 在
ExceededTimesLimiterActionTypeEnum新增一個對應的動作名稱 - 在設定檔(或商店設定)加一行
動作名稱.識別類型對應的設定字串 - 在該支業務方法一開頭手動呼叫
ExamineTryOverTimesLockApiAsync
目前 ExceededTimesLimiterActionTypeEnum 底下已經涵蓋購物車與結帳流程幾乎所有動作,例如 CartsCalculate、CartsSkuUpdateQty、CartsSkuAdd、CheckoutComplete、CheckoutCreate 等,但實際設定檔裡目前只有 CartsCreate 這一支被開啟(Enabled 為 true),其餘動作雖然框架已經支援,但預設都是關閉狀態。
設計上的取捨與觀察
優點
每個呼叫點可以精準決定服務名稱、動作名稱、識別類型,彈性高,且與商業邏輯(例如取得 ShopId、MemberId)自然結合,程式碼可讀性好,一看就懂在限制什麼。
代價
屬於散落式呼叫,不是集中在 ActionFilter 或 Middleware 統一管理,新增一支要限流的 API 時,得記得手動在 Service 方法內加這段程式碼,有被遺漏的風險,也無法透過 Attribute 一眼看出「哪些 API 有限流」。
視窗精度
固定時間窗設計在窗口交界處理論上可能讓短時間內的實際呼叫數略微超過設定值,但換來實作單純、效能開銷低,對搶購場景這種「大致抑制連點」的需求已經足夠。
需要留意:目前鎖定後回傳給前端的文案是寫死的固定字串,沒有透過 ErrorCode 傳遞語意,如果之後要讓前端針對不同動作顯示不同提示文案,需要先補上這塊的錯誤碼設計。


