電商技術里的庫存扣減


庫存扣減

當有很多人同時在買一件商品時(假設庫存充足),每個人幾乎同時下單成功,給人一種並行的感覺。

但真實情況,庫存只是一個數值,無論是存在 MySQL 數據庫還是 Redis 緩存,減值時都要控制順序,只能串行來扣減,當然為了保證安全性,會設計一些鎖控制操作。

image


庫存扣減關鍵技術點

  • 同一個商品,庫存數量是共享
  • 剩余庫存要大於等於本次扣減的數量,否則會出現超賣現象,引發資損
  • 對同一個數量多用戶並發扣減時,要注意並發安全,保證數據的一致性
  • 類似於秒殺這樣高 QPS 的扣減場景,要保證性能與高可用
  • 對於購物車下單場景,多個商品庫存批量扣減,要保證事務
  • 如果有交易退款,保證庫存扣減可以返還:返還的數據總量不能大於扣減的總量;返還要保證冪等;可以分多次返還

數據庫扣減方案

主要是依賴數據庫特性來保證扣減的一致性,邏輯簡單,開發部署成本很低。

依賴的數據庫特性:

  • 依賴數據庫的樂觀鎖(比如:版本號或者庫存數量)保證數據並發扣減的強一致性
  • 借助事務特性,針對購物車下單批量扣減時,部分扣減失敗,數據回滾

image

首先會查詢當前商品的剩余庫存(可能不准確,但沒關系,這里只是第一步粗略校驗),前置校驗,如果已經沒有庫存,前置攔截生效,減少對數據庫的寫操作。

畢竟讀操作不涉及加鎖,並發性能高。


數據庫包含兩張表:庫存表、流水表。

庫存表:

字段 說明
sku_id 商品id
leaved_amount 剩余可購買數量

當用戶進行取消訂單、申請退貨退款,需要把數量加回來。如果商家補過庫存,需要在此基礎上額外加上增量庫存。

流水表:

字段 說明
id 主鍵
sku_id 商品id
order_detail_id 訂單明細id
quantity_trade 本次購買扣減數量

用於查看明細、對賬、盤貨、排查問題等。在扣減后,某些場景下需要返還也依賴流水。


單個商品的扣減SQL:

update inventory
set leaved_amount = leaved_amount - #{count}
where sku_id='123' and leaved_amount >= #{count}

此 SQL 采用數據庫自帶行鎖機制,在 where 條件里判斷此次購買的數量小於等於剩余的數量。

在扣減服務的代碼里,判斷此 SQL 的返回值,如果值為 1 ,表示扣減成功。否則,返回 0 ,表示庫存不足,需要回滾。

扣減成功后,需要記錄扣減流水,並與訂單明細記錄做關聯:

  • 當用戶歸還數量時,需要帶回此編號,用來標識此次返還屬於歷史上的具體哪次扣減。
  • 進行冪等性控制。當用戶調用扣減接口出現超時時,因為用戶不知道是否成功,用此編號進行重試或反查。在重試時,使用此編號進行標識防重。

數據庫扣減方案【第一次優化】

數據庫扣減方案第一次升級主要是針對庫存前置校驗模塊的優化,作為前置攔截器,承載的流量很大,如果將流量全部壓到主庫上,很容易把數據壓垮。我們考慮把數據庫架構升級。

image

  • 采用了讀寫分離方式,新增加了一套從庫,借助 MySQL 自帶的數據同步能力。庫存校驗時讀取從數據庫。
  • 當然,數據同步有一定的時間延遲,從庫的數據新鮮度有一定的滯后性,所以這個庫存校驗結果並不一定准確,但卻能攔截大部分的無效流量。
  • 最終能不能成功購買,由主庫的樂觀扣減 SQL 來控制,並不會影響最終扣減的准確性。大大減輕主庫的查詢壓力。

數據庫扣減方案【第二次優化】

引入了從庫,確實能分攤主庫很大一部分壓力,但是面對秒殺這種萬級 QPS 流量,MySQL 的千級 TPS 根本支撐不了,需要進一步升級讀取的性能。

image

  • 此時引入緩存中間件(如 Redis),將 MySQL 的數據定時同步到緩存中,庫存校驗模塊,從 Redis 中查詢剩余的庫存數據。由於緩存基於內存操作,性能比數據庫高出幾個數量級,單台 Redis 實例可以達到 10W QPS 的讀性能。
  • 該方案升級后,基本上解決了在前置庫存校驗環節及獲取庫存數量接口的性能問題,提高了系統整體性能,提供較好的用戶體驗。
  • 補充說明:如果並發量還是很高的話,可以考慮引入緩存集群,將不同的秒殺商品 SKU 盡量均勻分布在多個 Redis 節點中,從而分攤掉整體的峰值 QPS 壓力。(參考緩存熱點的解決方案)。

數據庫方案分析

優點:

  • 借助數據庫的 ACID 特性,業務上不會出現超賣、少買現象
  • 實現簡單,如果項目工期緊張,或者開發資源不足情況下非常適用

不足:

  • 如果參與秒殺的 SKU 非常多,最后的寫操作都是基於庫存主庫,性能壓力會比較大。

純緩存扣減方案

Redis 采用單線程的事件模型,具有原子性的特性。當有多個客戶端給 Redis 發送命令時,Redis 會按照接收到的順序串行化執行。對於還未被調度的命令,則放在隊列里排隊等待。

庫存扣減為了保證數據並發安全,要求原子性,而 Redis 正好滿足扣減類的特殊性要求,是個不錯的技術選型。

image

首先,設計Redis的數據模型

剩余庫存(k-v結構):
key:sku_leaved_amount_{sku_id}
value:剩余的庫存數值

流水(hash結構):
key:inventory_flow_{sku_id}
hash—key:訂單明細id(不同業務場景的全局性id,用來做冪等控制)
hash—value:本次購買的數量

  • 對於購物車下單,多個商品批量扣減,我們需要按單個商品循環發起 Redis 調用。但是多個 Redis 命令無法保證原子性。
  • 我們可以采用 Lua 腳本形式,將這些命令打包到一個腳本中,作為一個命令發送給 Redis 執行,從而保證了原子性。
  • Lua 是一個類似 JavaScript、Shell 等的解釋性語言,它可以完成 Redis 已有命令不支持的功能。用戶在編寫完 Lua 腳本之后,將此腳本上傳至 Redis 服務端,服務端會返回一個標識碼代表此腳本。
  • 在實際執行具體請求時,將數據和此標識碼發送至 Redis 即可。Redis 會和執行普通命令一樣,采用單線程執行此 Lua 腳本和對應數據。

Lua 腳本執行流程:

  • 首先根據訂單明細 id 查詢扣減流水,是否已經操作過,做冪等性校驗
  • 然后查詢商品的剩余庫存,並根據下單購買數做校驗,只要有一個商品數量不足,則返回失敗
  • 修改所有商品的緩存中的剩余庫存數
  • 緩存中插入扣減流水記錄

當 Redis 扣減成功后,應用程序再將此次扣減異步化保存到數據庫中,持久化存儲,畢竟 Redis 只是臨時性存儲,有宕機風險,會丟失數據。


緩存方案分析

  • Redis 緩存方案,借助了緩存的高性能,承載更高的並發。但是沒有數據庫的 ACID 特性,極端情況下,可能出現少賣情況。
  • 為了避免少賣情況發生,純緩存方案需要做大量的對賬、異常處理的設計,系統復雜度增加很多。
  • 純緩存方案適合一些高並發、大流量場景,但對數據准確度要求不是特別苛刻的業務場景。

風險:上述 Lua 腳本把多條命令打包在一起,雖然保證了原子性,但不具備事務回滾特性。

比如,庫存扣減成功了,此時 Redis 宕機,扣減流水並沒有插入成功,應用程序認為本次 Redis 調用是失敗的,前台給用戶反饋錯誤提示,但是已經扣減的數量不會回滾。

當 Redis 故障修復后,再次啟動,此時恢復的數據已經存在不一致了。需要結合 Redis 和數據庫做數據核對 check,並結合扣減服務的日志,做數據的增量修復。


基於分庫分表的扣減方案

除了純緩存化方案外,我們還可以考慮將庫存表進行水平拆分,分攤洪峰壓力。

image

假如庫存表的 QPS 要求是 1.6 萬,經過拆分成 16 張表后,如果數據分布均勻,每個物理表預計處理 1000 QPS,完全處於 MySQL 單實例的承載范圍之內。

另外拆分后,單表的數據量也會相應減少很多,假如分表前有一個億數據,分表后每張表不到 1 千萬,索引查詢性能也會快很多。

注意:同一次扣減業務,庫存扣減和插入流水要放在同一個分庫中,通過事務保證一致性,滿足同時成功或同時失敗。

如果數據分布和業務請求足夠均勻,理論上經過分庫分表設計后,整個系統的吞吐量將會是線性的增長,主要取決於分表實例的數量。


其他扣減方案

  1. 如果某個 sku_id 的庫存扣減過熱,單台實例支撐不了(MySQL 官方測評:一般單行更新的 QPS 在 500 以內),可以考慮將一個商品的大庫存拆分成 N 份,放在不同的庫中(也就是說所有子庫的庫存數總和才是一件商品的真實庫存),由於前台的訪問流量非常大,按照均分原則,每個子庫分到的流量應該差不多。

    上層路由時只需要在 sku_id 后面拼接一個范圍內的隨機數,即可找到對應的子庫,有效減輕系統壓力。

  2. 單條商品庫存記錄更新過熱,也可以采用批量提交方式,將多次扣減累計計數,集中成一次扣減,從而實現了將串行處理變成了批處理,也可以大大減輕數據庫壓力。

  3. 引入 RocketMQ 消息隊列,經過前置校驗后,如果有剩余庫存,則把創建訂單的操作封裝成消息發送給 MQ,訂單系統從 RocketMQ 中以特定的頻率消費,創建訂單,該方案有一定的延遲性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM