Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购_超卖 redis

    // 设置key和value的序列化规则
    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;
}

}


#### 改造原先添加代金券逻辑


原先添加代金券的逻辑如下:  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/51514b3be296404284c5be788a1a432a.png)


现在需要把跟数据库交互的部分改成和redis交互,改造后代码如下:



	// 采用 Redis 实现
    String key = RedisKeyConstant.seckill_vouchers.getKey() +
            seckillVouchers.getFkVoucherId();
    // 验证 Redis 中是否已经存在该券的秒杀活动
    Map<String, Object> map = redisTemplate.opsForHash().entries(key);
    AssertUtil.isTrue(!map.isEmpty() && (int) map.get("amount") > 0, "该券已经拥有了抢购活动");

    // 插入 Redis
    seckillVouchers.setIsValid(1);
    seckillVouchers.setCreateDate(now);
    seckillVouchers.setUpdateDate(now);
    redisTemplate.opsForHash().putAll(key, BeanUtil.beanToMap(seckillVouchers));

执行测试,新建秒杀代金券活动存储到Redis中:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/414e9b85255bd8a1c3e6500ef5706b25.png#averageHue=#fefefd&clientId=u4abdc2e5-3be3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=550&id=ub071892a&margin=%5BobjectObject%5D&name=image.png&originHeight=688&originWidth=590&originalType=binary&ratio=1&rotation=0&showTitle=false&size=38940&status=done&style=none&taskId=ue53a107d-a70c-473b-a367-3bc81d214ac&title=&width=472)  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/a2f393400c3544db9f024f1600d56a66.png)  
 可以看到数据已经存储到redis中。


#### 改造下单逻辑


##### 调整数据库相关为redis


原先关系型数据库下单逻辑:



/\*\*

* 抢购代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登录token
* @Para path 访问路径
*/
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本参数校验
AssertUtil.isTrue(voucherId == null || voucherId < 0, “请选择需要抢购的代金券”);
AssertUtil.isNotEmpty(accessToken, “请登录”);
// 判断此代金券是否加入抢购
SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
AssertUtil.isTrue(seckillVouchers == null, “该代金券并未有抢购活动”);
// 判断是否有效
AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, “该活动已结束”);
// 判断是否开始、结束
Date now = new Date();
AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), “该抢购还未开始”);
AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), “该抢购已结束”);
// 判断是否卖完
AssertUtil.isTrue(seckillVouchers.getAmount() < 1, “该券已经卖完了”);
// 获取登录用户信息
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是一个LinkedHashMap,SignInDinerInfo
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
new SignInDinerInfo(), false);
// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
seckillVouchers.getId());
AssertUtil.isTrue(order != null, “该用户已抢到该代金券,无需再抢”);
// 扣库存
int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
AssertUtil.isTrue(count == 0, “该券已经卖完了”);
// 下单
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, "抢购成功");
}

查询、扣库存和下单逻辑调整成Redis:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/cf2035a03695110a7ee42b47e9089dbf.png#averageHue=#302f2d&clientId=u4abdc2e5-3be3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=92&id=u4736109d&margin=%5BobjectObject%5D&name=image.png&originHeight=115&originWidth=1127&originalType=binary&ratio=1&rotation=0&showTitle=false&size=18309&status=done&style=none&taskId=uc1c4ddf4-f1a8-41d7-a922-410fb04a51c&title=&width=901.6)



	// 扣库存 redis没有自减方法,数值传负数表示自减
    long count = redisTemplate.opsForHash().increment(key, "amount", -1);
    AssertUtil.isTrue(count <= 0, "该券已经卖完了");

订单信息还是保存到数据库中



    // 下单
    VoucherOrders voucherOrders = new VoucherOrders();
    voucherOrders.setFkDinerId(dinerInfo.getId());
    //redis中不需要维护该外键信息

// 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发起3000个线程,2000个用户并发请求,查看库存情况,目前还是超卖的:  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/42f89735fa7344009d8faf01d14b94d4.png)  
 订单的数量是正确的:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/eaeaa8a29018537dbe499be437a6fe58.png#averageHue=#fafaf9&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=161&id=u8070972f&margin=%5BobjectObject%5D&name=image.png&originHeight=201&originWidth=402&originalType=binary&ratio=1&rotation=0&showTitle=false&size=6539&status=done&style=none&taskId=u252a6671-191a-4b3d-b533-2ca5511d7b8&title=&width=321.6)  
 因为这一步判定是单线程的



long count = redisTemplate.opsForHash().increment(key, “amount”, -1);
AssertUtil.isTrue(count <= 0, “该券已经卖完了”);


是不是先下单然后再扣库存就可以了?当然不行,如果上面位置调整下会导致库存数量不对,订单数量也不对😰😰😰。  
 我们继续在先下单后扣库存的方法上添加一个事务:@Transactional(rollbackFor = Exception.class)。  
 执行发现订单数量正常了,库存还是负数:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/cafff8b5f230706e0c2fb45bdb382e3e.png#averageHue=#f0eceb&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=206&id=ub4da2b83&margin=%5BobjectObject%5D&name=image.png&originHeight=257&originWidth=455&originalType=binary&ratio=1&rotation=0&showTitle=false&size=7019&status=done&style=none&taskId=u574296d3-fbf1-43a4-97a5-6d95872a8f4&title=&width=364)  
 为什么会有这个问题呢,因为`redisTemplate.opsForHash().increment(key, "amount", -1)`这一步操作在redis中实际执行的是先查询再减少的操作,在高并发场景下会有问题。我们需要保证这两步的原子性。


##### Redis + Lua 解决超卖问题


在yml配置文件同级目录添加lua脚本,脚本内容如下:



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;
}

扣库存逻辑调整如下:



        	// 采用 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, "该券已经卖完了");

![image.png](https://img-blog.csdnimg.cn/img_convert/fa5ddcafa7265fa41f2404ae2b403029.png#averageHue=#3d4145&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=366&id=uba194fb6&margin=%5BobjectObject%5D&name=image.png&originHeight=457&originWidth=781&originalType=binary&ratio=1&rotation=0&showTitle=false&size=37006&status=done&style=none&taskId=u63128995-5f64-4f02-902d-60c1c755ddd&title=&width=624.8)  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/4d231dbca27d2cc35e8724f9fcf55caa.png#averageHue=#3e4245&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=300&id=u53e98886&margin=%5BobjectObject%5D&name=image.png&originHeight=375&originWidth=1138&originalType=binary&ratio=1&rotation=0&showTitle=false&size=38178&status=done&style=none&taskId=u09a4b7b2-4b19-4d82-ba54-49e4090d3ce&title=&width=910.4)  
 重置数据后执行JMeter执行5000个线程,两千个用户并发下单测试,结果如下:  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/e526b303f3d241f69603402c770ee527.png)  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/9765dc4b4932ddac3b51a3c0094bd28f.png#averageHue=#faf9f9&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=155&id=u40b52518&margin=%5BobjectObject%5D&name=image.png&originHeight=194&originWidth=419&originalType=binary&ratio=1&rotation=0&showTitle=false&size=8566&status=done&style=none&taskId=uaadbcbd1-4815-4765-ac5e-eb1c141a623&title=&width=335.2)  
 库存0,订单100,超卖问题解决。  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/1f32bd7aa6c84e8531e8cb1f7cb244b8.png#averageHue=#3e4245&clientId=ub754daa2-b664-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=360&id=u38bd6649&margin=%5BobjectObject%5D&name=image.png&originHeight=450&originWidth=1021&originalType=binary&ratio=1&rotation=0&showTitle=false&size=47690&status=done&style=none&taskId=u6f31b5e0-4901-4ca7-a87c-ac8fe514bbe&title=&width=816.8)  
 JMeter的结果值有中文乱码,进入JMeter安装位置,调整jmeter.properties文件中的`sampleresult.default.encoding`为UTF-8。重启JMeter再测试不再乱码。  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/d25dca19fb5a4fef8bf956c295bd82c2.png)


### 解决同一用户多次抢购问题


#### 问题描述


用JMeter测试同一用户并发抢购:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/c0afecdd979dac27a6db9a0e0949eebd.png#averageHue=#3e4245&clientId=u69367c94-3c16-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=226&id=u2f145cc7&margin=%5BobjectObject%5D&name=image.png&originHeight=282&originWidth=844&originalType=binary&ratio=1&rotation=0&showTitle=false&size=14933&status=done&style=none&taskId=u4b713907-2581-4820-83ba-417bfa825e4&title=&width=675.2)  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/62c211094161e7deb53e2c243853199f.png#averageHue=#3e4347&clientId=u69367c94-3c16-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=194&id=ufa31219a&margin=%5BobjectObject%5D&name=image.png&originHeight=242&originWidth=1058&originalType=binary&ratio=1&rotation=0&showTitle=false&size=22288&status=done&style=none&taskId=udeb12381-d895-4535-9467-31d1b0c2f92&title=&width=846.4)  
 查看数据库发现同一用户下单了多次:  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/ec0da2ff576e4cc7a4c7b715fd58b587.png)


#### Redisson 分布式锁解决同一用户多次下单


##### 什么是Redisson


![在这里插入图片描述](https://img-blog.csdnimg.cn/1e58e89eae724c69a8e9749ca0f99f03.png)  
 上图就是[redission官方网站]( )首页。  
 首页可以看出来,Redisson可以实现很多东西,在Redis的基础上,Redisson做了超多的封装,我们看一下,例如说  
 Spring Cache,TomcatSession,Spring Session,可排序的Set,还有呢Sortedsort,下面还有各种队列,包括这种双端  
 队列,还有map,这些是数据结构,下面就是各种锁,读写锁,这里面的锁还包含,可重入锁,还有CountDownLantch,这个是在多线程的时候使用的,比如说我启动很多个线程,去执行某个任务,然后把任务进行切分,都完成之后有一个等待,等待所有线程都达到这里之后,在一起往下走,把异步再变成同步,下边是一些线程池,还有订阅的各种功能,ScheduleService来做调度的一个任务,所以Redisson是非常强大的,然后我们在右上角有一个[documentation]( ),我们可以打开它,Redisson官方也提供了中文文档:[https://github.com/redisson/redisson/wiki/目录]( )。


##### 问题解决


同一用户可以多次抢购本质上是一个用户在抢购的某个商品的时候没有加锁,导致同一用户的多个线程同时进入抢购,接下来通过Redisson分布式锁来解决同一用户多次下单的问题。  
 锁的对象为用户id和代金券活动id,表示同一用户只能抢购一次某活动。改造后代码如下:



/\*\*

* 抢购代金券
*
* @param voucherId 代金券 ID
* @param accessToken 登录token
* @Para path 访问路径
*/
@Transactional(rollbackFor = Exception.class)
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) {
// 基本参数校验
AssertUtil.isTrue(voucherId == null || voucherId < 0, “请选择需要抢购的代金券”);
AssertUtil.isNotEmpty(accessToken, “请登录”);

    // 采用 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);
    // 判断是否开始、结束
    Date now = new Date();
    AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");
    AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");
    // 判断是否卖完
    AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了");
    // 获取登录用户信息
    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是一个LinkedHashMap,SignInDinerInfo
    SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
            new SignInDinerInfo(), false);
    // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
            seckillVouchers.getId());
    AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");

    // 使用 Redis 锁一个账号只能购买一次
    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中不需要维护该外键信息
            // 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, "该券已经卖完了");
        }
    } catch (Exception e) {
        // 手动回滚事务
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

        // Redisson 解锁
        lock.unlock();
        if (e instanceof ParameterException) {
            return ResultInfoUtil.buildError(0, "该券已经卖完了", path);
        }
    }

    return ResultInfoUtil.buildSuccess(path, "抢购成功");
}

JMeter测试验证,同一用户并发请求某一活动,只能下单一次:  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/c9a456dafa398703774c01970763a648.png#averageHue=#eeeded&clientId=u69367c94-3c16-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=228&id=uf2b3b6c2&margin=%5BobjectObject%5D&name=image.png&originHeight=285&originWidth=459&originalType=binary&ratio=1&rotation=0&showTitle=false&size=7099&status=done&style=none&taskId=ue323a4ea-e41b-4e2d-89c2-0c004418419&title=&width=367.2)  
 ![image.png](https://img-blog.csdnimg.cn/img_convert/f87776df8bb515943efb32ac38178aa6.png#averageHue=#fbf8f7&clientId=u69367c94-3c16-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=148&id=u85cc82a4&margin=%5BobjectObject%5D&name=image.png&originHeight=185&originWidth=1226&originalType=binary&ratio=1&rotation=0&showTitle=false&size=15364&status=done&style=none&taskId=u246712cd-6538-4f8e-9a8b-ac17ccbec7d&title=&width=980.8)  
 库存剩99,订单1条,完美。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值