大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。
以下是正文!
首先上一串程式碼
public String buy(Long goodsId, Integer goodsNum) {
//查詢商品庫存
Goods goods = goodsMapper.selectById(goodsId);
//如果當前庫存為0,提示商品已經賣光了
if (goods.getGoodsInventory() <= 0) {
return "商品已經賣光了!";
}
//如果當前購買數量大於庫存,提示庫存不足
if (goodsNum > goods.getGoodsInventory()) {
return "庫存不足!";
}
//更新庫存
goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);
goodsMapper.updateById(goods);
return "購買成功!";
}
我們看一下這串程式碼,邏輯用流程圖表示如下:
從圖上看,邏輯還是很清晰明瞭的,而且單測的話,也測試不出來什麼bug。但是在秒殺場景下,問題可就大發了,100件商品可能賣出1000單,出現超賣問題,這下就真的需要殺個程式設計師祭天了。
正常情況下,如果請求是一個一個接著來的話,這串程式碼也不會有問題,如下圖:
不同的時刻不同的請求,每次拿到的商品庫存都是更新過之後的,邏輯是ok的。
那為啥會出現超賣問題呢?
首先我們給這串程式碼增加一個場景:商品秒殺(非秒殺場景難以復現超賣問題)。
秒殺場景的特點如下:
- 高並行處理:秒殺場景下,可能會有大量的購物者同時湧入系統,因此需要具備高並行處理能力,保證系統能夠承受高並行存取,並提供快速的響應。
- 快速響應:秒殺場景下,由於時間限制和競爭激烈,需要系統能夠快速響應購物者的請求,否則可能會導致購買失敗,影響購物者的購物體驗。
- 分散式系統: 秒殺場景下,單臺伺服器扛不住請求高峰,分散式系統可以提高系統的容錯能力和抗壓能力,非常適合秒殺場景。
在這種場景下,請求不可能是一個接一個這種,而是成千上萬個請求同時打過來,那麼就會出現多個請求在同一時刻查詢庫存,如下圖:
如果在同一時刻查詢商品庫存表,那麼得到的商品庫存也肯定是相同的,判斷的邏輯也是相同的。
舉個例子,現在商品的庫存是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 '建立時間',
`update_time` datetime DEFAULT NULL COMMENT '更新時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
官方介紹:Redisson是一個基於Redis的Java駐留記憶體資料網格(In-Memory Data Grid)。它封裝了Redis使用者端API,並提供了一個分散式鎖、分散式集合、分散式物件、分散式Map等常用的資料結構和服務。Redisson支援Java 6以上版本和Redis 2.6以上版本,並且採用編解碼器和序列化器來支援任何物件型別。 Redisson還提供了一些高階功能,比如非同步API和響應式流式API。它可以在分散式系統中被用來實現高可用性、高效能、高可延伸性的資料處理。
<!--使用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() {
// 建立設定 指定redis地址及節點資訊
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123456");
// 根據config建立出RedissonClient範例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
public String buyRedisLock(Long goodsId, Integer goodsNum) {
RLock lock = redissonClient.getLock("goods_buy");
try {
//加分散式鎖
lock.lock();
//查詢商品庫存
Goods goods = goodsMapper.selectById(goodsId);
//如果當前庫存為0,提示商品已經賣光了
if (goods.getGoodsInventory() <= 0) {
return "商品已經賣光了!";
}
//如果當前購買數量大於庫存,提示庫存不足
if (goodsNum > goods.getGoodsInventory()) {
return "庫存不足!";
}
//更新庫存
goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);
goodsMapper.updateById(goods);
return "購買成功!";
} catch (Exception e) {
log.error("秒殺失敗");
} finally {
lock.unlock();
}
return "購買失敗";
}
加上Redisson分散式鎖之後,使得請求由非同步變為同步,讓購買操作一個一個進行,解決了庫存超賣問題,但是會讓使用者等待的時間加長,影響了使用者體驗。
MySQL的行鎖是一種針對行級別資料的鎖,它可以鎖定某個表中的某一行資料,以保證在鎖定期間,其他事務無法修改該行資料,從而保證資料的一致性和完整性。
特點如下:
- MySQL的行鎖只能在InnoDB儲存引擎中使用。
- 行鎖需要有索引才能實現,否則會自動鎖定整張表。
- 可以通過使用「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
那麼被查詢到的這行商品庫存資訊就會被鎖住,其他請求想要讀取這行資料時就需要等待當前請求結束了,這樣就做到了每次查詢庫存都是最新的。不過同Redisson分散式鎖一樣,會讓使用者等待的時間加長,影響使用者體驗。
樂觀鎖機制類似java中的cas機制,在查詢資料的時候不加鎖,只有更新資料的時候才比對資料是否已經發生過改變,沒有改變則執行更新操作,已經改變了則進行重試。
`version` int(11) DEFAULT NULL COMMENT '版本'
update t_goods
set goods_inventory = goods_inventory - #{goodsNum},
version = version + 1
where id = #{goodsId}
and version = #{version}
public String buyVersion(Long goodsId, Integer goodsNum) {
//查詢商品庫存(該語句使用了行鎖)
Goods goods = goodsMapper.selectById(goodsId);
//如果當前庫存為0,提示商品已經賣光了
if (goods.getGoodsInventory() <= 0) {
return "商品已經賣光了!";
}
if (goodsMapper.updateInventoryAndVersion(goodsId, goodsNum, goods.getVersion()) > 0) {
return "購買成功!";
}
return "庫存不足!";
}
通過增加了版本號的控制,在扣減庫存的時候在where條件進行版本號的比對。實現查詢的是哪一條記錄,那麼就要求更新的是哪一條記錄,在查詢到更新的過程中版本號不能變動,否則更新失敗。
前面的兩種辦法是通過每次都拿到最新的庫存
從而解決超賣問題,那換一種思路:保證在扣除庫存的時候,庫存一定大於購買量
是不是也可以解決這個問題呢?
答案是可以的。回到上面的程式碼:
//更新庫存
goods.setGoodsInventory(goods.getGoodsInventory() - goodsNum);
goodsMapper.updateById(goods);
我們把庫存的扣減寫在了程式碼中,這樣肯定是不行的,因為在分散式系統中我們獲取到的庫存可能都是一樣的,應該把庫存的扣減邏輯放到SQL中,即:
update t_goods
set goods_inventory = goods_inventory - #{goodsNum}
where id = #{goodsId}
上面的SQL保證了每次獲取的庫存都是取資料庫的庫存,不過我們還需要加一個判斷:保證庫存大於購買量,即:
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,提示商品已經賣光了
if (goods.getGoodsInventory() <= 0) {
return "商品已經賣光了!";
}
//此處需要判斷更新操作是否成功
if (goodsMapper.updateInventory(goodsId, goodsNum) > 0) {
return "購買成功!";
}
return "庫存不足!";
}
還有一種辦法和where條件一樣,就是unsigned 非負欄位限制,把庫存欄位設定為unsigned 非負欄位型別,那麼在扣減時也不會出現扣成負數的情況。
解決方案 | 優點 | 缺點 |
---|---|---|
redis分散式鎖 | Redis分散式鎖可以解決分散式場景下的鎖問題,保證多個節點對同一資源的存取順序和安全性,效能較高。 | 單點故障問題,如果Redis節點宕機,會導致鎖失效。 |
MySQL的行鎖 | 可以保證事務的隔離性,能夠避免並行情況下的資料衝突問題。 | 效能較低,對資料庫的效能影響較大,同時也存在死鎖問題。 |
樂觀鎖 | 相對於悲觀鎖,樂觀鎖不會阻塞執行緒,效能較高。 | 需要額外的版本控制欄位,且在高並行情況下容易出現並行衝突問題。 |
where條件和unsigned 非負欄位限制 | 可以通過where條件和unsigned非負欄位限制來保證庫存不會超賣,簡單易實現。 | 可能存在一定的安全隱患,如果某些操作沒有正確限制,仍有可能導致庫存超賣問題。同時,如果某些場景需要對庫存進行多次更新操作,限制條件可能會導致操作失敗,需要再次查詢資料,對效能會產生影響。 |
方案有很多,用法結合實際業務來看,沒有最優,只有更優。
全文至此結束,再會!