電商庫存扣減如何設計?不超賣!

隨着中國消費認知的不斷升級,網購走近千家萬戶,越來越被人們所接受。淘寶、唯品會、考拉、京東、拼多多等逐漸成爲我們生活的重要組成部分。

除了常規的購物下單外,這些電商平臺還經常搞一些雙十一活動,秒殺、大促、限時購,各種營銷玩法,層出不窮。今天就來跟大家聊一聊電商技術裏的庫存扣減

當有很多人同時在買一件商品時(假設庫存充足),每個人幾乎同時下單成功,給人一種並行的感覺。但真實情況,庫存只是一個數值,無論是存在 mysql 數據庫還是 redis 緩存,減值時都要控制順序,只能串行來扣減,當然爲了保證安全性,會設計一些鎖控制操作。

🌴 庫存扣減關鍵技術點

🌴 數據庫扣減方案

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

依賴的數據庫特性:

最上面會查詢當前的剩餘庫存(可能不準確,但沒關係,這裏只是第一步粗略校驗),前置校驗,如果已經沒有庫存,前置攔截生效,減少對數據庫的寫操作。畢竟讀操作不涉及加鎖,併發性能高。數據庫包含兩張表:庫存表、流水錶。

1、庫存表

a6Npx9

2、 流水錶

NAc0Vw

單條商品的扣減 SQL 大致如下:

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

此 SQL 採用數據庫自帶行鎖機制,在 where 條件裏判斷此次購買的數量小於等於剩餘的數量。在扣減服務的代碼裏,判斷此 SQL 的返回值,如果值爲 1 ,表示扣減成功。否則,返回 0 ,表示庫存不足,需要回滾。

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

  1. 當用戶歸還數量時,需要帶回此編號,用來標識此次返還屬於歷史上的具體哪次扣減。

  2. 進行冪等性控制。當用戶調用扣減接口出現超時時,因爲用戶不知道是否成功,用此編號進行重試或反查。在重試時,使用此編號進行標識防重。

🌴 【數據庫扣減方案】第一次升級

舉個極端的例子:最新款 iPhone 秒殺,庫存只有 5 件,活動期間峯值 QPS 預估在 10W,活動結束後,上面的流水錶最終只會插入 5 條記錄,但是查詢的 QPS 卻接近 10W QPS,讀的壓力非常大。

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

採用了讀寫分離方式,新增加了一套從庫,藉助 mysql 自帶的數據同步能力。庫存校驗時讀取從數據庫。

當然,數據同步有一定的時間延遲,從庫的數據新鮮度有一定的滯後性,所以這個庫存校驗結果並不一定準確,但卻能攔截大部分的無效流量。最終能不能成功購買,由主庫的樂觀扣減SQL來控制,並不會影響最終扣減的準確性。大大減輕主庫的查詢壓力。

🌴 【數據庫扣減方案】第二次升級

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

該方案升級後,基本上解決了在前置庫存校驗環節及獲取庫存數量接口的性能問題,提高了系統整體性能,提供較好的用戶體驗。

補充說明:

如果併發量還是很高的話,可以考慮引入緩存集羣,將不同的秒殺商品sku儘量均勻分佈在多個 redis 節點中,從而分攤掉整體的峯值 QPS 壓力。(參考緩存熱點的解決方案)

數據庫方案的優點:

數據庫方案的不足:

🌴 純緩存扣減方案

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

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

下面,我們簡單來看看基於Redis如何來設計庫存扣減?

首先,設計 Redis 的數據模型:

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

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

對於購物車下單,多個 sku 批量扣減,我們需要按單個 sku 循環發起 Redis 調用。但是多個 Redis 命令無法保證原子性。我們可以採用lua腳本形式,將這些命令打包到一個腳本中,作爲一個命令發送給 Redis 執行,從而保證了原子性。

lua 是一個類似 JavaScript、Shell 等的解釋性語言,它可以完成 Redis 已有命令不支持的功能。用戶在編寫完 lua 腳本之後,將此腳本上傳至 Redis 服務端,服務端會返回一個標識碼代表此腳本。在實際執行具體請求時,將數據和此標識碼發送至 Redis 即可。Redis 會和執行普通命令一樣,採用單線程執行此 lua 腳本和對應數據。

Lua 腳本執行流程:

批量扣減是對單個扣減的循環調用,所以這裏介紹的流程只講單次扣減的處理步驟。

  1. 首先根據訂單明細id查詢扣減流水,是否已經操作過,做冪等性校驗

  2. 然後查詢 sku 的剩餘庫存,並根據下單購買數做校驗,只要有一個 sku 數量不足,則返回失敗

  3. 修改所有 sku 的緩存中的剩餘庫存數

  4. 緩存中插入扣減流水記錄

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

緩存方案利弊分析:

風險:

上述Lua腳本把多條命令打包在一起,雖然保證了原子性,但不具備事務回滾特性。比如,庫存扣減成功了,此時Redis宕機,扣減流水並沒有插入成功,應用程序認爲本次Redis調用失敗的,前臺給用戶反饋錯誤提示,但是已經扣減的數量不會回滾。當 Redis 故障修復後,再次啓動,此時恢復的數據已經存在不一致了。需要結合Redis數據庫做數據覈對 check,並結合扣減服務的日誌,做數據的增量修復。

🌴 基於分庫分表的扣減方案

上面提到的數據庫方式是基於單庫單表玩法,雖然藉助ACID特性能保證數據的一致性,但是單臺 mysql 的併發能力有限,如何提升性能?

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

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

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

注意:

同一次扣減業務,庫存扣減和插入流水要放在同一個分庫中,通過事務保證一致性,滿足同時成功或同時失敗。如果數據分佈和業務請求足夠均勻,理論上經過分庫分表設計後,整個系統的吞吐量將會是線性的增長,主要取決於分表實例的數量。

🌴 其他扣減方案

還有其他的一些解決方案,這裏只是提供一些思路,方案細節就不展開了

1、如果某個 sku_id 的庫存扣減過熱,單臺實例支撐不了(mysql 官方測評:一般單行更新的 QPS 在 500 以內),可以考慮將一個 sku 的大庫存拆分成 N 份,放在不同的庫中(也就是說所有子庫的庫存數總和纔是一件 sku 的真實庫存),由於前臺的訪問流量非常大,按照均分原則,每個子庫分到的流量應該差不多。上層路由時只需要在sku_id後面拼接一個範圍內的隨機數,即可找到對應的子庫,有效減輕系統壓力。

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/L9XL2fQ_wOw4FqjQiiUvow