在電商大促秒殺、熱門演唱會搶票、限量球鞋抽籤等極端業務場景中,「庫存扣減」 是整個後端架構中最容易引發效能雪崩與資損事故的核心環節。

扣減架構必須在滿足嚴格的**防超賣(Zero Overselling)與防少賣(Zero Underselling)**前提下,承載每秒數萬乃至數十萬次的併發突發請求(QPS Spike)。

業界從單體關聯式資料庫的悲觀行鎖、樂觀 CAS 版本號,演進到基於 Redis 的記憶體扣減與分散式鎖,再到超大規模的分段庫存(Inventory Segment Sharding)。

本文基於 Martin Kleppmann 分散式鎖經典論文 與 ByteByteGo System Design 101,深入剖析高併發扣減架構的演進脈絡與爭議權衡。

高併發秒殺庫存扣減鎖策略與分段架構對比展示悲觀鎖 (SELECT FOR UPDATE)、樂觀鎖 (CAS Version)、Redis Redlock (與 Fencing Token) 及 10 萬 QPS 分段庫存 (Segment Sharding) 的工作機制與吞吐量對比。DATABASE ROW LOCK悲觀鎖 (Pessimistic)語法機制SELECT stock FROM invWHERE id=1 FOR UPDATE;優勢與適用場景• 強一致性,絕對杜絕超賣• 業務邏輯簡單直接• 寫入衝突極高且吞吐量低⚠️ 效能瓶頸• 佇列阻塞,連線池耗盡• 容易引發死鎖 (Deadlock)• TPS 上限約 500~1,000CAS VERSIONING樂觀鎖 (Optimistic)語法機制UPDATE inv SET stock=s-1WHERE id=1 AND ver=v;優勢與適用場景• 無資料庫行鎖等待• 讀多寫少場景效能極佳• 條件原子扣 (stock >= 1)⚠️ 效能瓶頸• 秒殺時大量請求失敗• 重試風暴引發 CPU 空轉• TPS 上限約 2,000~3,000REDIS + FENCING分散式鎖 (Distributed)Lua 腳本原子扣減redis.call('decrby', k, 1)SET lock_key uuid NX PX 30s優勢與防漂移• 記憶體級高速處理• Fencing Token 遞增保護• 將壓力卸載至快取層⚠️ 爭議與風險• GC 停頓導致鎖過期失效• 主從切換可能丟鎖• 單 Key TPS 約 20,000SEGMENT SHARDING分段庫存 (100k+ QPS)分桶架構Bucket 0..9 (各 100 件)hash(userId) % 10 路由核心優勢• 鎖競爭點分散 10~100 倍• 支援平行獨立扣減• 非同步 MQ 批量回寫 DB🎯 最佳實踐• 局部重試/借調算法• 適合超百萬級秒殺大促• TPS 上限可達 100,000+
PESSIMISTIC

1. 悲觀鎖 (SELECT FOR UPDATE)

強一致性杜絕超賣,但行鎖競爭激烈、易引發連線池枯竭與死鎖。TPS 上限約 500~1,000。

OPTIMISTIC

2. 樂觀鎖 (CAS Version)

無鎖等待,適合讀多寫少;但在極高併發秒殺下會引發「重試風暴」消耗 CPU。TPS 上限約 2,000~3,000。

DISTRIBUTED

3. Redis 分散式鎖 + Lua

記憶體級高速扣減;需搭配 Fencing Token 防範 GC 停頓引起的時鐘漂移與鎖過期。單 Key TPS 約 20,000。

SCALE-OUT

4. 分段庫存 (Segment Sharding)

將庫存拆解為 10~100 個獨立 Bucket,分散熱點,實現 100,000+ QPS 線性擴展。

圖 2:高併發秒殺扣減:悲觀鎖、樂觀鎖、Redis 分散式鎖與分段庫存架構全景對比

一、高併發秒殺扣減的業務紅線與挑戰

在秒殺情境下,工程團隊面臨兩大剛性約束:

  1. 絕不允許超賣(Overselling):庫存 100 件,售出 101 件即引發資損與客訴危機。
  2. 杜絕庫存少賣(Inventory Underutilization):未售罄前不能因為系統鎖失敗或異常回滾導致使用者買不到。
  3. 系統防禦雪崩:單一商品熱點(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 指出分散式鎖在非同步環境下的三大死穴:

  1. GC 停頓(Stop-The-World Pause)與程序掛起:客戶端獲取鎖後發生長 GC,期間鎖過期被其他節點搶佔,甦醒後繼續寫入導致數據覆蓋。
  2. 網路分區與延遲(Network Delays):鎖釋放訊息延遲抵達。
  3. 系統時鐘跳變與漂移(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 還有剩餘庫存,用戶被誤判為「已售罄」(少賣問題)。
  • 解法:
    1. 順延重試(Next Bucket Probe):當前 Bucket 不足時,嘗試輪詢下一個 Bucket(如 (bucketId + 1) % 10)。
    2. 非同步後台歸攏(Auto-Rebalancer):定時 Job 偵測不均衡分桶,將剩餘破碎庫存集中至 Bucket 0。

六、端到端架構最佳實踐全景

在真正的百萬級秒殺系統中,採用的是多層防禦的組合拳:

  1. 邊緣層(CDN / Gateway):靜態化商品頁面、驗證碼答題防刷、用戶級滑動窗口限流。
  2. 快取層(Redis Cluster):分段庫存預扣減 + Lua 腳本保障原子性。
  3. 非同步層(Message Queue):扣減成功的訂單推入 RocketMQ/Kafka,進行削峰排隊。
  4. 持久層(RDBMS):下游 Worker 批量消費訂單訊息,使用條件更新(stock >= count)最終寫入資料庫,兼顧高吞吐與強一致。