Redis高并發(fā)超賣(mài)問(wèn)題解決方案圖文詳解
1. Redis高并發(fā)超賣(mài)問(wèn)題解決方案
在高并發(fā)的秒殺搶購(gòu)場(chǎng)景中,常常會(huì)面臨一個(gè)稱(chēng)為“超賣(mài)”(Over-Selling)的問(wèn)題。超賣(mài)指的是同一件商品被售出的數(shù)量超過(guò)了實(shí)際庫(kù)存數(shù)量,導(dǎo)致庫(kù)存出現(xiàn)負(fù)數(shù)。這是由于多個(gè)用戶同時(shí)發(fā)起搶購(gòu)請(qǐng)求,而系統(tǒng)未能有效地控制庫(kù)存的并發(fā)訪問(wèn)。
下面進(jìn)行一個(gè)秒殺購(gòu)買(mǎi)某個(gè)商品的接口模擬,代碼如下:
@RestController public class MyController { @Autowired StringRedisTemplate stringRedisTemplate; @RequestMapping("/buy/{id}") public String buy(@PathVariable("id") Long id){ String key="product_" + id; int count = Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); if(count>0){ stringRedisTemplate.opsForValue().set(key, String.valueOf(--count)); System.out.println(key+"商品購(gòu)買(mǎi)成功,剩余庫(kù)存"+count); return "success"; } System.out.println(key+"商品庫(kù)存不足"); return "error"; } }
上面的代碼在高并發(fā)環(huán)境下容易出現(xiàn)超賣(mài)問(wèn)題,使用JMeter進(jìn)行壓測(cè),如下圖:
進(jìn)行壓測(cè)獲得的日志如下圖,存在并發(fā)安全問(wèn)題。
要解決上面的問(wèn)題,我們一開(kāi)始想到的是synchronized加鎖,但是在 Redis 的高并發(fā)環(huán)境下,使用 Java 中的 synchronized關(guān)鍵字來(lái)解決超賣(mài)問(wèn)題是行不通的,原因如下:
分布式環(huán)境下無(wú)效: synchronized是 Java 中的關(guān)鍵字,用于在單個(gè) JVM 中保護(hù)共享資源。在分布式環(huán)境下,多個(gè)服務(wù)實(shí)例之間無(wú)法通過(guò)synchronized來(lái)同步,因?yàn)楦鱾€(gè)實(shí)例之間無(wú)法直接共享 JVM 中的鎖。
性能問(wèn)題: synchronized會(huì)導(dǎo)致性能問(wèn)題,尤其在高并發(fā)的情況下,爭(zhēng)奪鎖可能會(huì)成為瓶頸。
對(duì)于 Redis 高并發(fā)環(huán)境下的超賣(mài)問(wèn)題,更合適的解決方案通常是使用 Redis 提供的分布式鎖(如基于 Redis 的分布式鎖實(shí)現(xiàn))。這可以確保在分布式環(huán)境中的原子性和可靠性。
基于Redis的分布式鎖,我們可以基于Redis中的Setnx(命令在指定的 key 不存在時(shí),為 key 設(shè)置指定的值),更改代碼如下:
@RestController public class MyController { @Autowired StringRedisTemplate stringRedisTemplate; @RequestMapping("/buy/{id}") public String buy(@PathVariable("id") Long id){ String lock="product_lock_"+id; String key="product_" + id; Boolean lock1 = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock"); String message="error"; if(!lock1){ System.out.println("業(yè)務(wù)繁忙稍后再試"); return "業(yè)務(wù)繁忙稍后再試"; } //try catch 設(shè)計(jì)是為了防止在執(zhí)行業(yè)務(wù)的時(shí)候出現(xiàn)異常導(dǎo)致redis鎖一直無(wú)法釋放 try { int count = Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); if (count > 0) { stringRedisTemplate.opsForValue().set(key, String.valueOf(--count)); System.out.println(key + "商品購(gòu)買(mǎi)成功,剩余庫(kù)存" + count); message="success"; } }catch (Throwable e){ e.printStackTrace(); }finally { stringRedisTemplate.delete(lock); } if(message.equals("error")) System.out.println(key+"商品庫(kù)存不足"); return message; } }
然后使用JMeter壓測(cè),在10s內(nèi)陸續(xù)發(fā)送500個(gè)請(qǐng)求,日志如下圖,由圖可以看出基本解決超賣(mài)問(wèn)題。
1.1 高并發(fā)場(chǎng)景超賣(mài)bug解析
系統(tǒng)在達(dá)到finally塊之前崩潰宕機(jī),鎖可能會(huì)一直存在于Redis中。這可能會(huì)導(dǎo)致其他進(jìn)程或線程無(wú)法在未來(lái)獲取該鎖,從而導(dǎo)致資源被鎖定,后續(xù)嘗試訪問(wèn)該資源的操作可能被阻塞。因此在redis中給定 key設(shè)置過(guò)期時(shí)間。代碼如下:
@RestController public class MyController { @Autowired StringRedisTemplate stringRedisTemplate; @RequestMapping("/buy/{id}") public String buy(@PathVariable("id") Long id){ String lock="product_lock_"+id; String key="product_" + id; Boolean lock1 = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock",10, TimeUnit.SECONDS); //保證原子性 // Boolean lock2 = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock"); // stringRedisTemplate.expire(lock,10,TimeUnit.SECONDS); //此時(shí)宕機(jī)依舊會(huì)出現(xiàn)redis鎖無(wú)法釋放,應(yīng)設(shè)置為原子操作 String message="error"; if(!lock1){ System.out.println("業(yè)務(wù)繁忙稍后再試"); return "業(yè)務(wù)繁忙稍后再試"; } //try catch 設(shè)計(jì)是為了防止在執(zhí)行業(yè)務(wù)的時(shí)候出現(xiàn)異常導(dǎo)致redis鎖一直無(wú)法釋放 try { int count = Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); if (count > 0) { stringRedisTemplate.opsForValue().set(key, String.valueOf(--count)); System.out.println(key + "商品購(gòu)買(mǎi)成功,剩余庫(kù)存" + count); message="success"; } }catch (Throwable e){ e.printStackTrace(); }finally { stringRedisTemplate.delete(lock); } if(message.equals("error")) System.out.println(key+"商品庫(kù)存不足"); return message; } }
在高并發(fā)場(chǎng)景下,還存在一個(gè)問(wèn)題,即業(yè)務(wù)執(zhí)行時(shí)間過(guò)長(zhǎng)可能導(dǎo)致 Redis 鎖提前釋放,并且誤刪除其他線程或進(jìn)程持有的鎖。這可能發(fā)生在以下情況:
- 線程A獲取鎖并開(kāi)始執(zhí)行業(yè)務(wù)邏輯。
- 由于高并發(fā),其他線程B、C等也嘗試獲取相同資源的鎖。
- 由于鎖的過(guò)期時(shí)間設(shè)置為10秒,線程A的業(yè)務(wù)邏輯執(zhí)行時(shí)間超過(guò)10秒,導(dǎo)致其鎖被 Redis 自動(dòng)釋放。
- 線程B在10秒內(nèi)獲取到了之前由線程A持有的鎖,并開(kāi)始執(zhí)行業(yè)務(wù)邏輯。
- 線程A在業(yè)務(wù)邏輯執(zhí)行完成后,嘗試刪除自己的鎖,但由于已經(jīng)被線程B持有,線程A實(shí)際上刪除的是線程B的鎖。
修改代碼如下:
@RestController public class MyController { @Autowired StringRedisTemplate stringRedisTemplate; @RequestMapping("/buy/{id}") public String buy(@PathVariable("id") Long id){ String lock="product_lock_"+id; String key="product_" + id; String clientId=UUID.randomUUID().toString(); Boolean lock1 = stringRedisTemplate.opsForValue().setIfAbsent(lock, clientId,10, TimeUnit.SECONDS); //保證原子性 // Boolean lock2 = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock"); // stringRedisTemplate.expire(lock,10,TimeUnit.SECONDS); //此時(shí)宕機(jī)依舊會(huì)出現(xiàn)redis鎖無(wú)法釋放,應(yīng)設(shè)置為原子操作 String message="error"; if(!lock1){ System.out.println("業(yè)務(wù)繁忙稍后再試"); return "業(yè)務(wù)繁忙稍后再試"; } //try catch 設(shè)計(jì)是為了防止在執(zhí)行業(yè)務(wù)的時(shí)候出現(xiàn)異常導(dǎo)致redis鎖一直無(wú)法釋放 try { int count = Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); if (count > 0) { stringRedisTemplate.opsForValue().set(key, String.valueOf(--count)); System.out.println(key + "商品購(gòu)買(mǎi)成功,剩余庫(kù)存" + count); message="success"; } }catch (Throwable e){ e.printStackTrace(); }finally { if (stringRedisTemplate.opsForValue().get(lock).equals(clientId))//在這里如果有別的業(yè)務(wù)代碼并且耗時(shí)較長(zhǎng), stringRedisTemplate.delete(lock)之前還是有可能超過(guò)過(guò)期時(shí)間出現(xiàn)問(wèn)題 stringRedisTemplate.delete(lock); } if(message.equals("error")) System.out.println(key+"商品庫(kù)存不足"); return message; } }
上面的代碼在高并發(fā)場(chǎng)景下仍然存在概率很低的問(wèn)題,所以就有了redisson分布式鎖。
1.2 Redisson
Redisson 是一個(gè)用于 Java 的 Redis 客戶端,它提供了豐富的功能,包括分布式鎖。Redisson 的分布式鎖實(shí)現(xiàn)了基于 Redis 的分布式鎖,具有簡(jiǎn)單易用、可靠性高的特點(diǎn)。
以下是 Redisson 分布式鎖的一些重要特性和用法:
可重入鎖: Redisson 的分布式鎖是可重入的,同一線程可以多次獲取同一把鎖,而不會(huì)出現(xiàn)死鎖。
公平鎖: Redisson 支持公平鎖,即按照獲取鎖的順序依次獲取,避免了某些線程一直獲取不到鎖的情況。
鎖超時(shí): 可以為分布式鎖設(shè)置過(guò)期時(shí)間,確保即使在某些情況下鎖沒(méi)有被顯式釋放,也能在一定時(shí)間后自動(dòng)釋放。
異步鎖: Redisson 提供了異步的分布式鎖,通過(guò)異步 API 可以在不阻塞線程的情況下獲取和釋放鎖。
監(jiān)控鎖狀態(tài): Redisson 允許監(jiān)控鎖的狀態(tài),包括鎖是否被某個(gè)線程持有,鎖的過(guò)期時(shí)間等。
導(dǎo)入依賴(lài)
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.23.5</version> </dependency>
application.yaml 配置:
spring: redis: host: 127.0.0.1 port: 6379 password: lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: 1000ms
RedissonConfig配置:
@Configuration public class RedissonConfig { @Value("${spring.redis.host}") private String host; @Value("${spring.redis.port}") private String port; /** * RedissonClient,單機(jī)模式 */ @Bean public RedissonClient redisson() { Config config = new Config(); SingleServerConfig singleServerConfig = config.useSingleServer(); singleServerConfig.setAddress("redis://" + host + ":" + port); return Redisson.create(config); } }
利用Redisson分布式鎖解決超賣(mài)問(wèn)題,修改代碼如下:
加鎖 lock.lock()
阻塞等待,默認(rèn)等待,加鎖的默認(rèn)時(shí)間都是30s,鎖的自動(dòng)續(xù)期,如果業(yè)務(wù)時(shí)間長(zhǎng),運(yùn)行期間會(huì)自動(dòng)給鎖續(xù)上新的30s,不用擔(dān)心業(yè)務(wù)時(shí)間長(zhǎng)導(dǎo)致鎖自動(dòng)過(guò)期被刪除,加鎖的業(yè)務(wù)只要運(yùn)行完成,就不會(huì)給當(dāng)前鎖續(xù)期,即使不手動(dòng)解鎖,鎖默認(rèn)在30s后自動(dòng)刪除。
加鎖 lock.lock(10,TimeUnit.SECONDS)
鎖到期后,不會(huì)自動(dòng)續(xù)期,如果傳遞了鎖的超時(shí)時(shí)間,就發(fā)送給redis執(zhí)行腳本,進(jìn)行占鎖,默認(rèn)超時(shí)就是我們指定的時(shí)間。如果未指定鎖的超時(shí)時(shí)間,只要占鎖成功,就會(huì)啟動(dòng)一個(gè)定時(shí)任務(wù)【重新給鎖設(shè)置過(guò)期時(shí)間,新的過(guò)期時(shí)間就是看門(mén)狗的默認(rèn)時(shí)間】,每隔10s就會(huì)自動(dòng)進(jìn)行續(xù)期。
@RestController public class MyController { @Autowired StringRedisTemplate stringRedisTemplate; @Autowired RedissonClient redisson; @RequestMapping("/buy/{id}") public String buy(@PathVariable("id") Long id){ String message="error"; String lock_key="product_lock_"+id; String key="product_" + id; RLock lock = redisson.getLock(lock_key); //try catch 設(shè)計(jì)是為了防止在執(zhí)行業(yè)務(wù)的時(shí)候出現(xiàn)異常導(dǎo)致redis鎖一直無(wú)法釋放 try { lock.lock(); int count = Integer.parseInt(stringRedisTemplate.opsForValue().get(key)); if (count > 0) { stringRedisTemplate.opsForValue().set(key, String.valueOf(--count)); System.out.println(key + "商品購(gòu)買(mǎi)成功,剩余庫(kù)存" + count); message="success"; } }catch (Throwable e){ e.printStackTrace(); }finally { lock.unlock(); } if(message.equals("error")) System.out.println(key+"商品庫(kù)存不足"); return message; } }
總結(jié)
到此這篇關(guān)于Redis高并發(fā)超賣(mài)問(wèn)題解決方案的文章就介紹到這了,更多相關(guān)Redis高并發(fā)超賣(mài)問(wèn)題解決內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
redis實(shí)現(xiàn)計(jì)數(shù)器-防止刷單方法介紹
本文主要向大家介紹了redis實(shí)現(xiàn)計(jì)數(shù)器防止刷單的方法和有關(guān)代碼,具有一定參考價(jià)值,需要的朋友可以了解下。2017-11-11Redis list 類(lèi)型學(xué)習(xí)筆記與總結(jié)
這篇文章主要介紹了Redis list 類(lèi)型學(xué)習(xí)筆記與總結(jié),本文著重講解了關(guān)于List的一些常用方法,比如lpush 方法、lrange 方法、rpush 方法、linsert 方法、 lset 方法等,需要的朋友可以參考下2015-06-06Redis的Expire與Setex區(qū)別說(shuō)明
這篇文章主要介紹了Redis的Expire與Setex區(qū)別說(shuō)明,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-09-09Redis 事務(wù)與過(guò)期時(shí)間詳細(xì)介紹
這篇文章主要介紹了Redis 事務(wù)與過(guò)期時(shí)間詳細(xì)介紹的相關(guān)資料,需要的朋友可以參考下2017-05-05Redis實(shí)現(xiàn)布隆過(guò)濾器的方法及原理
布隆過(guò)濾器優(yōu)點(diǎn)是空間效率和查詢時(shí)間都比一般的算法要好的多,缺點(diǎn)是有一定的誤識(shí)別率和刪除困難。本文將介紹布隆過(guò)濾器的原理以及Redis如何實(shí)現(xiàn)布隆過(guò)濾器,感興趣的朋友跟隨小編一起看看吧2019-12-12Redis哨兵主備切換的數(shù)據(jù)丟失問(wèn)題及解決
主備切換過(guò)程中可能會(huì)導(dǎo)致數(shù)據(jù)丟失,異步復(fù)制和腦裂是兩種主要原因,異步復(fù)制可能導(dǎo)致部分?jǐn)?shù)據(jù)未復(fù)制到slave而master宕機(jī),腦裂則可能導(dǎo)致多個(gè)master存在,舊master恢復(fù)后數(shù)據(jù)被清空,從而丟失數(shù)據(jù)2024-12-12Redis內(nèi)存碎片率調(diào)優(yōu)處理方式
Redis集群因內(nèi)存碎片率超過(guò)1.5觸發(fā)告警,分析發(fā)現(xiàn)內(nèi)因與外因?qū)е聝?nèi)存碎片,內(nèi)因?yàn)椴僮飨到y(tǒng)內(nèi)存分配機(jī)制,外因?yàn)镽edis操作特性,使用Redis內(nèi)置內(nèi)存碎片清理機(jī)制可有效降低碎片率,但需注意可能影響性能,建議使用MEMORY命令診斷內(nèi)存使用情況,合理配置參數(shù)以優(yōu)化性能2024-09-09