前言
本文總結庫存領域建設庫存預佔能力時遇到的問題以及解決方案。感謝【金鵬】、【孫靜】、【陳瑞】同學在本文撰寫中提供的內容及幫助!
1、庫存預佔業務概述
消費者拍下商品訂單後,庫存系統先爲該訂單預留庫存,這個預留庫存的動作被稱爲庫存預佔。
在系統中,庫存預佔主要是對庫存數據進行扣減操作。例:假如一個商品有5個可用庫存,訂單購買了1個此商品,庫存系統需要把可用庫存的數量由5扣減爲4
庫存預佔屬於物流核心流程。如果預佔能力出問題,可能會導致商品無法正常售賣或者出現超賣。
2、庫存預佔能力建設面臨的挑戰及應對
秒殺活動、直播促銷等業務場景,往往會出現短時間內多個訂單都去預佔某一個或幾個商品庫存的情況。如何處理高併發場景中對熱點商品進行庫存扣減操作,是庫存預佔業務要面臨的主要技術挑戰
2.1 性能挑戰
多個線程併發對同一個數據庫商品數據做庫存扣減時,數據庫中會加鎖來保障數據被正確操作。當商品數據足夠【熱】時,大量的鎖等待會導致性能問題,見下圖:
過往線上業務對固定商品的預佔峯值在數百次/秒,而使用常規數據庫預佔方案,經壓測數據驗證,僅能支撐50次/秒。一旦發生熱點商品高頻預佔,TP99就會飆升,如果高頻預佔時間較長,會給系統帶來穩定性風險
2.1.1 解決方案調研
1、異步限流。讓熱點不那麼熱
在業務上允許的情況下,減緩預佔操作速度,從而降低熱點熱度,緩解庫存系統的性能壓力。見下圖:
優點:邏輯簡單,改造風險較小。從整個調用鏈路的角度去優化問題,而不是隻優化瓶頸點
缺點:與下單方的交互機制需要支持異步機制,可能涉及流程改造;
2、商品庫存橫向拆分,提升數據庫處理能力,降低併發請求時數據庫鎖的影響。將一個熱點拆成多個不那麼熱的點
(1)商品入庫時,將數量拆分爲N份,放入N個表或者一個表的N行中
(2)預佔時,根據預佔單據號取餘數,訪問不同的數據源進行預佔
假如單條記錄支撐的性能是50單/秒,那麼拆分成3份以後,支撐的性能就能提升到100+單/秒。見下圖:
優點:上游無感知,改造可控制在庫存領域
缺點:1、邏輯相對複雜,改造風險高
2、業務有損。存在有庫存,但是預佔不到的情況。例:(1)3個數據源都只有1個可用庫存,但是訂單上數量爲2,預佔不成功 (2)第一個數據源已經沒有庫存,其他數據源有庫存,但是訂單路由到了第一個數據源。可以使用其他子庫重試、子庫存加和後重試等方式解決此問題,但是邏輯複雜
3、使用緩存抗寫流量。提升熱點處理能力
熱點商品預佔的耗時主要集中在數據庫操作上,使用處理速度更快的redis緩存來替代數據庫來提供預佔能力,見下圖:
緩存的處理能力比DB高的多,經壓測,可以支撐熱點1200單/秒
優點:上游無感知,改造可控制在庫存領域;
缺點:處理邏輯複雜。需要增加緩存處理邏輯;需要保障緩存-db的數據一致性
2.1.2 各方案對比及選型
方案 | 是否能無損支持業務 | 實現成本 |
異步限流 | 否,僅無損支持與下單方異步交互的場景 | 低 |
商品庫存橫向拆分 | 否,會出現有庫存但無法預佔的情況 | 中 |
緩存抗寫流量 | 是 | 高 |
結合業務現狀,當前存在部分KA商家體量大,改造風險高,但是這部分KA商家,訂單已經與下單方是異步交互,所以這部分使用【異步限流】方案;其他商家統一使用【緩存抗寫流量】的方案
2.1.3 性能優化成果
通過優化,成功將熱點商品預佔TPS從50提升到1200,提升了24倍。TP99降低到130ms,降低至原時長的4.3%(從3000ms到130ms)。
橙色部分爲優化後的結果:
2.2 線程同步問題
問題定義:多個線程操作查詢、操作同一個商品的庫存,使庫存數據混亂
DB預佔模式
解決方案:利用mysql事務、行鎖機制來避免線程之間互相影響,在sql語句中操作變化量
a、定位庫存。使用商品id、倉庫id、庫存狀態等信息來定位庫存id
b、操作庫存。根據庫存id扣減庫存,set 當前庫存=當前庫存+操作量。該步驟mysql會在id上加互斥鎖,避免不同線程之間的互相影響。這裏使用批量更新,來提升一單操作多商品的性能
UPDATE stock
SET stock_num = stock_num + CASE id
WHEN 1 THEN 'value1'
WHEN 2 THEN 'value2'
WHEN 3 THEN 'value3'
END
WHERE id IN (1,2,3)
c、校驗庫存。爲了防止超賣,根據庫存id查詢庫存,如果訂單中任一商品庫存被扣減爲小於0,則拋出異常,使用數據庫事務機制進行回滾
緩存(redis)預佔模式
解決方案:將redis操作放入lua腳本中,利用redis單線程執行以及lua腳本執行過程中不會被其他操作語句插入的特性,避免線程間互相影響
2.3 死鎖問題
1、預佔流程間死鎖。多個訂單預佔商品,包含多個相同商品,多線程併發請求時,線程之間持有對方依賴的鎖,然後等待對方釋放自己依賴的鎖。見下圖:
2、多流程間死鎖。多種單據(預佔、採購、取消等)操作多種類型庫存,多線程併發請求時,線程之間持有對方依賴的鎖,然後等待對方釋放自己依賴的鎖。
注:當前物流庫存平臺需要進行操作的庫存數據可以分爲倉庫庫存、邏輯庫存、批次庫存。其中邏輯庫存、批次庫存可以看作對某一個倉庫庫存進行不同維度的拆分。
鎖排序,保持鎖的順序一致。在多個事務請求資源的情況下,要保持鎖的請求順序一致,從而保障線程順序執行。僞代碼如下:
public Result handleOccupyRequest(List<CalcOccupyRequest> paramList) {
//XX業務邏輯
//Long類型比較器,根據庫存id進行排序
Comparator<Long> comparator = new Comparator<Long>() {
@Override
public int compare(Long o1, Long o2) {
return o1.compareTo(o2);
}
};
//對要操作的各類庫存進行排序
if(saleableStockIds!=null){
Collections.sort(saleableStockIds, comparator);
}
if (otherStockIds!=null){
Collections.sort(otherStockIds, comparator);
}
//XX業務邏輯
}
2.4 數據一致性問題
問題定義:redis、db作爲兩個獨立的數據源,都需要維護庫存數據,如何保障兩個數據源的最終一致性?
這個問題又可以拆解爲
1、如何從流程處理機制上保障redis-db之間的數據最終一致性?
2、萬一出現了不一致,如何發現及解決?
如何從流程處理機制上保障redis-db之間的數據最終一致性
萬一出現了不一致,如何發現及解決
3、庫存預佔處理流程
緩存處理流程: