高併發下防止庫存超賣的解決方案

最近在看秒殺相關的項目,針對防止庫存超賣的問題,查閱了很多資料,其解決方案可以分爲悲觀鎖、樂觀鎖、分佈式鎖、Redis原子操作、隊列串行化等等,這裏進行淺顯的記錄總結。

首先我們來看下庫存超賣問題是怎樣產生的:

1

2

3

4

5

6

//1.查詢出商品庫存信息

select stock from t_goods where id=1;

//2.根據商品信息生成訂單

insert into t_orders (id,goods_id) values (null,1);

//3.修改商品庫存

update t_goods set stock=stock-1 where id=1;

 

在高併發場景下,如果同時有兩個線程a和b,同時查詢到商品庫存爲1,他們都認爲存庫充足,於是開始下單減庫存。如果線程a先完成減庫存操作,庫存爲0,接着線程b也是減庫存,於是庫存就變成了-1,商品被超賣了。

下面讓我們來看看針對庫存超賣問題的解決方案;

解決方案一:悲觀鎖

所謂悲觀鎖,即悲觀的認爲自己在操作數據庫時,會大機率出現併發,於是在操作前會先進行加鎖,操作完成後再釋放鎖。如果加鎖失敗說明該記錄正在被修改,那麼當前操作可以等待後嘗試。

以我們常用的MySQL爲例,行鎖、表鎖、排他鎖等都是悲觀鎖,爲避免衝突,會在操作時先加鎖,其他線程必須等待它的完成。

這裏我們通過使用select...for update語句,在查詢商品表庫存時將該條記錄加鎖,待下單減庫存完成後,再釋放鎖。

1

2

3

4

5

6

7

8

9

10

//0.開始事務

begin;/begin work;/start transaction; (三者選一就可以)

//1.查詢出商品信息

select stock from t_goods where id=1 for update;

//2.根據商品信息生成訂單

insert into t_orders (id,goods_id) values (null,1);

//3.修改商品stock減一

update t_goods set stock=stock-1 where id=1;

//4.提交事務

commit;

這樣可以解決併發時庫存超賣的問題,然而高併發時,所有的操作都被串行化了,效率很低,將嚴重影響系統的吞吐量。而且使用悲觀鎖還有可能造成死鎖問題。

解決方案二:樂觀鎖

現在我們嘗試下使用樂觀鎖,所謂樂觀鎖,是相對於悲觀鎖而言的,它假設數據一般情況下不會發生併發,因此不會對數據進行加鎖,操作完成提交時纔對數據是否衝突進行檢測,如果發現衝突則返回錯誤。

比較常見的實現方式是,在表中增加一個version字段,操作前先查詢version信息,在數據提交時檢查version字段是否被修改,如果沒有被修改則進行提交,否則認爲是過期數據。

1

2

3

4

5

6

//1.查詢出商品信息

select stock, version from t_goods where id=1;

//2.根據商品信息生成訂單

insert into t_orders (id,goods_id) values (null,1);

//3.修改商品庫存

update t_goods set stock=stock-1, version = version+1 where id=1, version=version;

這樣,在併發時,如果線程a嘗試修改商品庫存時,發現版本號已經被線程b修改了,線程a執行update語句條件不滿足便不再執行了,庫存也不會被超賣。

但是這種樂觀鎖的方式,在高併發時,只有一個線程能執行成功,會造成大量的失敗,這給用戶的體驗顯然是很不好的。

這裏我們可以減小鎖的顆粒度,最大程度提升系統的吞吐量,提高併發能力:

1

2

//修改商品庫存時判斷庫存是否大於0

update t_goods set stock=stock-1 where id=1 and stock>0;

上面的update語句通過stock>0進行樂觀鎖的控制,在執行時,會在一次原子操作中查詢stock的值,並扣減一。

解決方案三:分佈式鎖

除了在數據庫層面加鎖,我們還可以通過在內存中加鎖,實現分佈式鎖。例如我們可以在Redis中設置一個鎖,拿到鎖的線程搶購成功,拿不到鎖的搶購失敗。

Redis的setnx方法可以實現鎖機制,key不存在時創建,並設置value,返回值爲1;key存在時直接返回0。線程調用setnx方法成功返回1認爲加鎖成功,其他線程要等到當前線程業務操作完成釋放鎖後,才能再次調用setnx加鎖成功。

Long TIMEOUT_SECOUND = 120000L;

Jedis client = jedisPool.getResource();

//線程設置lock鎖成功

while(client.setnx("lock",String.valueOf(System.currentTimeMillis())) == 1){

Long lockTime = Long.valueOf(client.get("lock"));

//持有鎖超時後自動釋放鎖

if (lockTime!=null && System.currentTimeMillis() > lockTime+TIMEOUT_SECOUND){

client.del("lock");

}

Thread.sleep(10000);

}

......

......

client.del("lock");

解決方案四:Redis原子操作

雖然通過以上方按可以防止庫存超賣,但是高併發情況下對數據庫進行頻繁操作,會造成嚴重的性能問題。因此我們必須在前端對請求進行限制。

我們可以在Redis中設置一個隊列key爲商品的id,隊列的長度爲商品庫存量。每次請求到達時pop出一個元素,這樣拿到元素的請求即認爲秒殺成功,後續通過MQ發送消息異步完成數據庫減庫存操作。沒有拿到元素的請求即認爲秒殺失敗。

由於Redis是工作線程是單線程的,而list的pop操作是原子性的,因此併發的請求都被串行化了,庫存就不會超賣了。

//獲取商品庫存

String token = redisTemplate.opsForList().leftPop(goodsStock);

if(token == null){

log.info(">>>商品已售空");

return setResultError("親,該秒殺已經售空,請下次再來!");

}

//異步發送MQ消息,執行數據庫操作

sendSecondKillMsg(goodsId, userId);

...

當然除此之外還有很多其他解決方案,也有很多可以優化的地方,繼續學習吧~

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