在電商大促秒殺、熱門演唱會搶票、限量球鞋抽籤等極端業務場景中,「庫存扣減」 是整個後端架構中最容易引發效能雪崩與資損事故的核心環節。
扣減架構必須在滿足嚴格的**防超賣(Zero Overselling)與防少賣(Zero Underselling)**前提下,承載每秒數萬乃至數十萬次的併發突發請求(QPS Spike)。
業界從單體關聯式資料庫的悲觀行鎖、樂觀 CAS 版本號,演進到基於 Redis 的記憶體扣減與分散式鎖,再到超大規模的分段庫存(Inventory Segment Sharding)。
本文基於 Martin Kleppmann 分散式鎖經典論文 與 ByteByteGo System Design 101,深入剖析高併發扣減架構的演進脈絡與爭議權衡。
1. 悲觀鎖 (SELECT FOR UPDATE)
強一致性杜絕超賣,但行鎖競爭激烈、易引發連線池枯竭與死鎖。TPS 上限約 500~1,000。
2. 樂觀鎖 (CAS Version)
無鎖等待,適合讀多寫少;但在極高併發秒殺下會引發「重試風暴」消耗 CPU。TPS 上限約 2,000~3,000。
3. Redis 分散式鎖 + Lua
記憶體級高速扣減;需搭配 Fencing Token 防範 GC 停頓引起的時鐘漂移與鎖過期。單 Key TPS 約 20,000。
4. 分段庫存 (Segment Sharding)
將庫存拆解為 10~100 個獨立 Bucket,分散熱點,實現 100,000+ QPS 線性擴展。
一、高併發秒殺扣減的業務紅線與挑戰
在秒殺情境下,工程團隊面臨兩大剛性約束:
- 絕不允許超賣(Overselling):庫存 100 件,售出 101 件即引發資損與客訴危機。
- 杜絕庫存少賣(Inventory Underutilization):未售罄前不能因為系統鎖失敗或異常回滾導致使用者買不到。
- 系統防禦雪崩:單一商品熱點(Hot Spot Key)不能拖垮全庫連線池,造成整體服務不可用。
二、方案 1:資料庫悲觀鎖(SELECT ... FOR UPDATE)
最直覺的解法是利用關聯式資料庫(如 MySQL InnoDB / PostgreSQL)的行級排他鎖(X Lock):
-- 悲觀鎖扣減流程
BEGIN;
SELECT stock FROM product_inventory WHERE product_id = 1001 FOR UPDATE;
-- 應用層校驗 stock >= buy_count
UPDATE product_inventory
SET stock = stock - 1
WHERE product_id = 1001;
COMMIT;
痛點與極限瓶頸
- 排隊等待與連線池耗盡:所有併發請求都在等待同一行的行鎖釋放。當 QPS 達到 1,000+ 時,資料庫活躍連線數飆升,迅速耗盡連線池(Connection Starvation),導致其他無關業務全部超時。
- 索引失效引發表鎖:若
product_id缺少索引或查詢未命中索引,InnoDB 會直接鎖定整張表,引發災難性停擺。 - 吞吐量天花板:受限於磁碟 I/O 與行鎖爭用,單行記錄悲觀鎖的極限 TPS 通常不超過 500 ~ 1,000。
三、方案 2:樂觀鎖與原子條件扣減(CAS)
樂觀鎖假設併發衝突較少,讀取時不加鎖,只在更新時檢查數據版本是否被修改過:
-- 方式 A:版本號 CAS 機制
SELECT stock, version FROM product_inventory WHERE product_id = 1001;
UPDATE product_inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 1001 AND version = :old_version;
-- 方式 B:純 SQL 原子條件更新 (推薦)
UPDATE product_inventory
SET stock = stock - :buy_count
WHERE product_id = 1001 AND stock >= :buy_count;
樂觀鎖在極高併發下的致命缺陷:重試風暴
- 方式 B 依賴資料庫底層行鎖的原子性(
stock >= buy_count保證不會扣成負數),比方式 A 更精簡。 - 但在秒殺場景下,10,000 個請求同時執行方式 A,只有 1 個成功,其餘 9,999 個請求全部失敗。
- 若應用層加入重試迴圈(Retry Loop),會瞬間引發**「重試風暴(Retry Storm)」**,CPU 100% 空轉,資料庫吞吐量反而暴跌至 2,000 ~ 3,000 TPS。
四、方案 3:Redis 分散式鎖與 Redlock 世紀之爭
為了將流量阻絕在資料庫之前,現代架構普遍採用 Redis 進行記憶體預扣減。
1. 單節點 Redis + Lua 腳本原子扣減
透過 Lua 腳本在 Redis 內部執行原子運算,徹底避免分散式環境下的競態條件(Race Condition):
-- stock_deduct.lua
local key = KEYS[1]
local buy_count = tonumber(ARGV[1])
local current_stock = tonumber(redis.call('get', key) or "0")
if current_stock >= buy_count then
redis.call('decrby', key, buy_count)
return 1 -- 扣減成功
else
return 0 -- 庫存不足
end
單節點 Redis 扣減效能可達 50,000 ~ 80,000 QPS,但單點存在容災風險。
2. Martin Kleppmann 對 Redlock 的致命批判
Redis 官方創始人 Salvatore Sanfilippo (antirez) 提出了多節點分散式鎖演算法 Redlock。然而,分散式系統大師 Martin Kleppmann 在其著名專文發表了強烈質疑:
Martin Kleppmann 指出分散式鎖在非同步環境下的三大死穴:
- GC 停頓(Stop-The-World Pause)與程序掛起:客戶端獲取鎖後發生長 GC,期間鎖過期被其他節點搶佔,甦醒後繼續寫入導致數據覆蓋。
- 網路分區與延遲(Network Delays):鎖釋放訊息延遲抵達。
- 系統時鐘跳變與漂移(Clock Drift):NTP 同步時鐘突變導致 TTL 提前失效。
3. 解法:Fencing Token(防漂移遞增令牌)
Martin Kleppmann 證明:分散式鎖若要保證安全性,必須要求後端儲存層支援 Fencing Token 驗證。
- 每次獲取鎖時,鎖伺服器返回一個嚴格單調遞增的數字(Fencing Token 33, 34, 35…)。
- 儲存層在寫入時檢查 Token:若收到 Token 33 的寫入請求,而先前已接受過 Token 34,則拒絕 Token 33 的寫入!
五、方案 4:10 萬 QPS 分段庫存架構(Segment Sharding)
當單一熱點商品的秒殺併發突破 100,000+ QPS 時,無論 Redis 還是 DB,單一 Key / 單一記錄都會受限於單核心 CPU 與網卡吞吐。
此時的終極解法是**「分段庫存(Inventory Segment Sharding)」**:
1. 分段分桶演算法
- 假設商品總庫存為 1,000 件,在 Redis 中拆分為 10 個獨立 Key:
product:1001:bucket:0到product:1001:bucket:9,每個 Key 配額 100 件。 - 用戶請求進來時,根據
hash(userId) % 10路由到指定 Bucket 進行原子扣減。 - 鎖競爭點被瞬間分散 10 倍,整體 QPS 呈線性擴展。
2. 局部庫存借調(Segment Rebalance)
- 問題:Bucket 0 扣光了,但 Bucket 5 還有剩餘庫存,用戶被誤判為「已售罄」(少賣問題)。
- 解法:
- 順延重試(Next Bucket Probe):當前 Bucket 不足時,嘗試輪詢下一個 Bucket(如
(bucketId + 1) % 10)。 - 非同步後台歸攏(Auto-Rebalancer):定時 Job 偵測不均衡分桶,將剩餘破碎庫存集中至 Bucket 0。
- 順延重試(Next Bucket Probe):當前 Bucket 不足時,嘗試輪詢下一個 Bucket(如
六、端到端架構最佳實踐全景
在真正的百萬級秒殺系統中,採用的是多層防禦的組合拳:
- 邊緣層(CDN / Gateway):靜態化商品頁面、驗證碼答題防刷、用戶級滑動窗口限流。
- 快取層(Redis Cluster):分段庫存預扣減 + Lua 腳本保障原子性。
- 非同步層(Message Queue):扣減成功的訂單推入 RocketMQ/Kafka,進行削峰排隊。
- 持久層(RDBMS):下游 Worker 批量消費訂單訊息,使用條件更新(
stock >= count)最終寫入資料庫,兼顧高吞吐與強一致。
