高併發業務下的庫存扣減技術方案設計

公众号-JavaEdge發表於2024-08-26

扣減庫存需要查詢庫存是否足夠:

  • 足夠就佔用庫存
  • 不夠則返回庫存不足(這裡不區分庫存可用、佔用、已消耗等狀態,統一成扣減庫存數量,簡化場景)

併發場景,若 查詢庫存和扣減庫存不具備原子性,就可能超賣,而高併發場景超賣機率會增高,超賣數額也會增高。處理超賣的確麻煩:

  • 系統全鏈路刷數會很麻煩(多團隊協作),客服外呼也有額外成本
  • 最主要原因,客戶搶到訂單又被取消,嚴重影響客戶體驗,甚至引發客訴產生公關危機

1.4.1 實現邏輯

常用方案redis+lua,藉助redis單執行緒執行+lua指令碼中的邏輯,可在一次執行中順序完成的特性達到原子性(叫排它性更準確,因為不具備回滾動作,異常情況需自己手動編碼回滾)。

lua指令碼基本實現

-- 1. 獲取庫存快取key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]

-- 2. 獲取剩餘庫存數量
local stock = tonumber(redis.call('get', hot_item_stock))

-- 3. 購買數量
local buy_qty = tonumber(ARGV[1])

-- 4. 如果庫存小於購買數量,則返回1,表達庫存不足
if stock < buy_qty then
  return 1
end

-- 5. 庫存足夠,更新庫存數量
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))

-- 6. 扣減成功則返回2,表達庫存扣減成功
return 2

但指令碼還有一些問題:

  • 不具備冪等性,同個訂單多次執行會導致重複扣減,手動回滾也無法判斷是否會回滾過,會出現重複增加的問題

  • 不具備可追溯性,不知道庫存被誰被哪個訂單扣減了

增強後的lua指令碼:

-- 1. 獲取庫存扣減記錄快取 key KYES[2] = hot_{itemCode-skuCode}_deduction_history
local  hot_deduction_history = KYES[2]

-- 2. 使用 Redis Cluster hash tag 保證 stock 和 history 在同一個槽
local exist = redis.call('hexists', hot_deduction_history, ARGV[2])
-- 3. 請求冪等判斷,存在返回0,表達已扣減過庫存
if exist == 1 then return 0 end

-- 4. 獲取庫存快取key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]

-- 5. 獲取剩餘庫存數量
local stock = tonumber(redis.call('get', hot_item_stock))

-- 6. 購買數量
local buy_qty = tonumber(ARGV[1])

-- 7. 如果庫存小於購買數量 則返回1,表達庫存不足
if stock < buy_qty then return 1 end

-- 8. 庫存足夠
-- 9. 1.更新庫存數量
-- 10. 2.插入扣減記錄 ARGV[2] = ${扣減請求唯一key} - ${扣減型別} 值為 buy_qty
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))
redis.call('hset', hot_deduction_history, ARGV[2], buy_qty)

-- 11. 如果剩餘庫存等於0則返回2,表達庫存已為0
if stock == 0 then return 2 end

-- 12. 剩餘庫存不為0返回 3 表達還有剩餘庫存
return 3 end

利用Redis Cluster hash tag保證stock和history在同個槽,這樣lua指令碼才能正常執行。

因為正常要求 Lua 指令碼操作的鍵必須在同一個 slot 中。

@Override
public <T, R> RFuture<R> evalReadAsync(String key, Codec codec, RedisCommand<T> evalCommandType, String script, List<Object> keys, Object... params) {
 NodeSource source = getNodeSource(key);
 return evalAsync(source, true, codec, evalCommandType, script, keys, false, params);
}

 private NodeSource getNodeSource(String key) {
     int slot = connectionManager.calcSlot(key);
     return new NodeSource(slot);
 }

利用hot_deduction_history,判斷扣減請求是否執行過,以實現冪等性。

藉助hot_deduction_history的V值判斷追溯扣減來源,如:使用者A的交易訂單A的扣減請求,或使用者B的借出單B的扣減請求。

回滾邏輯先判斷hot_deduction_history裡有沒有 ${扣減請求唯一key}:

  • 有,則執行回補邏輯
  • 沒有,則認定回補成功

但該邏輯依舊有漏洞,如(訊息亂序消費),訂單扣減庫存超時成功觸發了重新扣減庫存,但同時訂單取消觸發了庫存扣減回滾,回滾邏輯先成功,超時成功的重新扣減庫存就會成為髒資料留在redis裡。

1.4.2 處理方案

有兩種:

  • 追加對賬,定期校驗hot_deduction_history中資料對應單據的狀態,對於已經取消的單據追加一次回滾請求,存在時延(業務不一定接受)以及額外計算資源開銷
  • 使用順序訊息,讓扣減庫存、回滾庫存都走同一個MQ topic的有序佇列,藉助MQ訊息的有序性保證回滾動作一定在扣減動作後面執行,但有序序列必然帶來效能下降

1.4.3 高可用

Redis終究是記憶體,一旦服務中斷,資料就消失。所以需要追加保護資料不丟失的方案。

運用Redis部署的高可用方案:

  • 採用Redis Cluster(資料分片+ 多副本 + 同步多寫 + 主從自動選舉)
  • 多寫節點分(同城異地)多中心防止意外災害

定期歸檔冷資料。定期 + 庫存為0觸發redis資料往DB同步,流程如下:

CDC分發資料時,秒殺商品,hot_deduction_history的資料量不高,可以一次全量同步。但如果是普通大促商品,就需要再追加一個map動作分批處理,以保證每次執行CDC的資料量恆定,不至於一次性資料量太大出現OOM。程式碼如下:

/**
 * 對任務做分發
 * @param stockKey 目標庫存的key值
 */
public void distribute(String stockKey) {
    final String historyKey = StrUtil.format("hot_{}_deduction_history", stockKey);
    // 獲取指定庫存key 所有扣減記錄的key(生產請分頁獲取,防止資料量太多)
    final List<String> keys = RedisUtil.hkeys(historyKey, stockKey);
    // 以 100 為大小,分片所有記錄key
    final List<List<String>> splitKeys = CollUtil.split(keys, 100);
    // 將集合分發給各個節點執行
    map(historyKey, splitKeys);
}

/**
 * 對單頁任務做執行
 * @param historyKey 目標庫存的key值
 * @param stockKeys 要執行的頁面大小
 */
public void mapExec(String historyKey, List<String> stockKeys) {
    // 獲取指定庫存key 指定扣減記錄 的map
    final Map<String, String> keys = RedisUtil.HmgetToMap(historyKey, stockKeys);
    keys.entrySet()
        .stream()
        .map(stockRecordFactory::of)
        .forEach(stockRecord -> {
            // (冪等 + 去重) 扣減 + 儲存記錄
            stockConsumer.exec(stockRecord);
            // 刪除redis中的 key 釋放空間
            RedisUtil.hdel(historyKey, stockRecord.getRecordRedisKey());
        });
}

1.4.4 為啥不走DB

商品庫存資料在DB最終會落到單庫單表的一行資料。無法透過分庫分表提高請求的並行度。而在單節點場景,資料庫吞吐遠不如Redis。最基礎的原因:IO效率不是一個量級,DB是磁碟操作,而且還可能要多次讀盤,Redis是一步到位的記憶體操作。

同時,一般DB都是提交讀隔離級別,為保證原子性,執行庫存扣減,得加鎖,無論悲觀樂觀。不僅效能差(搶不到鎖要等待),而且因為非公平競爭,易出現執行緒飢餓。而redis是單執行緒操作,不存在共享變數競爭。

有些最佳化思路,如合併扣減,走批降低請求的並行連線數。但伴隨的集單的時延,以及按庫分批的訴求;還有拆庫存行,商品A100個庫存拆成2行商品A50庫存,然後扣減時分發請求,以提高並行連線數(多行可落在不同庫來提高並行連線數)。但伴隨的:

  • 複雜的庫存行拆分管理(把什麼庫存行在什麼時候拆分到哪些庫)
  • 部分庫存行超賣的問題(加鎖最佳化就又序列了,不加總量還有庫存,個別庫存行不足是允許一定係數超賣還是返回庫存不足就是一個要決策的問題)

部分頭部電商採用弱快取抗讀(非庫存不足,不實時更新),DB抗寫的方案。該方案前提在於,透過一系列技術方案,流量落到庫存已相對低且平滑了(扛得住,不用再自己實現操作原子性)。

關注我,緊跟本系列專欄文章,咱們下篇再續!

作者簡介:魔都架構師,多家大廠後端一線研發經驗,在分散式系統設計、資料平臺架構和AI應用開發等領域都有豐富實踐經驗。

各大技術社群頭部專家博主。具有豐富的引領團隊經驗,深厚業務架構和解決方案的積累。

負責:

  • 中央/分銷預訂系統效能最佳化
  • 活動&券等營銷中臺建設
  • 交易平臺及資料中臺等架構和開發設計
  • 車聯網核心平臺-物聯網連線平臺、大資料平臺架構設計及最佳化
  • LLM Agent應用開發
  • 區塊鏈應用開發
  • 大資料開發挖掘經驗
  • 推薦系統專案

目前主攻市級軟體專案設計、構建服務全社會的應用系統。

參考:

  • 程式設計嚴選網

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章