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

Posted 共饮一杯无

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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, "抢购成功");
    


高并发环境下会导致上图的判断出现错误。在高并发环境下,会有多个线程拿到的库存值都大于0,实际的继续往下执行的线程会高于实际的库存值,继续执行会导致卖出的订单量超过库存本身的数量,导致库存超卖。

同理同一用户多次发起,同时到达这一步也会错判,在还没有获取到最新的存储结果时,都会判定成是未抢购过,导致同一用户可以重复抢购问题。

解决库存超卖问题

添加相关枚举

在redis键的枚举类中添加如下枚举:

分布式锁的key来约束同一用户只能抢购一次。

添加RedisTemplate配置类

/**
 * RedisTemplate配置类
 * @author zjq
 */
@Configuration
public class RedisTemplateConfiguration 

    /**
     * redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类
     *
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) 
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 使用Jackson2JsonRedisSerialize 替换默认序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        // 设置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;
    


改造原先添加代金券逻辑

原先添加代金券的逻辑如下:

现在需要把跟数据库交互的部分改成和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中:


可以看到数据已经存储到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:

    	// 扣库存 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个用户并发请求,查看库存情况,目前还是超卖的:

订单的数量是正确的:

因为这一步判定是单线程的

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

是不是先下单然后再扣库存就可以了?当然不行,如果上面位置调整下会导致库存数量不对,订单数量也不对😰😰😰。
我们继续在先下单后扣库存的方法上添加一个事务:@Transactional(rollbackFor = Exception.class)。
执行发现订单数量正常了,库存还是负数:

为什么会有这个问题呢,因为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, "该券已经卖完了");


以上是关于Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购的主要内容,如果未能解决你的问题,请参考以下文章

Redis(二十三)-秒杀案例之超卖和超时问题解决

php使用redis怎么解决秒杀中的超卖问题

java 用redis如何处理电商平台,秒杀抢购超卖

java 用redis如何处理电商平台,秒杀抢购超卖

秒杀商品超卖事故:Redis分布式锁请慎用!

秒杀微服务实现抢购代金券功能