1. 程式人生 > 其它 >高併發場景-訂單庫存防止超賣

高併發場景-訂單庫存防止超賣

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中。

  1. 比較下單數量的大小,如果夠就做後續邏輯。

  2. 執行redis客戶端的increment,引數為負數,則做減法。因為redis是單執行緒處理,並且因為increment讓key對應的value 減少後返回的是修改後的值。 有的人會不做第一步查詢直接減,其實這樣不太好,因為當庫存為1時,很多做減3,或者減30情況,其實都是不夠,這樣就白減。

  3. 扣減資料庫的庫存,這個時候就不需要再select查詢,直接樂觀鎖update,把庫存欄位值減1 。

  4. 做完扣庫存就在訂單系統做下單。

  樣例場景:

  1. 假設兩個使用者在第一步查詢得到庫存等於10,A使用者走到第二步扣10件,同時一秒內B使用者走到第二部扣3件。
  2. 因為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佇列形式,使用者觸發購買商品後,進入佇列,讓使用者的頁面一直在轉圈圈,等輪到他買的時候再進入結算頁面,結算頁面的後續流程和本文一致。