社區(qū)精選|秒殺系統(tǒng)常見問題—庫存超賣
今天小編為大家?guī)淼氖巧鐓^(qū)作者 sum墨 的文章,讓我們一起來學習秒殺系統(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的。
秒殺場景的特點如下:
-
高并發(fā)處理:秒殺場景下,可能會有大量的購物者同時涌入系統(tǒng),因此需要具備高并發(fā)處理能力,保證系統(tǒng)能夠承受高并發(fā)訪問,并提供快速的響應(yīng)。 -
快速響應(yīng):秒殺場景下,由于時間限制和競爭激烈,需要系統(tǒng)能夠快速響應(yīng)購物者的請求,否則可能會導致購買失敗,影響購物者的購物體驗。 -
分布式系統(tǒng):秒殺場景下,單臺服務(wù)器扛不住請求高峰,分布式系統(tǒng)可以提高系統(tǒng)的容錯能力和抗壓能力,非常適合秒殺場景。

如果在同一時刻查詢商品庫存表,那么得到的商品庫存也肯定是相同的,判斷的邏輯也是相同的。
舉個例子,現(xiàn)在商品的庫存是 10 件,請求 1 買 6 件,請求 2 買 5 件,由于兩次請求查詢到的庫存都是 10 ,肯定是可以賣的。
但是真實情況是 5+6=11>10 ,明顯有問題好吧!這兩筆請求必然有一筆失敗才是對的!
那么,這種問題怎么解決呢?
問題解決
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)和服務(wù)。Redisson 支持 Java 6 以上版本和 Redis 2.6 以上版本,并且采用編解碼器和序列化器來支持任何對象類型。Redisson 還提供了一些高級功能,比如異步 API 和響應(yīng)式流式 API 。它可以在分布式系統(tǒng)中被用來實現(xiàn)高可用性、高性能、高可擴展性的數(shù)據(jù)處理。
Redisson 使用
引入
<!--使用redisson作為分布式鎖-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
注入對象
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 "購買失敗";
}
方法二、MySQL的行鎖
行鎖介紹
MySQL 的行鎖是一種針對行級別數(shù)據(jù)的鎖,它可以鎖定某個表中的某一行數(shù)據(jù),以保證在鎖定期間,其他事務(wù)無法修改該行數(shù)據(jù),從而保證數(shù)據(jù)的一致性和完整性。
特點如下:
MySQL 的行鎖只能在 InnoDB 存儲引擎中使用。
行鎖需要有索引才能實現(xiàn),否則會自動鎖定整張表。
可以通過使用“ SELECT ... FOR UPDATE”和“SELECT ... LOCK IN SHARE MODE ”語句來顯式地使用行鎖。
//查詢商品庫存
Goods goods = goodsMapper.selectById(goodsId);
原始查詢SQL
SELECT *
FROM t_goods
WHERE id = #{goodsId}
改寫為
SELECT *
FROM t_goods
WHERE id = #{goodsId} for update
方法三、樂觀鎖
商品表增加 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 非負字段限制
前面的兩種辦法是通過每次都拿到最新的庫存從而解決超賣問題,那換一種思路:保證在扣除庫存的時候,庫存一定大于購買量是不是也可以解決這個問題呢?
答案是可以的。回到上面的代碼:
//更新庫存
goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);
goodsMapper.updateById(goods);
update t_goods
set goods_inventory = goods_inventory - #{goodsNum}
where id = #{goodsId}
update t_goods
set goods_inventory = goods_inventory - #{goodsNum}
where id = #{goodsId}
AND (goods_inventory - #{goodsNum}) >= 0
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 "庫存不足!";
}
總結(jié)一下
| 解決方案 | 優(yōu)點 | 缺點 |
|---|---|---|
redis 分布式鎖 |
Redis 分布式鎖可以解決分布式場景下的鎖問題,保證多個節(jié)點對同一資源的訪問順序和安全性,性能較高。 |
|
|
|
|
|
|
|
|
|
|
|
|
|
全文至此結(jié)束,再會!
點擊左下角閱讀原文,到 SegmentFault 思否社區(qū) 和文章作者展開更多互動和交流,“公眾號后臺“回復“ 入群 ”即可加入我們的技術(shù)交流群,收獲更多的技術(shù)文章~
- END -
往期推薦
社區(qū)精選|面試官:你先實現(xiàn)個 CountDown 計時器組件吧!
社區(qū)精選|Vue 組件懶加載
社區(qū)精選|淺析微前端沙箱
