背景
在電商系統中買商品過程,先加入購物車,然后選中商品,點擊結算,即會進入待支付狀態,后續支付。
過程需要檢驗庫存是否足夠,保證庫存不被超賣。
場景一:買家需要購買數量可以多件
場景二:秒殺活動,到時間點只能購買一件
目的
- 防止相同用戶重復下單
- 檢查庫存准確數量
- 防止扣錯庫存數量
- 扣庫存時性能效率提升、不阻塞用戶
點贊再看,關注公眾號:【地藏思維】給大家分享互聯網場景設計與架構設計方案
掘金:地藏Kelvin https://juejin.im/user/5d67da8d6fb9a06aff5e85f7
主要解決手段
- 利用redis的incr、decr的原子性做操作
- redis的lpush、rpop的原子性做操作,但是這個只能一個一個的扣,但不能原子地同時扣多個
- sql樂觀鎖
交互流程
主要環節:購物車->結清->支付
本文講述結清時,扣庫存環節,分布式系統產生訂單環節后續文章再詳細分析。
備注:挺推薦使用https://www.processon.com/在線來做流程圖的
一、防止重復
利用redis分布式鎖
用分布式鎖,是為了防刷、防止同一個用戶同一秒里面把購物車里的商品進行多次結算,防止前端代碼出問題觸發兩次。
利用Jedis客戶端編寫分布式鎖
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
lockKey是redis的Key,為用戶id+商品id+商品數量組成,這樣同一秒中只能有一次處理邏輯。
requestId是redis的value,實際是當前線程id,表示有一條線程占用。
大家要注意這種分布式鎖寫法,是同時設定超時時間的。有些分布式鎖的文章可能是比較舊版的redis不支持同時設置超時時間,他就一條語句先設置key value,另一條語句后設置超時時間。所以大家留意一下。
二、扣減庫存
安全扣減庫存方案有很多說法,列一下幾個方案和我推薦的方案。
方案一:分布式鎖
有的文章會用redis分布式鎖來做保證扣庫存數量准確的環節,讓點擊結算時,后端邏輯會查詢庫存和扣庫存的update語句同時只有一條線程能夠執行,以商品id為分布式鎖的key,鎖一個商品。但是這樣,其他購買相同商品的用戶將會進行等待。
- 優點:這樣做雖然安全
- 缺點:但是失去的是性能問題。
方案二:分布式鎖+分段緩存
也有文章會說借鑒ConcurrenthashMap,分段鎖的機制,把100個商品,分在3個段上,key為分段名字,value為庫存數量。用戶下單時對用戶id進行%3計算,看落在哪個redis的key上,就去取哪個。
如key1=product-01,value1=33;key2=product-02,value2=33;key3=product-03,value3=33;
其實會有幾個問題:
- 一個是用戶想買34件的時候,要去兩個片查
- 一個片上賣完了為0,又要去另外一個片查
- 取余方式計算每一片數量,除不盡時,讓最后一片補,如100/3=33.33。
缺點:
- 方案復雜
- 有遺留問題
方案三: redis的lpush rpop
redis隊列的lpush、rpop都是只能每次進出一個,對於購買多個數量的情況下不適用,只適用於秒殺情況購買一個的場景、或者搶紅包的場景,所以覺得不是很通用。
備注:這個搶紅包場景以后再分享。
方案四:推薦使用redis原子操作+sql樂觀鎖
利用Redis increment 的原子操作,保證庫存數安全
-
先查詢redis中是否有庫存信息,如果沒有就去數據庫查,這樣就可以減少訪問數據庫的次數。
獲取到后把數值填入redis,以商品id為key,數量為value。
注意要設置序列化方式為StringRedisSerializer,不然不能把value做加減操作。
還需要設置redis對應這個key的超時時間,以防所有商品庫存數據都在redis中。 -
比較下單數量的大小,如果夠就做后續邏輯。
-
執行redis客戶端的increment,參數為負數,則做減法。因為redis是單線程處理,並且因為increment讓key對應的value 減少后返回的是修改后的值。
有的人會不做第一步查詢直接減,其實這樣不太好,因為當庫存為1時,很多做減3,或者減30情況,其實都是不夠,這樣就白減。 -
扣減數據庫的庫存,這個時候就不需要再select查詢,直接樂觀鎖update,把庫存字段值減1 。
-
做完扣庫存就在訂單系統做下單。
樣例場景:
- 假設兩個用戶在第一步查詢得到庫存等於10,A用戶走到第二步扣10件,同時一秒內B用戶走到第二部扣3件。
- 因為redis單線程處理,若A用戶線程先執行redis語句,那么現在庫存等於0,B就只能失敗,就不會出更新數據庫了。
public void order(OrderReq req) {
String key = "product:" + req.getProductId();
// 第一步:先檢查 庫存是否充足
Integer num = (Integer) redisTemplate.get(key);
if (num == null){
// 去查數據庫的數據
// 並且把數據庫的庫存set進redis,注意使用NX參數表示只有當沒有redis中沒有這個key的時候才set庫存數量到redis
//注意要設置序列化方式為StringRedisSerializer,不然不能把value做加減操作
// 同時設置超時時間,因為不能讓redis存着所有商品的庫存數,以免占用內存。
if (count >=0) {
//設置有效期十分鍾
redisTemplate.expire(key, 60*10+隨機數防止雪崩, TimeUnit.SECONDS);
}
// 減少經常訪問數據庫,因為磁盤比內存訪問速度要慢
}
if (num < req.getNum()) {
logger.info("庫存不足");
}
// 第二步:減少庫存
long value = redisTemplate.increment(key, -req.getNum().longValue());
// 庫存充足
if (value >= 0) {
logger.info("成功購買");
// update 數據庫中商品庫存和訂單系統下單,單的狀態未待支付
// 分開兩個系統處理時,可以用LCN做分布式事務,但是也是有概率會訂單系統的網絡超時
// 也可以使用最終一致性的方式,更新庫存成功后,發送mq,等待訂單創建生成回調。
boolean res= updateProduct(req);
if (res)
createOrder(req);
} else {
// 減了后小小於0 ,如兩個人同時買這個商品,導致A人第一步時看到還有10個庫存,但是B人買9個先處理完邏輯,
// 導致B人的線程10-9=1, A人的線程1-10=-9,則現在需要增加剛剛減去的庫存,讓別人可以買1個
redisTemplate.increment(key, req.getNum().longValue());
logger.info("恢復redis庫存");
}
}
update使用樂觀鎖
updateProduct方法中執行的sql如下:
update Product set count = count - #{購買數量} where id = #{id} and count - #{購買數量} >= 0;
雖然redis已經防止了超賣,但是數據庫層面,為了也要防止超賣,以防redis崩潰時無法使用或者不需要redis處理時,則用樂觀鎖,因為不一定全部商品都用redis。
利用sql每條單條語句都是有事務的,所以兩條sql同時執行,也就只會有其中一條sql先執行成功,另外一條后執行,也如上文提及到的場景一樣。
簡單說一下分布式事務:
分開兩個系統處理庫存和訂單時,這個時候可以用LCN框架做分布式事務,但是因為是http請求的,也是有概率會訂單系統的網絡超時,導致未返回結果。
其實也可以使用最終一致性的方式,數據表記錄一條交互流水記錄,更新庫存成功后,更新這個交互流水記錄的庫存操作字段為已處理,訂單處理字段為處理中,然后發送mq,等待訂單創建生成回調。也要做定時任務做主動查詢訂單系統的結果,以防沒有結果回來。
方案優勢
- 不需要頻繁訪問數據庫商品庫存還有多少
- 不阻塞其他用戶
- 安全扣減庫存量
- 內存訪問庫存數量,減少數據庫交互
高並發額外優化
- 用戶訪問下單是,前端ui可以讓用戶觸發結算后,把按鈕置灰色,防止重復觸發。
- 可以按照庫存數量來選定是否要用redis,因為如果庫存數量少,或者說最近下單次數少的商品,就不用放redis,因為少人看和買的情況下,不必放redis導致占用內存。
- 如果到時間點搶購時,可以使用mq隊列形式,用戶觸發購買商品后,進入隊列,讓用戶的頁面一直在轉圈圈,等輪到他買的時候再進入結算頁面,結算頁面的后續流程和本文一致。
歡迎關注
我的公眾號 :地藏思維
掘金:地藏Kelvin
簡書:地藏Kelvin
我的Gitee: 地藏Kelvin https://gitee.com/kelvin-cai