本篇,咱們來實現優惠券秒殺下單功能。通過本篇學習,我們將會有如下收穫:
1:優惠券領券業務邏輯;
2:分析在高併發情況下,出現超賣問題產生的原因;
3:解決超賣問題兩種方案:版本號法及CAS法
4:樂觀鎖弊端改進方案;
本文涉及內容比較多,篇幅會比較長,同時有大量截圖。希望大家能耐心看完。好了,話不對說,咱們開始go go go~
一:基本的秒殺實現
下單時候需要判斷:
1:秒殺是否開始或結束,如果尚未開始或者已經結束則無法下單;
2:庫存是否充足,不充足無法下單
業務:
根據上圖邏輯,我們可以得到代碼相關邏輯:
1:查下優惠券、2:判斷是否秒殺開始;3:判斷秒殺是否結束;4:判斷庫存是否充足;5:扣減庫存;6:創建訂單;
相關代碼如下:
二:分析上面代碼是否存在問題
我們使用JMeter模擬200個用戶去秒殺搶優惠券。運行結果:
異常是45.5%。這個不對啊,按照我們預期的應該是50%的用戶失敗纔對。這45.5%,說明優惠券超賣出了9個。是嗎?我們來查查優惠券表:
庫存爲-9.再來查詢訂單表:發現訂單是109條。在高併發的情況下,還真的是超賣出了9個呢。
來分析爲什麼會出現這種情況呢?
來看看代碼,扣減庫存的相關代碼:
我們來分享下扣除庫存流程:
兩個線程來搶,假設當前就庫存就剩下一個了。線程1和線程2來搶這個庫存。流程如下:
在高併發的情況下,線程誰先執行,還真不好說。在高併發情況下,可能執行的順序就如下圖:
超賣問題分析:
T1的時候,線程1執行從數據庫查詢操作,查詢結果爲1;然後CPU讓出,線程2來執行,在T2時候,線程2也去執行數據庫查詢操作,查詢結果也是1.然後線程2,讓出CPU,T3時候,線程1得到了CPU執行權,執行扣除庫存操作。T4時候線程得到了CPU執行權,同樣執行扣除庫存操作。當兩個線程都執行完成後,數據庫中的庫存就成了-1了。
這只是有2個線程,當高併發的時候,有多個線程來查詢庫存,扣除庫存。如果出現了上面情況,就會出現超賣情況。
超賣問題場景的解決方案
超賣問題就是典型的多線程安全問題,針對這一問題常見的解決方案就是加鎖。鎖分爲樂觀鎖和悲觀鎖。我們來看看:
悲觀鎖:認爲線程安全問題一定會發生的,因此在操作數據之前,先獲取鎖,確保線程串行執行。
例如:Synchronized、Lock都是悲觀鎖。
因爲讓線程串行了,所以,悲觀鎖的效率低。
樂觀鎖:認爲線程安全問題不一定會發生,因此不加鎖,只是在更新數據的時候,判斷有沒有其他線程對數據做了修改。
如果沒有修改,則認爲是安全的,自己才更新數據;
如果已經被其他線程修改了說明發生了安全問題,此時可以重試或者拋出異常。
樂觀鎖的關鍵是判斷之前查詢得到的數據是否被修改過,常見的方式有兩種:
1:版本號法
每當數據被修改,版本號就+1
我們來看看還是上面多線程搶優惠券情況下,版本號法執行流程:
線程1,執行扣除庫存後,版本號+1後,就是2。如下圖:
我們再來看看線程2執行流程:
版本號法優化:
我們從上圖的邏輯中可以看出,在查詢庫存的時候,同時把版本號也查詢出來,在更新的時候,庫存-1,版本號也-1.where條件是版本號=查詢庫存的時候的版本號。我們只需要觀察版本號和庫存關係:同時查詢出來、同時-1.那麼,我們可不可以優化下,只使用一個字段來實現呢?答案是可以的:我們就把庫存作爲版本號概念,在更新的時候,where 條件中的version=查詢庫存的時候的版本號這個條件換成:where id =10 and stock = #{stock}。這樣就剩下一個字段。
其實,上面這個思路就是大名鼎鼎的CAS思想,也就是第二種常見的方案。
2:CAS法
我們來看看CAS法邏輯圖:
知識小擴展:
針對CAS中自旋壓力過大,我們可以使用Longadder這個類來解決。在Java8中提供了一個對AtomicLong改進的一個類:LongAdder.大量線程併發更新一個原子性的時候,天然的問題就是自旋,會導致併發性能問題,當然這個也比我們直接使用sync來得好。所以可以利用這個類,LongAdder來進行優化。
如果獲取某個值,則會對cell和base值進行遞增,最後返回一個完整的值。
好了,秒殺超賣問題分析完了,解決方案也有了。那麼接下來,我們就來實現解決超賣問題的代碼。
其實,我們只需要修改扣減庫存的邏輯,只添加一個where條件即可。如下圖:
修改完成之後,我們再使用JMeter模擬200個用戶去秒殺搶優惠券。運行結果:
異常竟然是89.9%。比沒修改前,異常率還增加了。我們再來看看結果樹情況:
一上來,就庫存不足了。我們z看看數據庫中,庫存情況:
優惠券領券了21張。爲什麼會出現這種情況呢?200個人來搶購100張優惠券,竟然纔有21個人搶到了。這個肯定不是我們想要的結果。這個是什麼原因導致的呢?其實這個就涉及到了CAS樂觀鎖的弊端了。我們重新分析:
如上圖,假設剛開始,就有3個線程同時搶奪資源,其中線程3先執行了更新,將100更新成了99,然後線程1和線程2,就更新失敗了。三個線程,只有一個更新成功了,就如同,我們在結果樹上看到的一樣。如下圖:
那麼失敗的這兩個,就搶不到了,導致我們庫存有剩餘。但是,咱們從真正的業務上來說,搶不到的依據是庫存等於0,纔算搶不到,而不是說我搶到之後,在修改的時候,別人不能夠在搶成功了。我們線程1和線程2在搶的時候,庫存還剩餘99啊,這個是不符合實際業務的。這就是樂觀鎖方案的問題所在--成功率太低了。那麼,我們對樂觀鎖法進行改進。
樂觀鎖法弊端改進
改進思路:在更新的時候,不再判斷庫存是否等於我手裏的庫存值。而是判斷,庫存是否大於0.如果大於,就執行扣除操作。
修改扣除庫存相關代碼:
修改完成之後,我們再使用JMeter模擬200個用戶去秒殺搶優惠券。運行結果:
從上圖中,我們看到異常率是50%。符合我們的預期。我們看看數據庫中的庫存:
訂單表中也是100條訂單。商品沒有超賣,訂單數量也正常。這樣是不是很完美解決了超賣問題?
答案:否。我們可以看到,這個方案,直接是由數據庫來處理的。我們知道,數據庫本來就是比較寶貴的資源,在高併發情況下,這種方案,肯定是不行的。我們繼續往下學習。
小總結
我們來總結下超賣這樣線程安全問題,解決方案有哪些?
下一篇預告:
在下一篇中咱們將實現另外一個功能:一人一單的功能。在下一篇中,您將有如下收穫:
1:悲觀鎖、樂觀鎖的使用場景;
2:synchronized關鍵字,在不同位置,鎖的顆粒度是不同的,怎麼優化呢;
3:toString方法之後,不能保證唯一,如果要保證唯一,需要在調用String的intern方法;
4:對spring事務有更深入瞭解-解決spring事務失效一種情況;
5:spring boot怎麼開啓對AspectJ的支持。
結束語
大家好,我是凱哥Java(kaigejava),樂於分享技術文章,歡迎大家關注“凱哥Java”,及時瞭解更多。讓我們一起學Java。也歡迎大家有事沒事就來和凱哥聊聊~~~。
如操作有問題歡迎去 我的 個人博客(www#kaigejava#com)留言或者 微號(凱哥Java。Kaigejava或者kaigejava2022)留言交流哦。