Redis高并發(fā)場景防止庫存數量超賣少賣
簡介
商品超賣現象,即銷售數量超過了實際庫存量,通常是由于未能正確判斷庫存狀況而發(fā)生的。在常規(guī)的庫存管理系統(tǒng)中,我們會在扣減庫存之前進行庫存充足性檢驗:僅當庫存數量大于零時,系統(tǒng)才會執(zhí)行扣減動作;若庫存不足,則即時返回錯誤提示。然而,在高并發(fā)的銷售場景下,傳統(tǒng)的處理方法往往難以確保庫存扣減的準確性。為了解決這一問題,我們可以采用線程加鎖機制或利用Redis等內存數據結構來同步庫存狀態(tài),從而保證即使在大量同時交易的情況下,庫存扣減也能保持準確無誤。
數據庫校驗
商品類
/** * @description 商品類 * @author yiridancan * @date 2024/3/23 9:06 */ public class Goods { private int id; /** * 商品名稱 */ private String name; /** * 庫存數量 */ private int inventoryCount; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getInventoryCount() { return inventoryCount; } public void setInventoryCount(int inventoryCount) { this.inventoryCount = inventoryCount; } }
實現類
import com.yiridancan.reduceInventory.entity.Goods; import com.yiridancan.reduceInventory.mapper.GoodsMapper; import com.yiridancan.reduceInventory.service.IGoodsService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Objects; /** * 商品實現類 * @author yiridancan * @date 2024/3/23 18:35 */ @Slf4j @Service public class GoodsServiceImpl implements IGoodsService { @Autowired private GoodsMapper goodsMapper; /** * 扣減庫存 * @param goodsId 商品id * @author yiridancan * @date 2024/3/23 18:33 */ @Override public void reduceInventory(int goodsId) { //1.根據商品id獲取商品庫存數量 Goods goods = goodsMapper.findGoodsInventory(goodsId); if(Objects.isNull(goods)){ log.error("未獲取到商品信息"); return; } //2.如果庫存數量大于0則扣減庫存,如果等于0代表沒有貨物打印錯誤信息 if(goods.getInventoryCount() > 0 ){ //默認扣減庫存1 goods.setInventoryCount(goods.getInventoryCount()-1); goodsMapper.updateGoodsInventory(goods); log.info("{}扣減庫存成功,扣減后庫存為:{}",goods.getName(),goods.getInventoryCount()); }else { log.error("{}庫存為0",goods.getName()); } } }
首先,我們需要根據商品ID獲取商品數據。如果無法獲取到數據,則打印異常并終止執(zhí)行。接著,通過查詢庫存數量進行校驗判斷:若庫存大于0,則扣減庫存;反之,若庫存為0,則打印異常信息。
數據庫
測試代碼
@Test void contextLoads() { //商品id int goodsId = 1; //創(chuàng)建固定數量的線程池 int num = 20; ExecutorService executorService = Executors.newFixedThreadPool(num); //模擬20個并發(fā)同時請求接口 for (int i = 0; i < num; i++) { executorService.submit(() -> { goodsService.reduceInventory(goodsId); }); } executorService.shutdown(); try { executorService.awaitTermination(1, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } //獲取商品最終庫存數量 Goods goodsInventory = goodsMapper.findGoodsInventory(goodsId); if(Objects.isNull(goodsInventory)){ return; } log.info("{}商品最終庫存為:{}",goodsInventory.getName(),goodsInventory.getInventoryCount()); }
運行結果
測試中,系統(tǒng)面臨了20個同時發(fā)出的請求,而可用庫存量僅為10個。理論上,這意味著應當有10個請求能夠成功完成庫存扣減,而另外10個請求則需被妥善拒絕。為解決此并發(fā)操作導致的數據不一致性問題,我們可以通過引入鎖機制來確保數據訪問的同步性,從而保障系統(tǒng)的正確性和穩(wěn)定性。
悲觀鎖
可以通過synchronized、ReentrantLock等悲觀鎖來保證原子性和一致性
我們發(fā)現,在20次并發(fā)請求的測試場景中,僅有10次能夠成功減少庫存量,而另外10次則遭到拒絕。這種機制確保了數據一致性的嚴密守護。然而,若我們選擇采用悲觀鎖的策略,雖然可以強化數據完整性,但卻可能導致大量請求進入阻塞隊列,尤其是在高并發(fā)的環(huán)境下,這種重量級的同步處理可能會對服務性能和數據庫響應能力造成顯著負擔,甚至有可能引發(fā)系統(tǒng)瓶頸。因此,在設計高并發(fā)系統(tǒng)時,我們需要權衡鎖機制的選擇,以優(yōu)化系統(tǒng)性能,保證服務的高效流暢。
樂觀鎖
樂觀鎖采用了一種比較寬松的并發(fā)控制策略。它允許多個線程同時讀取和修改共享數據,但在數據提交時會檢查是否有其他線程在此期間修改過相同的數據。如果檢測到沖突,通常需要重新嘗試操作,直到成功為止。樂觀鎖的核心在于它認為沖突不太可能發(fā)生,或者沖突發(fā)生的概率較低,因此不一開始就對數據加鎖,從而避免了鎖機制可能帶來的性能開銷。一般通過數據庫版本號或者時間戳來進行實現
定義一個抽象接口:
/** * 通過樂觀鎖實現扣減庫存 * @author yiridancan * @date 2024/3/25 22:33 * @param goodsId 商品id */ void casReduceInventory(int goodsId);
實現類:
/** * 通過樂觀鎖實現扣減庫存 * @param goodsId 商品id * @author yiridancan * @date 2024/3/25 22:33 */ @Override public void casReduceInventory(int goodsId) { int retryCount = 0; //重試次數設置為3,避免無休止的重試占用紫鳶 while (retryCount <=3){ //1.根據商品id獲取商品信息 Goods goods = goodsMapper.findGoodsInventory(goodsId); if(Objects.isNull(goods) || goods.getInventoryCount() == 0){ log.error("未獲取到商品信息或庫存數量不足"); return; } //默認扣減庫存1 goods.setInventoryCount(goods.getInventoryCount()-1); int updateRow = goodsMapper.updateGoodsInventoryByCAS(goods); //如果修改條數大于0代表扣減庫存成功 if(updateRow > 0 ){ log.info("{}扣減庫存成功,扣減后庫存為:{}",goods.getName(),goods.getInventoryCount()); return; } retryCount++; log.error("{}商品被修改過,進行重試??!版本號:{}",goods.getName(),goods.getDataVersion()); } }
首先會先定義一個重試次數,避免一直重試占用資源。然后獲取到具體的商品信息,默認扣減庫存為1(實際可以根據用戶設置的數量進行扣減),然后根據查詢出來的版本號和id去數據庫中更新數據,如果返回更新數量代表扣減庫存成功,則打印相關打印進行結束,否則進行重試,直到庫存數量不足或扣減庫存成功才結束
<update id="updateGoodsInventoryByCAS"> update goods set inventory_count=#{inventoryCount},data_version=data_version+1 where id=#{id} and data_version=#{dataVersion} </update>
Redis
借助Redis單線程的特性,再加上lua腳本執(zhí)行過程原子性的保障。我們可以在Redis中通過lua腳本進行庫存扣減操作
因為lua腳本在執(zhí)行過程中,可以避免被打斷,并且redis執(zhí)行的過程也是單線程的,所以在腳本中進行判斷,再扣減,這個過程是可以避免并發(fā)的。所以也就可以實現前面我們說的原子性+有序性了。
并且Redis是一個高性能的分布式緩存,使用Lua腳本扣減庫存的方案也非常的高效
首先將商品庫存初始化到Redis中,然后后續(xù)對Redis進行庫存扣減
local key = KEYS[1] -- 商品的鍵名 local amount = tonumber(ARGV[1]) -- 扣減的數量 -- 獲取商品當前的庫存量 local stock = tonumber(redis.call('get', key)) -- 如果庫存足夠,則減少庫存并返回新的庫存量 if stock >= amount then redis.call('decrby', key, amount) return redis.call('get', key) else return "INSUFFICIENT STOCK" end
編寫Lua腳本,通常是單獨放在一個文件中。這里偷了一個懶直接聲明成字符串了
/** * 通過Redis扣減庫存 * * @param goodsId 商品id * @author yiridancan * @date 2024/3/27 15:48 */ @Override public void redisReduceInventory(int goodsId) { String prefix = "goodsInventory:"; //將商品數據緩存到Redis中,key是商品id,value是商品庫存數量 goodsMapper.findGoodsAll().forEach(goods -> { stringRedisTemplate.opsForValue().set(prefix+goods.getId(),String.valueOf(goods.getInventoryCount())); }); //lua腳本,一般放在文件中 String script = "local key = KEYS[1] -- 商品的鍵名\n" + "local amount = tonumber(ARGV[1]) -- 扣減的數量\n" + "\n" + "-- 獲取商品當前的庫存量\n" + "local stock = tonumber(redis.call('get', key))\n" + "\n" + "-- 如果庫存足夠,則減少庫存并返回新的庫存量\n" + "if stock >= amount then\n" + " redis.call('decrby', key, amount)\n" + " return redis.call('get', key)\n" + "else\n" + " return \"INSUFFICIENT STOCK\"\n" + "end\n"; DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(script, String.class); // 創(chuàng)建一個包含庫存key的列表 List<String> keys = Collections.singletonList(prefix + goodsId); // 創(chuàng)建一個包含扣減數量的參數列表 List<String> args = Collections.singletonList(Integer.toString(1)); // 執(zhí)行Lua腳本,傳入鍵列表和參數列表 String result = stringRedisTemplate.execute(redisScript, keys, args.toArray(new String[0])); //如果不是庫存不足代表扣減成功 if(!result.equals("INSUFFICIENT STOCK")){ log.info("扣減庫存成功,庫存數量:{}",result); }else { log.error("庫存數量不足"); } }
首先把商品數據統(tǒng)一緩存到Redis中,然后編寫一段Lua腳本交給DefaultRedisScript,DefaultRedisScript可以自定義數據返回類型
創(chuàng)建兩個集合,分別存放key和參數,通過StringRedisTemplate.execute執(zhí)行Lua腳本,如果返回的值是INSUFFICIENT STOCK代表庫存不足,打印錯誤日志,否則扣減庫存成功
最后在任務執(zhí)行完成后定時將Redis中的庫存同步到數據庫中做持久化即可
其他方案
- Redis+MQ+數據庫:利用Redis來扛高并發(fā)流量。先在Redis扣減庫存,然后發(fā)送一個MQ消息,消費者在接收到消息后做數據庫庫存的真正扣減和業(yè)務邏輯
把修改轉換成新增,直接插入一次占用記錄,然后異步統(tǒng)計剩余庫存,或者通過SQL統(tǒng)計流水方式計算剩余庫存
通過Redisson進行加鎖處理
..............
總結
綜合來說,實踐中往往會根據業(yè)務需求和現有技術棧選擇合適的方法,Redis因其高性能和原子操作特性,在很多場景下成為首選方案之一。而具體實施時,可能還需要結合多種手段以及負載均衡、熔斷、降級等策略來應對復雜的高并發(fā)挑戰(zhàn)。
到此這篇關于Redis高并發(fā)場景防止庫存數量超賣少賣的文章就介紹到這了,更多相關Redis防止超賣少賣內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!