java秒殺系統(tǒng)常見問題庫存超賣解決實例分析
先看問題
首先上一串代碼
public String buy(Long goodsId, Integer goodsNum) { //查詢商品庫存 Goods goods = goodsMapper.selectById(goodsId); //如果當前庫存為0,提示商品已經(jīng)賣光了 if (goods.getGoodsInventory() <= 0) { return "商品已經(jīng)賣光了!"; } //如果當前購買數(shù)量大于庫存,提示庫存不足 if (goodsNum > goods.getGoodsInventory()) { return "庫存不足!"; } //更新庫存 goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum); goodsMapper.updateById(goods); return "購買成功!"; }
我們看一下這串代碼,邏輯用流程圖表示如下:
從圖上看,邏輯還是很清晰明了的,而且單測的話,也測試不出來什么bug。但是在秒殺場景下,問題可就大發(fā)了,100件商品可能賣出1000單,出現(xiàn)超賣問題,這下就真的需要殺個程序員祭天了。
問題分析
正常情況下,如果請求是一個一個接著來的話,這串代碼也不會有問題,如下圖:
不同的時刻不同的請求,每次拿到的商品庫存都是更新過之后的,邏輯是ok的。
那為啥會出現(xiàn)超賣問題呢?首先我們給這串代碼增加一個場景:商品秒殺(非秒殺場景難以復現(xiàn)超賣問題)。
秒殺場景的特點如下:
- 高并發(fā)處理:秒殺場景下,可能會有大量的購物者同時涌入系統(tǒng),因此需要具備高并發(fā)處理能力,保證系統(tǒng)能夠承受高并發(fā)訪問,并提供快速的響應。
- 快速響應:秒殺場景下,由于時間限制和競爭激烈,需要系統(tǒng)能夠快速響應購物者的請求,否則可能會導致購買失敗,影響購物者的購物體驗。
- 分布式系統(tǒng): 秒殺場景下,單臺服務器扛不住請求高峰,分布式系統(tǒng)可以提高系統(tǒng)的容錯能力和抗壓能力,非常適合秒殺場景。
在這種場景下,請求不可能是一個接一個這種,而是成千上萬個請求同時打過來,那么就會出現(xiàn)多個請求在同一時刻查詢庫存,如下圖:
如果在同一時刻查詢商品庫存表,那么得到的商品庫存也肯定是相同的,判斷的邏輯也是相同的。
舉個例子,現(xiàn)在商品的庫存是10件,請求1買6件,請求2買5件,由于兩次請求查詢到的庫存都是10,肯定是可以賣的。
但是真實情況是5+6=11>10,明顯有問題好吧!這兩筆請求必然有一筆失敗才是對的!
那么,這種問題怎么解決呢?
問題解決
從上面例子來看,問題好像是由于我們每次拿到的庫存都是一樣的
,才導致庫存超賣問題,那是不是只要保證每次拿到的庫存都是最新
的話,這個問題不就迎刃而解了嗎!
在說方案前,先把我的測試表結(jié)構(gòu)貼出來:
CREATE TABLE `t_goods` ( `id` bigint NOT NULL COMMENT '物理主鍵', `goods_name` varchar(64) DEFAULT NULL COMMENT '商品名稱', `goods_pic` varchar(255) DEFAULT NULL COMMENT '商品圖片', `goods_desc` varchar(255) DEFAULT NULL COMMENT '商品描述信息', `goods_inventory` int DEFAULT NULL COMMENT '商品庫存', `goods_price` decimal(10,2) DEFAULT NULL COMMENT '商品價格', `create_time` datetime DEFAULT NULL COMMENT '創(chuàng)建時間', `update_time` datetime DEFAULT NULL COMMENT '更新時間', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
方法一、redis分布式鎖
Redisson介紹
官方介紹:Redisson是一個基于Redis的Java駐留內(nèi)存數(shù)據(jù)網(wǎng)格(In-Memory Data Grid)。它封裝了Redis客戶端API,并提供了一個分布式鎖、分布式集合、分布式對象、分布式Map等常用的數(shù)據(jù)結(jié)構(gòu)和服務。Redisson支持Java 6以上版本和Redis 2.6以上版本,并且采用編解碼器和序列化器來支持任何對象類型。 Redisson還提供了一些高級功能,比如異步API和響應式流式API。它可以在分布式系統(tǒng)中被用來實現(xiàn)高可用性、高性能、高可擴展性的數(shù)據(jù)處理。
Redisson使用 引入
<!--使用redisson作為分布式鎖--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.16.8</version> </dependency>
注入對象
RedissonConfig.java
import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RedissonConfig { /** * 所有對Redisson的使用都是通過RedissonClient對象 * * @return */ @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { // 創(chuàng)建配置 指定redis地址及節(jié)點信息 Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123456"); // 根據(jù)config創(chuàng)建出RedissonClient實例 RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
代碼優(yōu)化
public String buyRedisLock(Long goodsId, Integer goodsNum) { RLock lock = redissonClient.getLock("goods_buy"); try { //加分布式鎖 lock.lock(); //查詢商品庫存 Goods goods = goodsMapper.selectById(goodsId); //如果當前庫存為0,提示商品已經(jīng)賣光了 if (goods.getGoodsInventory() <= 0) { return "商品已經(jīng)賣光了!"; } //如果當前購買數(shù)量大于庫存,提示庫存不足 if (goodsNum > goods.getGoodsInventory()) { return "庫存不足!"; } //更新庫存 goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum); goodsMapper.updateById(goods); return "購買成功!"; } catch (Exception e) { log.error("秒殺失敗"); } finally { lock.unlock(); } return "購買失敗"; }
加上Redisson分布式鎖之后,使得請求由異步變?yōu)橥剑屬徺I操作一個一個進行,解決了庫存超賣問題,但是會讓用戶等待的時間加長,影響了用戶體驗。
方法二、MySQL的行鎖
行鎖介紹
MySQL的行鎖是一種針對行級別數(shù)據(jù)的鎖,它可以鎖定某個表中的某一行數(shù)據(jù),以保證在鎖定期間,其他事務無法修改該行數(shù)據(jù),從而保證數(shù)據(jù)的一致性和完整性。
特點如下:
- MySQL的行鎖只能在InnoDB存儲引擎中使用。
- 行鎖需要有索引才能實現(xiàn),否則會自動鎖定整張表。
- 可以通過使用“SELECT ... FOR UPDATE”和“SELECT ... LOCK IN SHARE MODE”語句來顯式地使用行鎖。
總之,行鎖可以有效地保證數(shù)據(jù)的一致性和完整性,但是過多的行鎖也會導致性能問題,因此在使用行鎖時需要謹慎考慮,避免出現(xiàn)性能瓶頸。
那么回到庫存超賣這個問題上來,我們可以在一開始查詢商品庫存的時候增加一個行鎖,實現(xiàn)非常簡單,也就是將
//查詢商品庫存 Goods goods = goodsMapper.selectById(goodsId); 原始查詢SQL SELECT * FROM t_goods WHERE id = #{goodsId} 改寫為 SELECT * FROM t_goods WHERE id = #{goodsId} for update
那么被查詢到的這行商品庫存信息就會被鎖住,其他請求想要讀取這行數(shù)據(jù)時就需要等待當前請求結(jié)束了,這樣就做到了每次查詢庫存都是最新的。不過同Redisson分布式鎖一樣,會讓用戶等待的時間加長,影響用戶體驗。
方法三、樂觀鎖
樂觀鎖機制類似java中的cas機制,在查詢數(shù)據(jù)的時候不加鎖,只有更新數(shù)據(jù)的時候才比對數(shù)據(jù)是否已經(jīng)發(fā)生過改變,沒有改變則執(zhí)行更新操作,已經(jīng)改變了則進行重試。
商品表增加version字段并初始化數(shù)據(jù)為0
`version` int(11) DEFAULT NULL COMMENT '版本'
將更新SQL修改如下
update t_goods set goods_inventory = goods_inventory - #{goodsNum}, version = version + 1 where id = #{goodsId} and version = #{version}
Java代碼修改如下
public String buyVersion(Long goodsId, Integer goodsNum) { //查詢商品庫存(該語句使用了行鎖) Goods goods = goodsMapper.selectById(goodsId); //如果當前庫存為0,提示商品已經(jīng)賣光了 if (goods.getGoodsInventory() <= 0) { return "商品已經(jīng)賣光了!"; } if (goodsMapper.updateInventoryAndVersion(goodsId, goodsNum, goods.getVersion()) > 0) { return "購買成功!"; } return "庫存不足!"; }
通過增加了版本號的控制,在扣減庫存的時候在where條件進行版本號的比對。實現(xiàn)查詢的是哪一條記錄,那么就要求更新的是哪一條記錄,在查詢到更新的過程中版本號不能變動,否則更新失敗。
方法四、where條件和unsigned 非負字段限制
前面的兩種辦法是通過每次都拿到最新的庫存
從而解決超賣問題,那換一種思路:保證在扣除庫存的時候,庫存一定大于購買量
是不是也可以解決這個問題呢?
答案是可以的?;氐缴厦娴拇a:
//更新庫存 goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum); goodsMapper.updateById(goods);
我們把庫存的扣減寫在了代碼中,這樣肯定是不行的,因為在分布式系統(tǒng)中我們獲取到的庫存可能都是一樣的,應該把庫存的扣減邏輯放到SQL中,即:
update t_goods set goods_inventory = goods_inventory - #{goodsNum} where id = #{goodsId}
上面的SQL保證了每次獲取的庫存都是取數(shù)據(jù)庫的庫存,不過我們還需要加一個判斷:保證庫存大于購買量,即:
update t_goods set goods_inventory = goods_inventory - #{goodsNum} where id = #{goodsId} AND (goods_inventory - #{goodsNum}) >= 0
那么上面那段Java代碼也需修改一下:
public String buySqlUpdate(Long goodsId, Integer goodsNum) { //查詢商品庫存(該語句使用了行鎖) Goods goods = goodsMapper.queryById(goodsId); //如果當前庫存為0,提示商品已經(jīng)賣光了 if (goods.getGoodsInventory() <= 0) { return "商品已經(jīng)賣光了!"; } //此處需要判斷更新操作是否成功 if (goodsMapper.updateInventory(goodsId, goodsNum) > 0) { return "購買成功!"; } return "庫存不足!"; }
還有一種辦法和where條件一樣,就是unsigned 非負字段限制,把庫存字段設置為unsigned 非負字段類型,那么在扣減時也不會出現(xiàn)扣成負數(shù)的情況。
總結(jié)
解決方案 | 優(yōu)點 | 缺點 |
---|---|---|
redis分布式鎖 | Redis分布式鎖可以解決分布式場景下的鎖問題,保證多個節(jié)點對同一資源的訪問順序和安全性,性能較高。 | 單點故障問題,如果Redis節(jié)點宕機,會導致鎖失效。 |
MySQL的行鎖 | 可以保證事務的隔離性,能夠避免并發(fā)情況下的數(shù)據(jù)沖突問題。 | 性能較低,對數(shù)據(jù)庫的性能影響較大,同時也存在死鎖問題。 |
樂觀鎖 | 相對于悲觀鎖,樂觀鎖不會阻塞線程,性能較高。 | 需要額外的版本控制字段,且在高并發(fā)情況下容易出現(xiàn)并發(fā)沖突問題。 |
where條件和unsigned 非負字段限制 | 可以通過where條件和unsigned非負字段限制來保證庫存不會超賣,簡單易實現(xiàn)。 | 可能存在一定的安全隱患,如果某些操作沒有正確限制,仍有可能導致庫存超賣問題。同時,如果某些場景需要對庫存進行多次更新操作,限制條件可能會導致操作失敗,需要再次查詢數(shù)據(jù),對性能會產(chǎn)生影響。 |
方案有很多,用法結(jié)合實際業(yè)務來看,沒有最優(yōu),只有更優(yōu)。
以上就是java秒殺系統(tǒng)常見問題庫存超賣的詳細內(nèi)容,更多關于java秒殺系統(tǒng)庫存超賣的資料請關注腳本之家其它相關文章!
相關文章
java創(chuàng)建txt文件并寫入內(nèi)容的方法代碼示例
這篇文章主要介紹了java創(chuàng)建txt文件并寫入內(nèi)容的兩種方法,分別是使用java.io.FileWriter和BufferedWriter,以及使用Java7的java.nio.file包中的Files和Path類,需要的朋友可以參考下2025-01-01淺談java 數(shù)據(jù)處理(int[][]存儲與讀取)
下面小編就為大家?guī)硪黄獪\談java 數(shù)據(jù)處理(int[][]存儲與讀取)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-06-06自定義注解實現(xiàn)Spring容器注入Bean方式(類似于mybatis的@MapperScans)
本文介紹了如何通過自定義注解@MyService和@MyServiceScans在SpringBoot項目中自動將指定包下的類注入Spring容器,詳細解釋了創(chuàng)建自定義注解、定義包掃描器ClassPathBeanDefinitionScanner的作用與實現(xiàn)2024-09-09Java設計模式之Template?Pattern模板模式詳解
這篇文章主要介紹了Java設計模式之Template?Pattern模板模式詳解,模板模式(Template?Pattern)行為型模式之一,抽象父類定義一個操作中的算法的骨架,而將一些步驟延遲到子類中,需要的朋友可以參考下2023-10-10