分佈式項目線程安全問題(電商扣減庫存的安全問題1)

電商減庫存存在的安全問題

@Override
public void deductStock(Map<Long, Integer> skuMap) {
    for (Map.Entry<Long, Integer> entry : skuMap.entrySet()) {
        Long skuId = entry.getKey();
        Integer num = entry.getValue();
        // 查詢sku
        Sku sku = getById(skuId);
        //  判斷庫存是否充足
        if (sku.getStock() < num) {
            // 如果不足,拋出異常
            throw new LyException(400, "庫存不足!");
        }

        // 如果充足,扣減庫存 update tb_sku set stock = stock - 1, sold = sold + 1  where id = 1
        Map<String,Object> param = new HashMap<>();
        param.put("id", skuId);
        param.put("num", num);
        getBaseMapper().deductStock(param);
    }
}

上面這樣的操作存在安全風險,因爲我們的代碼是允許多線程的環境,當多個用戶併發訪問時,先判斷庫存是否充足,會出現一種情況:

  • 判斷的時候,庫存是充足的,但是在減庫存之前,有其它線程搶先一步,扣減庫存,導致庫存不足了,此時就會出現超賣現象!

思路1,同步鎖
按照以往的思路,我們應該怎麼做?

  • 我們一般需要加同步鎖,synchronized,目的是讓多線程執行,從而保證線程安全,但是加Synchronized只能保證當前jvm內的線程安全。
  • 如果搭建一個微服務集羣,同步鎖synchronized就失效了,原因是因爲線程所,在進程時會失效,因爲每個進程都有自己的鎖。
  • 解決多進行安全的問題,必須使用進程鎖(分佈式鎖):
  • 在這裏插入圖片描述

標題:分佈式鎖

分佈式鎖其實可以這樣理解爲:控制分佈式系統有序的去對共享資源進行操作,通過互斥保持一致性
舉個不恰當的例子:假設共享資源就是一個房子,裏面有各種各樣的書,分佈式系統就是要進屋子裏看書的那個人,分佈式鎖就是保證這房子裏面,只有一個門,並且一次只能一個人進去,而且門只有一把鑰匙,然後許多人進去看書,可以,排隊,第二個人沒有鑰匙,那就等着,等第一個人出來,然後你在拿這鑰匙進去,就這樣以此類推。

二:實現原理

  • 互斥性
  • 保證同一時間只要一個客戶端可以拿到鎖,也就是可以對共享資源進行操作
  • 安全性
  • 只有加鎖的服務纔能有解鎖的權限,也就是不能讓a加的鎖,bcd都可以解鎖,如果都能解鎖,那分佈式就沒有意義了
  • 可能出現的情況就是a去查詢發現持有鎖,就在準備解鎖,這時候突然a持有的鎖過期了,然後b就去獲取鎖,因爲a鎖過期,b拿到鎖,這時候a繼續執行第二部進行解鎖如果不加校驗,就將b持有的鎖就給刪除了
    避免死鎖
  • 出現死鎖就會導致後續的任何服務都拿不到鎖,不能在對共享資源進行任何操作了
  • 保證加鎖與解鎖操作是原子性操作
  • 這個其實屬於是實現分佈式鎖的問題,假設a用redis實現分佈式鎖
  • 假設枷鎖操作,操作步驟分爲兩步
  • 設置key set(key ,value) 2:給key設置過期時間
  • 假設現在a剛實現set後,程序崩了就導致了沒給key設置過期,時間就導致key一直存在就發生了死鎖

三.使用redis實現分佈式鎖

首先,爲了確保分佈式鎖可用,我們至少要確保鎖的實現同時滿足一下四個條件

  • 互斥性,在任意時刻,只要一個客戶端能持有鎖

  • 不會發生死鎖,即使有一個客戶端持有鎖的時候崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖

  • 具有容錯性,只要大部分的redis節點正常允許,客戶段就可以加鎖和解鎖

  • 解鈴還須繫鈴人,枷鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

    可以看到,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

  • 第一個爲key,我們使用key來當鎖,因爲key是唯一的。

  • 第二個爲value,我們傳的是requestId,很多童鞋可能不明白,有key作爲鎖不就夠了嗎,爲什麼還要用到value?原因就是我們在上面講到可靠性時,分佈式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值爲requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。

  • 第三個爲nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;

  • 第四個爲expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數決定。

  • 第五個爲time,與第四個參數相呼應,代表key的過期時間。

總的來說,執行上面的set()方法就只會導致兩種結果:1. 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。

心細的童鞋就會發現了,我們的加鎖代碼滿足我們可靠性裏描述的三個條件。首先,set()加入了NX參數,可以保證如果已有key存在,則函數不會調用成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設置了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因爲到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最後,因爲我們將value賦值爲requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。

思路2,數據庫排它鎖

數據庫鎖簡單來說有兩種:

  • 共享鎖:讀操作時會開啓共享鎖,此時大家都可以查詢
  • 排他鎖(互斥鎖):一般是寫操作會開啓排它鎖,此時其他事務無法獲取共享鎖或排它鎖,會阻塞

要保證安全,必須排它鎖
但是我們之前的業務是先查詢sku(讀),然後判斷是否充足,然後減庫存(寫),這樣就會導致多個請求同時查詢到一樣的庫存,減庫存還是有安全問題
我們必須在查詢時加排他鎖,怎麼辦

  • 可以通過select …for update語法來開啓,但時我們要加鎖的商品不止一個,此時加鎖就是範圍鎖,甚至時表鎖,性能會有較大的影響

思路3:樂觀鎖

上述思路1和思路2都是枷鎖,實現互斥,保證線程的安全,我們稱之爲悲觀鎖。

  • 悲觀鎖:認爲線程安全問題一定會發生,因此會加鎖保證線程串行執行,從而保證安全,我們爲了追求性能,可以使用樂觀鎖的機制
  • 樂觀鎖,認爲線程安全的問題一定會發生,因爲允許許多線程並執行,一般會在執行那一刻進行判斷和比較,然後根據是否存在風險來決定是否執行操作
  • 舉例說明,可以給庫存表加一個字段,叫version
id  stock    version
10	10			1

執行更新前,先查詢庫存及version
然後判斷庫存是否充足,如果充足,執行sql

update tb_stock set stock = stock - #{num}, version = version + 1 WHERE id = #{id} AND version = 1

樂觀鎖就先比較執行的思路,其實就是CAS(compare and set)的思想。
CAS的思想在很多地方都使用,例如

  • JDK的JUC包下的AtomicInteger,AtomicLong等等
  • Redis的watch,也是樂觀鎖,CAS原理

簡化:我們在減庫存中,可以用stock來代替version,執行sql時判斷stock是否跟自己查詢到一樣

update tb_stock set stock = stock - 1 WHERE id = 10 AND stock = 10

思路4:繼續簡化
我們可以不查詢庫存,直接執行sql,在sql語句中做判斷
語句是這樣的

update tb_stock set stock = stock - 1 WHERE id = 10 AND stock >= 1

思路5:繼續簡化
我們最終的目的是,庫存不能超賣,不能爲負數,因爲我們可以設置stock字段爲無符號整數,數據庫自動會寫入數據字段,如果爲負,會拋出異常,我們就無需枷鎖或任何其他判斷了
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章