基于MySQL和Redis扣減庫(kù)存的實(shí)踐
背景
在很多情況下,扣減庫(kù)存是一個(gè)十分常見(jiàn)的需求,例如:學(xué)生選課系統(tǒng)中課程數(shù)量的扣減,抽獎(jiǎng)系統(tǒng)中活動(dòng)次數(shù)的扣減,電商系統(tǒng)中商品庫(kù)存的扣減等,都涉及到數(shù)量的扣減,這些系統(tǒng)在成功扣減的前提下,絕對(duì)不能出現(xiàn)庫(kù)存扣減多了的情況,也就是不能出現(xiàn)超賣(mài)。同時(shí),我們也要注重系統(tǒng)性能的提升,這篇文章從這兩個(gè)角度進(jìn)行分析和討論。
環(huán)境搭建
后臺(tái)系統(tǒng)
基于 SpringBoot 搭建后臺(tái)系統(tǒng),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 進(jìn)行數(shù)據(jù)的存儲(chǔ),使用 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 '商品庫(kù)存' ) comment '庫(kù)存測(cè)試表'; insert into t_good(good_name, stock) value('iphone', 50);
創(chuàng)建一張商品庫(kù)存表,里面含有商品 id、商品名稱 和庫(kù)存 3 個(gè)字段,所有扣減庫(kù)存的操作都在這張表上進(jìn)行;
測(cè)試工具
使用 JMeter 5.5 進(jìn)行測(cè)試
以下的庫(kù)存數(shù)量統(tǒng)一設(shè)置為 50 個(gè),線程組的數(shù)量為 10 個(gè),循環(huán) 10 次,共 100 個(gè)扣減請(qǐng)求,最終正確的結(jié)果應(yīng)該是扣減完畢后庫(kù)存的數(shù)量應(yīng)該為 0, 而不是 -50
扣減模式
基于數(shù)據(jù)庫(kù)行鎖 + CAS 實(shí)現(xiàn)庫(kù)存的扣減
行鎖
若直接直接在數(shù)據(jù)庫(kù)層面進(jìn)行庫(kù)存的直接扣減,100 個(gè)線程同時(shí)進(jìn)行請(qǐng)求,肯定會(huì)造成庫(kù)存的超賣(mài)
SQL 語(yǔ)句為
<update id="descGoodStock"> update t_desc.t_good set t_good.stock = t_good.stock - 1 where id = #{id} </update>
考慮到 update 語(yǔ)句,若根據(jù)主鍵索引作為條件進(jìn)行更新,會(huì)對(duì)數(shù)據(jù)庫(kù)的某一行加上行鎖(數(shù)據(jù)庫(kù)開(kāi)啟事務(wù)自動(dòng)提交),所以我們加上 stock > 0
的判斷條件
<update id="descGoodStockByLock"> update t_desc.t_good set t_good.stock = t_good.stock - 1 where id = #{id} and t_good.stock > 0 </update>
開(kāi)啟 JMeter 進(jìn)行測(cè)試,可見(jiàn)沒(méi)有超賣(mài)
CAS
CAS 即 Compare and Set,先把舊的庫(kù)存查出來(lái),再把舊的庫(kù)存作為 update 的條件之一,若數(shù)據(jù)庫(kù)中的庫(kù)存與舊的庫(kù)存一致,則進(jìn)行更新,否則不進(jìn)行更新。
其實(shí)本質(zhì)上與行鎖的方式?jīng)]什么區(qū)別,而且多了一次查詢,寫(xiě)這個(gè)方法只是為了記錄而已
若有兩個(gè)以上的線程先查詢到了商品的舊庫(kù)存,這種方法可能會(huì)出現(xiàn)扣不完的情況
Java 代碼:
@PostMapping("/db") public Map<String, Object> goodDescControllerByDataBase(Long id) { HashMap<String, Object> ret = new HashMap<>(); // 查出舊的值 Good good = goodMapper.selectStockById(id); // 再進(jìn)行更新 int i = goodMapper.descGoodStockCAS(id, good.getStock()); if (i > 1) { ret.put("info", "success, 扣減成功"); } else { ret.put("info", "fail, 扣減失敗"); } return ret; }
SQL 語(yǔ)句
<update id="descGoodStockCAS"> update t_desc.t_good set t_good.stock = t_good.stock - 1 where id = #{id} and t_good.stock = #{stock} and t_good.stock > 0 </update>
測(cè)試結(jié)果:
綜上,基于數(shù)據(jù)庫(kù)的兩種扣減庫(kù)存的方式都沒(méi)有實(shí)現(xiàn)超賣(mài),但是畢竟是數(shù)據(jù)庫(kù),數(shù)據(jù)存儲(chǔ)于物理磁盤(pán)中,性能方面就有待考量;
基于 Redis 實(shí)現(xiàn)庫(kù)存的扣減
基本思想是:我們把庫(kù)存的數(shù)量提前放到 Redis 上,直接在 Redis 進(jìn)行庫(kù)存的扣減
- 先查詢 redis 中的庫(kù)存
- 若小于 0 直接返回
- 若大于 0 則進(jìn)行 Redis 和 數(shù)據(jù)庫(kù) 中的庫(kù)存扣減
不過(guò)這里存在 并發(fā) 問(wèn)題,考慮極限情況,兩個(gè)線程同時(shí)獲得 stock = 1,然后再去進(jìn)行庫(kù)存扣減,勢(shì)必會(huì)造成超賣(mài)的現(xiàn)象
下面給出兩種解決辦法
使用 decrement 方法
redisTemplate.opsForValue().decrement()
:對(duì)某個(gè) key 進(jìn)行減 1 操作,會(huì)返回扣減后的值
若該值大于等于 0 才進(jìn)行數(shù)據(jù)庫(kù)的庫(kù)存的扣減,否則直接返回庫(kù)存不足的提示
這種方法是基于 Redis 的指令是原子性的
Java 代碼:
@PostMapping("/redis") public Map<String, Object> goodDescControllerByRedis(Long id) throws InterruptedException { HashMap<String, Object> ret = new HashMap<>(); ret.put("info", "fail, 扣減失敗"); // 查詢 Redis 中的庫(kù)存 Integer stock = (Integer) redisTemplate.opsForValue().get(key + id); Thread.sleep(100); if (stock <= 0) { return ret; } // 扣減 redis 中庫(kù)存 Long decrement = redisTemplate.opsForValue().decrement(key + id); if (decrement >= 0) { // 扣減數(shù)據(jù)庫(kù)庫(kù)存 goodMapper.descGoodStock(id); ret.put("info", "success, 扣減成功"); } return ret; }
其實(shí) decrement
方法是原子性的,可以不用對(duì)庫(kù)存先進(jìn)行查詢的操作,只需要判斷扣減后的數(shù)是否大于 0 即可。但是如果并發(fā)量高的話,建議還是加上判斷的邏輯,可以提高 Redis 的性能,不用每次進(jìn)行 decrement
操作;
缺點(diǎn):這種辦法會(huì)導(dǎo)致 Redis 中庫(kù)存產(chǎn)生超賣(mài)現(xiàn)象,若對(duì) Redis 中庫(kù)存數(shù)量要求準(zhǔn)確,就不要使用這種方法;
測(cè)試結(jié)果:
Redis 中的庫(kù)存產(chǎn)生超賣(mài)現(xiàn)象:
MySQL 中的庫(kù)存沒(méi)有超賣(mài):
使用 LUA 腳本
上述問(wèn)題的關(guān)鍵是:查詢 和 扣減 是兩個(gè)分開(kāi)操作,不是一條原子性的命令。我們可以使用 LUA 腳本,把這兩條命令封裝到 LUA 代碼中,實(shí)現(xiàn)這兩個(gè)操作的原子性
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) then return -1; end if (stock > 0) then redis.call('incrby', KEYS[1], -1); return 1; end end return -1
先獲取值,然后判斷庫(kù)存數(shù)量,若沒(méi)有小于等于 0 就先進(jìn)行扣減即可
Java 代碼
private static final DefaultRedisScript<Long> DECREASE_GOOD_STOCK_SCRIPT = new DefaultRedisScript<>(); static { DECREASE_GOOD_STOCK_SCRIPT.setLocation(new ClassPathResource("/lua/desc_stock.lua")); // 設(shè)置返回值類(lèi)型 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; }
結(jié)果:Redis 和 MySQL 中的庫(kù)存均為 0 ,沒(méi)有超賣(mài)
使用分布式鎖
可以使用 redisson 分布式鎖進(jìn)行扣減庫(kù)存處理,鎖住查詢和扣減兩個(gè)步驟即可;
若是在分布式環(huán)境下,要考慮 分布式鎖 與 LUA 腳本的結(jié)合!
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; }
測(cè)試結(jié)果:
Redis 中庫(kù)存數(shù)量沒(méi)有超賣(mài)
MySQL 中庫(kù)存數(shù)量沒(méi)有超賣(mài)
總結(jié)
如果在項(xiàng)目初期流量較少可以考慮基于 數(shù)據(jù)庫(kù)行鎖 進(jìn)行庫(kù)存的扣減,到了后期流量大,幾乎都要用到 Redis:
- decrement:追求簡(jiǎn)單快速實(shí)現(xiàn),不考慮 Redis 庫(kù)存中的準(zhǔn)確性;
- LUA 腳本:追求 Redis 中庫(kù)存的準(zhǔn)確性,在 Redis 層面上要進(jìn)行多重的條件判斷
- Lock:追求 Redis 中庫(kù)存的準(zhǔn)確性,在分布式環(huán)境中要考慮 LUA + Lock 的結(jié)合
到此這篇關(guān)于基于MySQL和Redis扣減庫(kù)存的實(shí)踐的文章就介紹到這了,更多相關(guān)MySQL和Redis扣減庫(kù)存內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
MySQL實(shí)現(xiàn)分詞搜索(FULLTEXT)的方法
這篇文章主要介紹了MySQL實(shí)現(xiàn)分詞搜索(FULLTEXT)的方法,包括全文搜索的簡(jiǎn)單使用,建表添加FULLTEXT索引使用該技術(shù)非常簡(jiǎn)單,首先需要有一張表,我建立了一張圖書(shū)表并插入了兩條數(shù)據(jù),需要的朋友可以參考下2022-10-10JSP連接MySQL數(shù)據(jù)庫(kù)詳細(xì)步驟
這篇文章主要介紹了JSP連接MySQL數(shù)據(jù)庫(kù)詳細(xì)步驟,文章內(nèi)容詳細(xì)全面,且通過(guò)實(shí)例進(jìn)行講解,容易理解,需要的朋友可以參考下2023-01-01對(duì)MySQL子查詢的簡(jiǎn)單改寫(xiě)優(yōu)化
這篇文章主要介紹了對(duì)MySQL子查詢的簡(jiǎn)單改寫(xiě)優(yōu)化,文中的小修改主要將子查詢改為關(guān)聯(lián)從而降低查詢時(shí)關(guān)聯(lián)的次數(shù),需要的朋友可以參考下2015-05-05mysql 本地?cái)?shù)據(jù)庫(kù)如何從遠(yuǎn)程數(shù)據(jù)庫(kù)導(dǎo)數(shù)據(jù)
mysql 本地?cái)?shù)據(jù)庫(kù)如何從遠(yuǎn)程數(shù)據(jù)庫(kù)導(dǎo)數(shù)據(jù),本文以此問(wèn)題進(jìn)行詳細(xì)介紹,需要了解的朋友可以參考下2012-11-11linux下mysql自動(dòng)備份數(shù)據(jù)庫(kù)與自動(dòng)刪除臨時(shí)文件
mysql自動(dòng)備份數(shù)據(jù)庫(kù)與自動(dòng)刪除臨時(shí)文件,有需要的朋友可以參考下2013-02-02Windows下通過(guò)cmd進(jìn)入DOS窗口訪問(wèn)MySQL數(shù)據(jù)庫(kù)
這篇文章主要介紹了Windows下通過(guò)cmd進(jìn)入DOS窗口訪問(wèn)MySQL數(shù)據(jù)庫(kù)的實(shí)現(xiàn)方法,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-03-03Mysql中的排序規(guī)則utf8_unicode_ci、utf8_general_ci的區(qū)別總結(jié)
Mysql中utf8_general_ci與utf8_unicode_ci有什么區(qū)別呢?在編程語(yǔ)言中,通常用unicode對(duì)中文字符做處理,防止出現(xiàn)亂碼,那么在MySQL里,為什么大家都使用utf8_general_ci而不是utf8_unicode_ci呢?2014-04-04