Redis解決秒殺微服務(wù)搶購代金券超賣和同一個(gè)用戶多次搶購
之前的博客,我通過 傳統(tǒng)的數(shù)據(jù)庫方式實(shí)現(xiàn)秒殺按照正常邏輯來走,通過壓力測(cè)試發(fā)現(xiàn)會(huì)有超賣合同一用戶可以多次搶購?fù)淮鹑膯栴}。本文我將講述通過redis來解決超賣和同一用戶多次搶購問題。
超賣和同一用戶多次搶購問題分析
/**
* 搶購代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登錄token
* @Para path 訪問路徑
*/
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本參數(shù)校驗(yàn)
AssertUtil.isTrue(voucherId == null || voucherId < 0, "請(qǐng)選擇需要搶購的代金券");
AssertUtil.isNotEmpty(accessToken, "請(qǐng)登錄");
// 判斷此代金券是否加入搶購
SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
AssertUtil.isTrue(seckillVouchers == null, "該代金券并未有搶購活動(dòng)");
// 判斷是否有效
AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "該活動(dòng)已結(jié)束");
// 判斷是否開始、結(jié)束
Date now = new Date();
AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "該搶購還未開始");
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "該搶購已結(jié)束");
// 判斷是否賣完
AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "該券已經(jīng)賣完了");
// 獲取登錄用戶信息
String url = oauthServerName + "user/me?access_token={accessToken}";
ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
resultInfo.setPath(path);
return resultInfo;
}
// 這里的data是一個(gè)LinkedHashMap,SignInDinerInfo
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
// 判斷登錄用戶是否已搶到(一個(gè)用戶針對(duì)這次活動(dòng)只能買一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
seckillVouchers.getId());
AssertUtil.isTrue(order != null, "該用戶已搶到該代金券,無需再搶");
// 扣庫存
int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
AssertUtil.isTrue(count == 0, "該券已經(jīng)賣完了");
// 下單
VoucherOrders voucherOrders = new VoucherOrders();
voucherOrders.setFkDinerId(dinerInfo.getId());
voucherOrders.setFkSeckillId(seckillVouchers.getId());
voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
voucherOrders.setOrderNo(orderNo);
voucherOrders.setOrderType(1);
voucherOrders.setStatus(0);
count = voucherOrdersMapper.save(voucherOrders);
AssertUtil.isTrue(count == 0, "用戶搶購失敗");
return ResultInfoUtil.buildSuccess(path, "搶購成功");
}

高并發(fā)環(huán)境下會(huì)導(dǎo)致上圖的判斷出現(xiàn)錯(cuò)誤。在高并發(fā)環(huán)境下,會(huì)有多個(gè)線程拿到的庫存值都大于0,實(shí)際的繼續(xù)往下執(zhí)行的線程會(huì)高于實(shí)際的庫存值,繼續(xù)執(zhí)行會(huì)導(dǎo)致賣出的訂單量超過庫存本身的數(shù)量,導(dǎo)致庫存超賣。

同理同一用戶多次發(fā)起,同時(shí)到達(dá)這一步也會(huì)錯(cuò)判,在還沒有獲取到最新的存儲(chǔ)結(jié)果時(shí),都會(huì)判定成是未搶購過,導(dǎo)致同一用戶可以重復(fù)搶購問題。
解決庫存超賣問題
添加相關(guān)枚舉
在redis鍵的枚舉類中添加如下枚舉:

分布式鎖的key來約束同一用戶只能搶購一次。
添加RedisTemplate配置類
/**
* RedisTemplate配置類
* @author zjq
*/
@Configuration
public class RedisTemplateConfiguration {
/**
* redisTemplate 序列化使用的jdkSerializeable, 存儲(chǔ)二進(jìn)制字節(jié)碼, 所以自定義序列化類
*
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用Jackson2JsonRedisSerialize 替換默認(rèn)序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 設(shè)置key和value的序列化規(guī)則
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
@Bean
public DefaultRedisScript<Long> stockScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//放在和application.yml 同層目錄下
redisScript.setLocation(new ClassPathResource("stock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
}
改造原先添加代金券邏輯
原先添加代金券的邏輯如下:

現(xiàn)在需要把跟數(shù)據(jù)庫交互的部分改成和redis交互,改造后代碼如下:
// 采用 Redis 實(shí)現(xiàn)
String key = RedisKeyConstant.seckill_vouchers.getKey() +
seckillVouchers.getFkVoucherId();
// 驗(yàn)證 Redis 中是否已經(jīng)存在該券的秒殺活動(dòng)
Map<String, Object> map = redisTemplate.opsForHash().entries(key);
AssertUtil.isTrue(!map.isEmpty() && (int) map.get("amount") > 0, "該券已經(jīng)擁有了搶購活動(dòng)");
// 插入 Redis
seckillVouchers.setIsValid(1);
seckillVouchers.setCreateDate(now);
seckillVouchers.setUpdateDate(now);
redisTemplate.opsForHash().putAll(key, BeanUtil.beanToMap(seckillVouchers));
執(zhí)行測(cè)試,新建秒殺代金券活動(dòng)存儲(chǔ)到Redis中:


可以看到數(shù)據(jù)已經(jīng)存儲(chǔ)到redis中。
改造下單邏輯
調(diào)整數(shù)據(jù)庫相關(guān)為redis
原先關(guān)系型數(shù)據(jù)庫下單邏輯:
/**
* 搶購代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登錄token
* @Para path 訪問路徑
*/
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本參數(shù)校驗(yàn)
AssertUtil.isTrue(voucherId == null || voucherId < 0, "請(qǐng)選擇需要搶購的代金券");
AssertUtil.isNotEmpty(accessToken, "請(qǐng)登錄");
// 判斷此代金券是否加入搶購
SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
AssertUtil.isTrue(seckillVouchers == null, "該代金券并未有搶購活動(dòng)");
// 判斷是否有效
AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "該活動(dòng)已結(jié)束");
// 判斷是否開始、結(jié)束
Date now = new Date();
AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "該搶購還未開始");
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "該搶購已結(jié)束");
// 判斷是否賣完
AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "該券已經(jīng)賣完了");
// 獲取登錄用戶信息
String url = oauthServerName + "user/me?access_token={accessToken}";
ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
resultInfo.setPath(path);
return resultInfo;
}
// 這里的data是一個(gè)LinkedHashMap,SignInDinerInfo
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
// 判斷登錄用戶是否已搶到(一個(gè)用戶針對(duì)這次活動(dòng)只能買一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
seckillVouchers.getId());
AssertUtil.isTrue(order != null, "該用戶已搶到該代金券,無需再搶");
// 扣庫存
int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
AssertUtil.isTrue(count == 0, "該券已經(jīng)賣完了");
// 下單
VoucherOrders voucherOrders = new VoucherOrders();
voucherOrders.setFkDinerId(dinerInfo.getId());
voucherOrders.setFkSeckillId(seckillVouchers.getId());
voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
voucherOrders.setOrderNo(orderNo);
voucherOrders.setOrderType(1);
voucherOrders.setStatus(0);
count = voucherOrdersMapper.save(voucherOrders);
AssertUtil.isTrue(count == 0, "用戶搶購失敗");
return ResultInfoUtil.buildSuccess(path, "搶購成功");
}
查詢、扣庫存和下單邏輯調(diào)整成Redis:

// 扣庫存 redis沒有自減方法,數(shù)值傳負(fù)數(shù)表示自減
long count = redisTemplate.opsForHash().increment(key, "amount", -1);
AssertUtil.isTrue(count <= 0, "該券已經(jīng)賣完了");
訂單信息還是保存到數(shù)據(jù)庫中
// 下單
VoucherOrders voucherOrders = new VoucherOrders();
voucherOrders.setFkDinerId(dinerInfo.getId());
//redis中不需要維護(hù)該外鍵信息
// voucherOrders.setFkSeckillId(seckillVouchers.getId());
voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
voucherOrders.setOrderNo(orderNo);
voucherOrders.setOrderType(1);
voucherOrders.setStatus(0);
count = voucherOrdersMapper.save(voucherOrders);
JMeter發(fā)起3000個(gè)線程,2000個(gè)用戶并發(fā)請(qǐng)求,查看庫存情況,目前還是超賣的:

訂單的數(shù)量是正確的:

因?yàn)檫@一步判定是單線程的
long count = redisTemplate.opsForHash().increment(key, "amount", -1); AssertUtil.isTrue(count <= 0, "該券已經(jīng)賣完了");
是不是先下單然后再扣庫存就可以了?當(dāng)然不行,如果上面位置調(diào)整下會(huì)導(dǎo)致庫存數(shù)量不對(duì),訂單數(shù)量也不對(duì)??????。
我們繼續(xù)在先下單后扣庫存的方法上添加一個(gè)事務(wù):@Transactional(rollbackFor = Exception.class)。
執(zhí)行發(fā)現(xiàn)訂單數(shù)量正常了,庫存還是負(fù)數(shù):

為什么會(huì)有這個(gè)問題呢,因?yàn)?code>redisTemplate.opsForHash().increment(key, "amount", -1)這一步操作在redis中實(shí)際執(zhí)行的是先查詢?cè)贉p少的操作,在高并發(fā)場(chǎng)景下會(huì)有問題。我們需要保證這兩步的原子性。
Redis + Lua 解決超賣問題
在yml配置文件同級(jí)目錄添加lua腳本,腳本內(nèi)容如下:
if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) then
local stock = tonumber(redis.call('hget', KEYS[1], KEYS[2]));
if (stock > 0) then
redis.call('hincrby', KEYS[1], KEYS[2], -1);
return stock;
end;
return 0;
end;
在RedisTemplate配置類中添加如下配置bean并注入lua腳本:
@Bean
public DefaultRedisScript<Long> stockScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//放在和application.yml 同層目錄下
redisScript.setLocation(new ClassPathResource("stock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
扣庫存邏輯調(diào)整如下:
// 采用 Redis + Lua 解決超賣問題
// 扣庫存
List<String> keys = new ArrayList<>();
keys.add(key);
keys.add("amount");
Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);
AssertUtil.isTrue(amount == null || amount < 1, "該券已經(jīng)賣完了");


重置數(shù)據(jù)后執(zhí)行JMeter執(zhí)行5000個(gè)線程,兩千個(gè)用戶并發(fā)下單測(cè)試,結(jié)果如下:


庫存0,訂單100,超賣問題解決。

JMeter的結(jié)果值有中文亂碼,進(jìn)入JMeter安裝位置,調(diào)整jmeter.properties文件中的sampleresult.default.encoding為UTF-8。重啟JMeter再測(cè)試不再亂碼。

解決同一用戶多次搶購問題
問題描述
用JMeter測(cè)試同一用戶并發(fā)搶購:


查看數(shù)據(jù)庫發(fā)現(xiàn)同一用戶下單了多次:

Redisson 分布式鎖解決同一用戶多次下單
什么是Redisson

上圖就是redission官方網(wǎng)站首頁。
首頁可以看出來,Redisson可以實(shí)現(xiàn)很多東西,在Redis的基礎(chǔ)上,Redisson做了超多的封裝,我們看一下,例如說
Spring Cache,TomcatSession,Spring Session,可排序的Set,還有呢Sortedsort,下面還有各種隊(duì)列,包括這種雙端
隊(duì)列,還有map,這些是數(shù)據(jù)結(jié)構(gòu),下面就是各種鎖,讀寫鎖,這里面的鎖還包含,可重入鎖,還有CountDownLantch,這個(gè)是在多線程的時(shí)候使用的,比如說我啟動(dòng)很多個(gè)線程,去執(zhí)行某個(gè)任務(wù),然后把任務(wù)進(jìn)行切分,都完成之后有一個(gè)等待,等待所有線程都達(dá)到這里之后,在一起往下走,把異步再變成同步,下邊是一些線程池,還有訂閱的各種功能,ScheduleService來做調(diào)度的一個(gè)任務(wù),所以Redisson是非常強(qiáng)大的,然后我們?cè)谟疑辖怯幸粋€(gè)documentation,我們可以打開它,Redisson官方也提供了中文文檔:https://github.com/redisson/redisson/wiki/目錄。
問題解決
同一用戶可以多次搶購本質(zhì)上是一個(gè)用戶在搶購的某個(gè)商品的時(shí)候沒有加鎖,導(dǎo)致同一用戶的多個(gè)線程同時(shí)進(jìn)入搶購,接下來通過Redisson分布式鎖來解決同一用戶多次下單的問題。
鎖的對(duì)象為用戶id和代金券活動(dòng)id,表示同一用戶只能搶購一次某活動(dòng)。改造后代碼如下:
/**
* 搶購代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登錄token
* @Para path 訪問路徑
*/
@Transactional(rollbackFor = Exception.class)
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本參數(shù)校驗(yàn)
AssertUtil.isTrue(voucherId == null || voucherId < 0, "請(qǐng)選擇需要搶購的代金券");
AssertUtil.isNotEmpty(accessToken, "請(qǐng)登錄");
// 采用 Redis
String key = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;
Map<String, Object> map = redisTemplate.opsForHash().entries(key);
SeckillVouchers seckillVouchers = BeanUtil.mapToBean(map, SeckillVouchers.class, true, null);
// 判斷是否開始、結(jié)束
Date now = new Date();
AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "該搶購還未開始");
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "該搶購已結(jié)束");
// 判斷是否賣完
AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "該券已經(jīng)賣完了");
// 獲取登錄用戶信息
String url = oauthServerName + "user/me?access_token={accessToken}";
ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
resultInfo.setPath(path);
return resultInfo;
}
// 這里的data是一個(gè)LinkedHashMap,SignInDinerInfo
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
// 判斷登錄用戶是否已搶到(一個(gè)用戶針對(duì)這次活動(dòng)只能買一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
seckillVouchers.getId());
AssertUtil.isTrue(order != null, "該用戶已搶到該代金券,無需再搶");
// 使用 Redis 鎖一個(gè)賬號(hào)只能購買一次
String lockName = RedisKeyConstant.lock_key.getKey()
+ dinerInfo.getId() + ":" + voucherId;
long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime();
// Redisson 分布式鎖
RLock lock = redissonClient.getLock(lockName);
try {
// Redisson 分布式鎖處理
boolean isLocked = lock.tryLock(expireTime, TimeUnit.MILLISECONDS);
if (isLocked) {
// 下單
VoucherOrders voucherOrders = new VoucherOrders();
voucherOrders.setFkDinerId(dinerInfo.getId());
//redis中不需要維護(hù)該外鍵信息
// voucherOrders.setFkSeckillId(seckillVouchers.getId());
voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId());
String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
voucherOrders.setOrderNo(orderNo);
voucherOrders.setOrderType(1);
voucherOrders.setStatus(0);
long count = voucherOrdersMapper.save(voucherOrders);
AssertUtil.isTrue(count == 0, "用戶搶購失敗");
// 采用 Redis + Lua 解決超賣問題
// 扣庫存
List<String> keys = new ArrayList<>();
keys.add(key);
keys.add("amount");
Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);
AssertUtil.isTrue(amount == null || amount < 1, "該券已經(jīng)賣完了");
}
} catch (Exception e) {
// 手動(dòng)回滾事務(wù)
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
// Redisson 解鎖
lock.unlock();
if (e instanceof ParameterException) {
return ResultInfoUtil.buildError(0, "該券已經(jīng)賣完了", path);
}
}
return ResultInfoUtil.buildSuccess(path, "搶購成功");
}
JMeter測(cè)試驗(yàn)證,同一用戶并發(fā)請(qǐng)求某一活動(dòng),只能下單一次:


庫存剩99,訂單1條,完美。
到此這篇關(guān)于Redis解決秒殺微服務(wù)搶購代金券超賣和同一個(gè)用戶多次搶購的文章就介紹到這了,更多相關(guān)Redis超賣和同一個(gè)用戶多次搶購內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redisson實(shí)現(xiàn)分布式鎖、鎖續(xù)約的案例
這篇文章主要介紹了Redisson如何實(shí)現(xiàn)分布式鎖、鎖續(xù)約,本文通過示例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-03-03
Redis未授權(quán)訪問配合SSH key文件利用詳解
這篇文章主要給大家介紹了關(guān)于Redis未授權(quán)訪問配合SSH key文件利用的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-09-09
關(guān)于redis Key淘汰策略的實(shí)現(xiàn)方法
下面小編就為大家?guī)硪黄P(guān)于redis Key淘汰策略的實(shí)現(xiàn)方法。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-03-03
Redis遍歷所有key的兩個(gè)命令(KEYS 和 SCAN)
這篇文章主要介紹了Redis遍歷所有key的兩個(gè)命令(KEYS 和 SCAN),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04
Redis消息隊(duì)列實(shí)現(xiàn)異步秒殺功能
在高并發(fā)場(chǎng)景下,為了提高秒殺業(yè)務(wù)的性能,可將部分工作交給 Redis 處理,并通過異步方式執(zhí)行,Redis 提供了多種數(shù)據(jù)結(jié)構(gòu)來實(shí)現(xiàn)消息隊(duì)列,總結(jié)三種,本文詳細(xì)介紹Redis消息隊(duì)列實(shí)現(xiàn)異步秒殺功能,感興趣的朋友一起看看吧2025-04-04

