前言:
隨著中國消費認知的不斷升級,網購走近千家萬戶,越來越被人們所接受。淘寶、唯品會、考拉、京東、拼多多等逐漸成為我們生活的重要組成部分。
除了常規的購物下單外,這些電商平臺還經常搞一些雙十一活動,秒殺、大促、限時購,各種營銷玩法,層出不窮! 今天就來跟大家聊一聊電商技術里的庫存扣減。

當有很多人同時在買一件商品時(假設庫存充足),每個人幾乎同時下單成功,給人一種并行的感覺。但真實情況, 庫存只是一個數值,無論是存在MySQL數據庫還是redis緩存,減值時都要控制順序,只能串行來扣減,當然為了保證安全性,會設計一些鎖控制操作。
palm_tree: 庫存扣減關鍵技術點
- 同一個SKU,庫存數量是共享
- 剩余庫存要大于等于本次扣減的數量,否則會出現 超賣 現象,引發資損
- 對同一個數量多用戶并發扣減時,要注意并發安全,保證數據的一致性
- 類似于秒殺這樣高QPS的扣減場景,要保證性能與高可用
- 對于購物車下單場景,多個商品庫存批量扣減,要保證事務
- 如果有 交易退款 ,保證庫存扣減可以返還
- 返還的數據總量不能大于扣減的總量
- 返還要保證冪等
- 可以分多次返還
palm_tree: 數據庫扣減方案
主要是依賴數據庫特性來保證扣減的一致性,邏輯簡單,開發部署成本很低。
依賴的數據庫特性:
- 依賴數據庫的樂觀鎖(比如:版本號或者庫存數量)保證數據并發扣減的強一致性
- 借助事務特性,針對購物車下單批量扣減時,部分扣減失敗,數據回滾

最上面會查詢當前的剩余庫存(可能不準確,但沒關系,這里只是第一步粗略校驗),前置校驗,如果已經沒有庫存,前置攔截生效,減少對數據庫的寫操作。畢竟讀操作不涉及加鎖,并發性能高。數據庫包含兩張表:庫存表、流水表。
1、庫存表
字段 |
說明 |
sku_id |
商品規格id |
leaved_amount |
剩余可購買數量 |
- 當用戶進行取消訂單、申請退貨退款,需要把數量加回來
- 如果商家補過庫存,需要在此基礎上額外加上增量庫存
2、 流水表
字段 |
說明 |
id |
主鍵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 ,表示庫存不足,需要回滾。
扣減成功后,需要記錄扣減流水,并與訂單明細記錄做關聯。
- 當用戶歸還數量時,需要帶回此編號,用來標識此次返還屬于歷史上的具體哪次扣減。
- 進行冪等性控制。當用戶調用扣減接口出現超時時,因為用戶不知道是否成功,用此編號進行重試或反查。在重試時,使用此編號進行標識防重。
palm_tree: 【數據庫扣減方案】第一次升級
舉個極端的例子:最新款iphone秒殺,庫存只有5件,活動期間峰值QPS預估在10W,活動結束后,上面的流水表最終只會插入5條記錄,但是查詢的QPS卻接近 10W QPS ,讀的壓力非常大。
所以,數據庫扣減方案第一次升級主要是針對 庫存前置校驗 模塊的優化,作為前置攔截器,承載的流量很大,如果將流量全部壓到主庫上,很容易把數據壓垮。我們考慮把數據庫架構升級。

采用了 讀寫分離 方式,新增加了一套從庫,借助mysql自帶的數據同步能力。 庫存校驗 時讀取從數據庫。
當然,數據同步有一定的時間延遲,從庫的數據新鮮度有一定的滯后性,所以這個 庫存校驗 結果并不一定準確,但卻能攔截大部分的 無效流量 。最終能不能成功購買,由主庫的 樂觀扣減SQL來控制,并不會影響最終扣減的準確性。大大減輕主庫的查詢壓力。
palm_tree: 【數據庫扣減方案】第二次升級
引入了從庫,確實能分攤主庫很大一部分壓力,但是面對秒殺這種萬級QPS流量,mysql的 千級TPS 根本支撐不了,需要進一步升級讀取的性能。

- 此時引入緩存中間件(如Redis),將mysql的數據定時同步到緩存中
- 庫存校驗 模塊,從redis中查詢剩余的庫存數據。由于緩存基于內存操作,性能比數據庫高出幾個數量級,單臺redis實例可以達到10W QPS的讀性能
該方案升級后,基本上解決了在前置 庫存校驗 環節及 獲取庫存數量接口 的性能問題,提高了系統整體性能,提供較好的用戶體驗。
補充說明:
如果并發量還是很高的話,可以考慮引入 緩存集群 ,將不同的 秒殺商品sku 盡量均勻分布在多個redis節點中,從而分攤掉整體的峰值QPS壓力。(參考緩存熱點的解決方案)
數據庫方案的優點:
- ACID 超賣 少買
- 實現簡單,如果項目工期緊張,或者開發資源不足情況下非常適用
數據庫方案的不足:
- 如果參與秒殺的SKU非常多,最后的寫操作都是基于 庫存主庫 ,性能壓力會比較大。
palm_tree: 純緩存扣減方案
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 腳本執行流程:
批量扣減是對單個扣減的循環調用,所以這里介紹的流程只講單次扣減的處理步驟。
- 首先根據 訂單明細id 查詢扣減流水,是否已經操作過,做冪等性校驗
- 然后查詢sku的剩余庫存,并根據 下單購買數 做校驗,只要有一個sku 數量不足,則返回失敗
- 修改所有sku的緩存中的剩余庫存數
- 緩存中插入扣減流水記錄
當Redis扣減成功后,應用程序再將此次扣減 異步化 保存到數據庫中,持久化存儲,畢竟Redis只是臨時性存儲,有宕機風險,會丟失數據。
緩存方案利弊分析:
- Redis 高性能 ACID 少賣
- 為了避免 少賣 情況發生, 純緩存方案 需要做大量的對賬、異常處理的設計,系統復雜度增加很多。
- 純緩存方案 適合一些高并發、大流量場景,但對數據準確度要求不是特別苛刻的業務場景。
風險:
上述 Lua腳本 把多條命令打包在一起,雖然保證了原子性,但不具備 事務回滾 特性。比如,庫存扣減成功了,此時 Redis宕機 ,扣減流水并沒有插入成功,應用程序認為本次 Redis調用 是失敗 的,前臺給用戶反饋錯誤提示,但是已經扣減的數量不會回滾。當Redis故障修復后,再次啟動,此時恢復的數據已經存在不一致了。需要結合 Redis 和 數據庫 做數據核對check,并結合扣減服務的日志,做數據的增量修復。
palm_tree: 基于分庫分表的扣減方案
上面提到的數據庫方式是基于 單庫單表 玩法,雖然借助 ACID 特性能保證數據的一致性,但是單臺mysql的并發能力有限,如何提升性能?
除了 純緩存 化方案外,我們還可以考慮將 庫存表 進行 水平拆分 ,分攤洪峰壓力。

假如庫存表的QPS要求是1.6萬,經過拆分成16張表后,如果數據分布均勻,每個物理表預計處理 1000 QPS,完全處于mysql單實例的承載范圍之內。
另外拆分后,單表的數據量也會相應減少很多,假如分表前有一個億數據,分表后每張表不到1千萬,索引查詢性能也會快很多。
注意:
同一次扣減業務,庫存扣減和插入流水要放在同一個分庫中,通過事務保證一致性,滿足同時成功或同時失敗。如果數據分布和業務請求足夠均勻,理論上經過分庫分表設計后,整個系統的吞吐量將會是線性的增長,主要取決于分表實例的數量。
palm_tree: 其他扣減方案
還有其他的一些解決方案,這里只是提供一些思路,方案細節就不展開了
1、如果某個sku_id的庫存扣減過熱,單臺實例支撐不了( mysql官方測評:一般單行更新的QPS在500以內 ),可以考慮將一個sku的大庫存拆分成N份,放在不同的庫中(也就是說所有子庫的庫存數總和才是一件sku的真實庫存),由于前臺的訪問流量非常大,按照 均分原則 ,每個子庫分到的流量應該差不多。上層路由時只需要在 sku_id 后面拼接 一個范圍內的隨機數 ,即可找到對應的子庫,有效減輕系統壓力。
2、單條sku庫存記錄更新過熱,也可以采用批量提交方式,將多次扣減累計計數,集中成一次扣減, 從而實現了將串行處理變成了批處理 ,也可以大大減輕數據庫壓力。
3、引入 RocketMQ 消息隊列,經過前置校驗后,如果有剩余庫存,則把創建訂單的操作封裝成消息發送給MQ,訂單系統從RocketMQ中以特定的頻率消費,創建訂單,該方案有一定的延遲性。
原文:
https://mp.weixin.qq.com/s/jJTIBL8unJ-IRbDqgREsCw