1、場景
在電商系統中買商品過程,先加入購物車,然后選中商品,點擊結算,即會進入待支付狀態,后續支付。 過程需要檢驗庫存是否足夠,保證庫存不被超賣。
場景一:買家需要購買數量可以多件
場景二:秒殺活動,到時間點只能購買一件
2、要解決的問題
- 防止相同用戶重復下單
- 檢查庫存准確數量
- 防止扣錯庫存數量
- 扣庫存時性能效率提升、不阻塞用戶
3、解決方案分析
主要技術手段:
利用redis的incr、decr的原子性做操作
redis的lpush、rpop的原子性做操作,但是這個只能一個一個的扣,但不能原子地同時扣多個
sql樂觀鎖
問題1:防止重復
用分布式鎖,是為了防刷、防止同一個用戶同一秒里面把購物車里的商品進行多次結算,防止前端代碼出問題觸發兩次。 利用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,表示有一條線程占用。
問題2:扣減庫存
方案一:分布式鎖
使用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都是只能每次進出一個,對於購買多個數量的情況下不適用,只適用於秒殺情況購買一個的場景、或者搶紅包的場景,所以覺得不是很通用。
對於限時秒殺每次只能搶一個的場景,如果商品數量不多可以開搶前一次性預熱將商品ID全都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,等待訂單創建生成回調。也要做定時任務做主動查詢訂單系統的結果,以防沒有結果回來。因為下單很多時候還會設計優惠券、積分、活動相關的功能,使用mq還可以做到各模塊的解耦。
方案優勢
- 不需要頻繁訪問數據庫商品庫存還有多少
- 不阻塞其他用戶
- 安全扣減庫存量
- 內存訪問庫存數量,減少數據庫交互
高並發額外優化
- 用戶訪問下單是,前端ui可以讓用戶觸發結算后,把按鈕置灰色,防止重復觸發。
- 可以按照庫存數量來選定是否要用redis,因為如果庫存數量少,或者說最近下單次數少的商品,就不用放redis,因為少人看和買的情況下,不必放redis導致占用內存。
- 如果到時間點搶購時,可以使用mq隊列形式,用戶觸發購買商品后,進入隊列,讓用戶的頁面一直在轉圈圈,等輪到他買的時候再進入結算頁面,結算頁面的后續流程和本文一致。
