基于MySQL和Redis扣減庫存的實踐
目錄
- 背景
- 環境搭建
- 后臺系統
- 中間件
- 測試工具
- 扣減模式
- 基于數據庫行鎖 + CAS 實現庫存的扣減
- 基于 Redis 實現庫存的扣減
- 總結
背景
在很多情況下,扣減庫存是一個十分常見的需求,例如:學生選課系統中課程數量的扣減,抽獎系統中活動次數的扣減,電商系統中商品庫存的扣減等,都涉及到數量的扣減,這些系統在成功扣減的前提下,絕對不能出現庫存扣減多了的情況,也就是不能出現超賣。同時,我們也要注重系統性能的提升,這篇文章從這兩個角度進行分析和討論。
環境搭建
后臺系統
基于 SpringBoot 搭建后臺系統,JDK 為 1.8
<properties> <java.version>1.8</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <spring-boot.version>2.3.12.RELEASE</spring-boot.version></properties><dependencies> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId> </dependency> <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional> </dependency> <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.2</version> </dependency></dependencies>
中間件
中間件使用 MySQL + Redis 進行數據的存儲,使用 Mybatis 作為 ORM 框架
create database t_desc collate utf8mb4_general_ci;use t_desc;create table t_good ( id bigint auto_increment primary key comment "自增id", good_name varchar(255) not null comment "商品名稱", stock int not null comment "商品庫存") comment "庫存測試表";insert into t_good(good_name, stock) value("iphone", 50);
創建一張商品庫存表,里面含有商品 id、商品名稱 和庫存 3 個字段,所有扣減庫存的操作都在這張表上進行;
測試工具
使用 JMeter 5.5 進行測試
以下的庫存數量統一設置為 50 個,線程組的數量為 10 個,循環 10 次,共 100 個扣減請求,最終正確的結果應該是扣減完畢后庫存的數量應該為 0, 而不是 -50
扣減模式
基于數據庫行鎖 + CAS 實現庫存的扣減
行鎖
若直接直接在數據庫層面進行庫存的直接扣減,100 個線程同時進行請求,肯定會造成庫存的超賣
SQL 語句為
<update id="descGoodStock"> update t_desc.t_good set t_good.stock = t_good.stock - 1 where id = #{id}</update>
考慮到 update 語句,若根據主鍵索引作為條件進行更新,會對數據庫的某一行加上行鎖(數據庫開啟事務自動提交),所以我們加上 stock > 0
的判斷條件
<update id="descGoodStockByLock">update t_desc.t_goodset t_good.stock = t_good.stock - 1where id = #{id} and t_good.stock > 0</update>
開啟 JMeter 進行測試,可見沒有超賣
CAS
CAS 即 Compare and Set,先把舊的庫存查出來,再把舊的庫存作為 update 的條件之一,若數據庫中的庫存與舊的庫存一致,則進行更新,否則不進行更新。
其實本質上與行鎖的方式沒什么區別,而且多了一次查詢,寫這個方法只是為了記錄而已
若有兩個以上的線程先查詢到了商品的舊庫存,這種方法可能會出現扣不完的情況
Java 代碼:
@PostMapping("/db")public Map<String, Object> goodDescControllerByDataBase(Long id) { HashMap<String, Object> ret = new HashMap<>(); // 查出舊的值 Good good = goodMapper.selectStockById(id); // 再進行更新 int i = goodMapper.descGoodStockCAS(id, good.getStock()); if (i > 1) {ret.put("info", "success, 扣減成功"); } else {ret.put("info", "fail, 扣減失敗"); } return ret;}
SQL 語句
<update id="descGoodStockCAS">update t_desc.t_goodset t_good.stock = t_good.stock - 1where id = #{id} and t_good.stock = #{stock} and t_good.stock > 0 </update>
測試結果:
綜上,基于數據庫的兩種扣減庫存的方式都沒有實現超賣,但是畢竟是數據庫,數據存儲于物理磁盤中,性能方面就有待考量;
基于 Redis 實現庫存的扣減
基本思想是:我們把庫存的數量提前放到 Redis 上,直接在 Redis 進行庫存的扣減
- 先查詢 redis 中的庫存
- 若小于 0 直接返回
- 若大于 0 則進行 Redis 和 數據庫 中的庫存扣減
不過這里存在 并發 問題,考慮極限情況,兩個線程同時獲得 stock = 1,然后再去進行庫存扣減,勢必會造成超賣的現象
下面給出兩種解決辦法
使用 decrement 方法
redisTemplate.opsForValue().decrement()
:對某個 key 進行減 1 操作,會返回扣減后的值
若該值大于等于 0 才進行數據庫的庫存的扣減,否則直接返回庫存不足的提示
這種方法是基于 Redis 的指令是原子性的
Java 代碼:
@PostMapping("/redis") public Map<String, Object> goodDescControllerByRedis(Long id) throws InterruptedException {HashMap<String, Object> ret = new HashMap<>();ret.put("info", "fail, 扣減失敗");// 查詢 Redis 中的庫存Integer stock = (Integer) redisTemplate.opsForValue().get(key + id);Thread.sleep(100);if (stock <= 0) { return ret;}// 扣減 redis 中庫存Long decrement = redisTemplate.opsForValue().decrement(key + id);if (decrement >= 0) { // 扣減數據庫庫存 goodMapper.descGoodStock(id); ret.put("info", "success, 扣減成功");}return ret; }
其實 decrement
方法是原子性的,可以不用對庫存先進行查詢的操作,只需要判斷扣減后的數是否大于 0 即可。但是如果并發量高的話,建議還是加上判斷的邏輯,可以提高 Redis 的性能,不用每次進行 decrement
操作;
缺點:這種辦法會導致 Redis 中庫存產生超賣現象,若對 Redis 中庫存數量要求準確,就不要使用這種方法;
測試結果:
Redis 中的庫存產生超賣現象:
MySQL 中的庫存沒有超賣:
使用 LUA 腳本
上述問題的關鍵是:查詢 和 扣減 是兩個分開操作,不是一條原子性的命令。我們可以使用 LUA 腳本,把這兩條命令封裝到 LUA 代碼中,實現這兩個操作的原子性
LUA 代碼
------ Generated by EmmyLua(https://github.com/EmmyLua)--- Created by Ezreal.--- DateTime: 2023/5/6 21:56---if (redis.call("exists", KEYS[1]) == 1) then local stock = tonumber(redis.call("get", KEYS[1])); if (stock <= 0) thenreturn -1; end if (stock > 0) thenredis.call("incrby", KEYS[1], -1);return 1; endendreturn -1
先獲取值,然后判斷庫存數量,若沒有小于等于 0 就先進行扣減即可
Java 代碼
private static final DefaultRedisScript<Long> DECREASE_GOOD_STOCK_SCRIPT = new DefaultRedisScript<>();static { DECREASE_GOOD_STOCK_SCRIPT.setLocation(new ClassPathResource("/lua/desc_stock.lua")); // 設置返回值類型 DECREASE_GOOD_STOCK_SCRIPT.setResultType(Long.class);}@PostMapping("/lua")public Map<String, Object> goodDescControllerByLUA(Long id) { List<String> keys = new ArrayList<>(); keys.add("stock:" + id); HashMap<String, Object> ret = new HashMap<>(); ret.put("info", "fail, 扣減失敗"); Long execute = redisTemplate.execute(DECREASE_GOOD_STOCK_SCRIPT, keys); if (execute == 1) {goodMapper.descGoodStock(id);ret.put("info", "success, 扣減成功"); } return ret;}
結果:Redis 和 MySQL 中的庫存均為 0 ,沒有超賣
使用分布式鎖
可以使用 redisson 分布式鎖進行扣減庫存處理,鎖住查詢和扣減兩個步驟即可;
若是在分布式環境下,要考慮 分布式鎖 與 LUA 腳本的結合!
java 代碼
@PostMapping("/lock")public Map<String, Object> goodDescControllerByLock(Long id) throws InterruptedException { HashMap<String, Object> ret = new HashMap<>(); ret.put("info", "fail, 扣減失敗"); // 加鎖 RLock lock = redissonClient.getLock("stock" + id); boolean tryLock = lock.tryLock(2L, 1L, TimeUnit.SECONDS); if (tryLock) {Integer stock = (Integer) redisTemplate.opsForValue().get(key + id);if (stock <= 0) { return ret;}Long decrement = redisTemplate.opsForValue().decrement(key + id);if (decrement >= 0) { goodMapper.descGoodStock(id); ret.put("info", "success, 扣減成功");} } return ret;}
測試結果:
Redis 中庫存數量沒有超賣
MySQL 中庫存數量沒有超賣
總結
如果在項目初期流量較少可以考慮基于 數據庫行鎖 進行庫存的扣減,到了后期流量大,幾乎都要用到 Redis:
- decrement:追求簡單快速實現,不考慮 Redis 庫存中的準確性;
- LUA 腳本:追求 Redis 中庫存的準確性,在 Redis 層面上要進行多重的條件判斷
- Lock:追求 Redis 中庫存的準確性,在分布式環境中要考慮 LUA + Lock 的結合
到此這篇關于基于MySQL和Redis扣減庫存的實踐的文章就介紹到這了,更多相關MySQL和Redis扣減庫存內容請搜索以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持!
