最近在看秒殺相關的項目,針對防止庫存超賣的問題,查閱了很多資料,其解決方案可以分爲悲觀鎖、樂觀鎖、分佈式鎖、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); ... |
當然除此之外還有很多其他解決方案,也有很多可以優化的地方,繼續學習吧~