并发秒杀系统中超卖问题与重复下单问题的解决思路

  • Post author:
  • Post category:其他




什么是超卖问题

问题原始描述:两用户查询某商品库存都是1,导致卖出2个商品,产生了超卖问题。

超卖导致的原因:

不同用户检查库存够用,然后并发下订单,减库存,由于检查库存和减少库存这两个操作不保证原子性,所以可能会出现本线程检查库存够用到实际减少库存操作之间,其他线程抢先扣除库存导致本线程扣除库存后库存出现负数,引发超卖。



秒杀下单流程

  1. 判断用户是否登陆,是否有收货地址等
  2. 判断库存是否够用
  3. 判断是否已经秒杀到了,防止重复下单
  4. 减库存
  5. 创建订单



流程中可能会出现的问题

  1. 超卖问题,由于步骤2与步骤4并不是原子操作,并发访问下会导致超卖问题
  2. 重复下单问题,秒杀应该要求用户只能下一次单,即秒杀操作应该具有幂等性

幂等性:指的是一个操作应该满足f(x) = f(f(x))的特性,即为调用一次与调用多次效果一样。



解决方案



超卖问题解决:

解决超卖问题可以使用redis进行预扣库存操作,由于redis的increment操作具有原子性,即扣除库存与数量查询是原子操作,解决了 判断库存与减库存 两个操作不具有原子性的问题(超卖问题的诞生就是因为这两个操作不具有原子性)。

/**
     * redis原子操作预检库存操作
     * 操作的前提是库存表已经存入redis中,(可在添加秒杀商品接口中执行)
     * 此操作执行了校验库存及扣除库存的操作
     * @return 预扣库存是否成功
     */
    private boolean stockTicket(String goodId){
        //库存数量键
        String key = "sec:product:stock:"+goodId;

        //校验是否存在此商品
        Object value = redisTemplate.opsForValue().get(key);
        if (null==value){
            //商品不存在
            log.info("商品不存在");
            return false;
        }
        //检查库存
        Integer stock = (Integer) value;
        if (stock<=0){
            //库存不足
            log.info("预检时库存不足");
            return  false;
        }
        //此处库存充足
        //但是不能直接进行减库存操作 ,高并发访问情况下,此时可能有其他线程已经将redis的key修改了

        //redis 预检库存操作
        Long increment = redisTemplate.opsForValue().increment(key, -1);
        //这里的 increment是原子操作的产物,所以一定是线程安全的
        if (increment>=0){
            //秒杀成功
            //todo: 这里再执行数据同步,可以使用mq的操作异步同步数据

            log.info("秒杀成功");
            //这里暂时直接插入
            //下面这个操作错误的原因是执行此操作并不是按顺序执行,有可能最后一次修改并不是increment=0的那条线程,
            //有可能是increment=任何数的一条线程去做的最后一次修改,导致数据库中最后数据不为0
//            seckillGoodsService.update(
//                    new UpdateWrapper<SeckillGoods>().eq("goods_id",goodId).set("stock_count",increment));


            //正确操作还是应该是库中自减
            seckillGoodsService.update(
                   new UpdateWrapper<SeckillGoods>().eq("goods_id",goodId).
                           setSql("stock_count=stock_count-1"));  //这里的自减是原子操作,线程安全
            return true;
        }else {
            //秒杀失败,在此前第一次查看库存与第二次原子减库存之间有线程修改库存导致库存不足了
            //为了保证数据的线程安全性,需要回退数据,将之前减去的redis中库存回退
            redisTemplate.opsForValue().increment(key,1);
            return false;
        }
    }

流程:

  1. 首先校验redis中是否有此商品
  2. 先校验一遍库存,这里是为了若无库存直接返回,出于性能考虑(类比于双重检查加锁中的第一次判空检查)
  3. 若库存充足调用increment减库存并获取到减少后的值
  4. 再将获取到的值进行校验,若小于0则证明3,4步骤之间有其他线程抢先一步扣除了库存,需要回退库存
  5. 若校验通过(库存>=0)则证明秒杀成功
  6. 秒杀成功后在合适时间再对mysql进行数据同步



解决重复下单问题:

重复下单问题的出现可能有两种情况引起

  1. 一个用户秒杀到一件商品后(所有流程执行完毕,mysql中已经有了他的订单),他又进行秒杀
  2. 一个用户快速发起多次请求(两个请求的代码都执行通过了订单重复校验),这时这两条并行的代码就都能执行到下单,这两条代码就会在数据库中生成两份订单,而这是不允许的。



解决第一种情况:

进行订单的预检:

出于性能考虑,若将订单预检在数据库中进行会有数据库大量的并发查找压力

所以将订单的预检功能上移到redis。

/**
     * 使用redis预存订单,请求刚进入时就查看预存订单表,若表中有数据则证明重复下单
     *
     * @param userId 用户id
     * @param goodsId 商品id
     * @param Time 过期时间 ms(一般为秒杀结束后过期)
     * @return
     */

    private boolean PreOrder(Integer userId,Integer goodsId,Long Time){

        String key = "sec:product:order:"+userId+":"+goodsId;
        redisTemplate.opsForValue().set(key,1,Time, TimeUnit.SECONDS);
		//todo: 这里再执行真正的入单操作,可以使用mq进行异步入单
        return true;



    }

  
  /**
     * 校验订单
     * 校验redis中是否预存了订单信息,若有则表示重复下单
     * @param userId 用户id
     * @param goodsId 商品id
     * @return 是否重复下单,true表示未重复下单
     */
    @Override
    public boolean checkOrder(Integer userId,Integer goodsId){
        String key = "sec:product:order:"+userId+":"+goodsId;
        Object o = redisTemplate.opsForValue().get(key);
        //为空则证明第一次下单
        Assert.isTrue(null==o,ResponseEnum.REPEAT_ORDER);
        return true;
    }

流程

  1. 秒杀成功后将用户id与商品id入redis作为预下订单,设置过期时间为秒杀结束时间
  2. 在合适时间再异步将订单真正写入数据库
  3. 在请求刚入系统就进行订单校验,校验redis中是否有此用户商品对应的订单





解决第二种情况:

同样使用redis可以拦截快速二次请求

    /**
     * 使用redis拦截同一用户快速二次请求
     * @param userId 请求用户
     * @return 是否放行
     */
    private boolean healthRequest(Integer userId){
        String key = "sec:req:"+userId;
        long count = redisTemplate.opsForValue().increment(key, 1);
        if (count == 1) {
        //设置有效期2秒
            redisTemplate.expire(key, 2, TimeUnit.SECONDS);
        }
        if (count > 1) {
           //重复提交订单
            //抛出异常
            Assert.isTrue(false,ResponseEnum.REPEAT_req);
        }
        return true;
    }

原理:

在redis中设置一个快速过期的以用户id作为key的请求次数缓存,用户请求进来后,若缓存中有数据则证明在过期时间内用户二次请求了系统,这时可以将其拦截。



整体执行流程:

@ApiOperation(value = "秒杀接口" )
    @PostMapping("/doSeckill")
    public R doSeckill(HttpServletRequest request,
                        @ApiParam(value = "商品vo", required = true)
                        @RequestBody SeckillGoodDetailVO goods) {

        //解析用户id
        String token = request.getHeader("token");  // 获取令牌(前端将token放入请求头中)
        Integer userId = JwtUtils.getUserId(token);     //解析出token中的用户id


        //校验是否重复下单
        seckillOrderService.checkOrder(userId,goods.getId());

        //执行操作
        Order order = seckillOrderService.doSeckill2(userId, goods);

        return R.ok().message("下单成功").data("order",order);
    }
@Override
    public Order doSeckill2(Integer userId, SeckillGoodDetailVO goods) {

        //校验及扣库存
        boolean b = stockTicket(goods.getId().toString());
        //断言秒杀成功
        Assert.isTrue(b, ResponseEnum.FAIL_SECKILL);


        //计算预入单过期时间
        Long second =  goods.getEndDate().toEpochSecond(ZoneOffset.of("+8"))-
                LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));

        //redis预入订单
        PreOrder(userId,goods.getId(),second);


        //真正订单入库操作,可以使用mq执行
        Order order = order(userId, goods);

        return order;
    }


完整代码



版权声明:本文为qq_45748351原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。