高并发秒杀优化操作

1. 减少数据库的操作

  1. 判断是否重复抢购这个操作可以优化,大致思路是把用户订单放到Redis里,键中加上用户,抢购时判断是否已存在信息。来代替查询数据库

    1. 具体操作

      1
      2
      3
      //生成订单时
      redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goods.getId(),
      JsonUtil.object2JsonStr(seckillOrder));
      1
      2
      3
      4
      5
      6
      7
      //秒杀操作前
      SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" +user.getId()
      + ":" + goodsId);
      //如果取出的信息不为空代表已经购买过此产品,不能再参与(限购为1情况下)
      if(seckillOrder != null){
      return RespBean.error(RespBeanEnum.REPEATE_ERROR);
      }
  2. 库存可以在初始化时候就加入到Redis里,秒杀的时候直接减去Redis里的库存,然后再使用RabbitMQ消息队列分别进行处理

    1. 具体操作

      • 配置初始化库存
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      //接口调用implements InitializingBean
      @RestController
      @RequestMapping("/seckill")
      public class SecKillController implements InitializingBean{
      //实现这个接口的方法
      //系统初始化,把商品库存数量加载到Redis
      @Override
      public void afterPropertiesSet() throws Exception{
      //从数据库找出有秒杀商品
      List<GoodsVo> list = goodsService.findGoodsVo();
      if(CollectionUtils.isEmpty(list)){
      return;
      }
      //把每个秒杀产品的库存信息加载到Redis里
      list.forEach(goodsVo ->
      redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),
      goodsVo.getStockCount()));
      }

      }
      • 秒杀接口里配置库存减少操作
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @RequestMapping("/doSeckill",method = RequestMethod.POST)
      public RespBean doSeckill(User user,Long goodsId){
      //判断用户是否存在
      //一堆代码判断是否重复抢购
      //判断是否重复抢购后
      ValueOperations valueOperations = redisTemplate.opsForValue();
      //预减库存,每次执行自动-1
      Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
      //如果库存减了之后小于零就执行这个情况,个人感觉这里可能会有问题
      if (stock < 0){
      //每次执行自动加一,防止库存出现-1的情况
      valueOperations.increment("seckillGoods:" + goodsId);
      return RespBean.error(RespBeanEnum.EMPTY_STOCK);
      }
      //生成订单,以后会替换成SeckillMessage
      Order order = orderService.seckill(user,goods);
      return RespBean.success(Order);
      }
  3. 把秒杀请求封装成一个对象发送给RabbitMQ。把操作异步掉,用消息队列达到流量削锋的目的。

    1. 具体操作

      • 创建封装对象SeckillMessage

        1
        2
        3
        4
        5
        public class SeckillMessage{
        private User user;
        private Long goodId;
        //getter、setter、构造器省略
        }
      • 选择使用RabbitMQ的Topic模式,进行config配置

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        @Configuration
        public class RabbitMQTopicConfig{
        private static final String QUEUE = "seckillQueue";
        private static final String EXCHANGE = "seckillExchange";

        @Bean
        public Queue queue(){
        return new Queue(QUEUE);
        }

        @Bean
        public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
        }

        @Bean
        public Binding binding(){
        return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");
        }
        }
      • MQSender配置

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        @Service
        //lombok的注解
        @Slf4j
        public class MQSender {
        @Autowired
        private RabbitTemplate rabbitTemplate;

        public void sendSeckillMessage(String message){
        log.info("发送消息:" + message);
        rabbitTemplate.converAndSend("seckillExchange", "seckill.message", message);
        }
        }

      • 配置完后回到Controller接口修改,加入MQSender

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        @Autowired
        private MQSender mqSender;

        @RequestMapping("/doSeckill",method = RequestMethod.POST)
        public RespBean doSeckill(User user,Long goodsId){
        //判断用户是否存在
        //一堆代码判断是否重复抢购
        //判断是否重复抢购后
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //预减库存,每次执行自动-1
        Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
        //如果库存减了之后小于零就执行这个情况,个人感觉这里可能会有问题
        if (stock < 0){
        //每次执行自动加一,防止库存出现-1的情况
        valueOperations.increment("seckillGoods:" + goodsId);
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        //替换处
        SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
        //使用RabbitMQ实现异步,把订单生成放入另一部分操作,这样能快速返回信息。这里是JsonUtil是自己编写的工具类,在下文
        mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
        //前端收到0,要作出"排队中"的响应
        return RespBean.success(0);
        }
      • MQReceiver,接受sender消息,异步掉操作

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        @Service
        //lombok的注解
        @Slf4j
        public class MQReceiver {
        @Autowired
        private RabbitTemplate rabbitTemplate;

        @Autowired
        private IGoodsService goodsService;

        @Autowired
        private RedisTemplate redisTemplate;

        @Autowired
        private OrderService orderService;

        @RabbitListener(queues = "seckillQueue")
        public void receive(String message){
        //先打印下看看能不能拿到正确的message
        log.info("接受到的消息:" + message);
        //转换消息从JSON到对象
        SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class);
        //获取goodId和用户
        User user = seckillMessage.getUser();
        Long goodsId = seckillMessage.getGoodId();
        //获取商品信息
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        //判断库存
        if(goodsVo.getStockCount() < 1){
        return;
        }
        //判断是否重复抢购
        SeckillOrder seckillOrder =
        (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":"
        + goodsId);
        if(seckillOrder != null){
        return;
        }
        //下单操作
        orderService.seckill(user,goodsVo);
        }
        }
  4. 内存标记,减少库存卖光之后对redis的访问

    1. 具体操作

      • 在Controller里设置个内存标记

        1
        2
        //Long对应不同的商品id
        private Map<Long,Boolean> EmptyStockMap = new HashMap<>();
      • 之前的初始化方法重写,加入内存标记初始化

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        @Override
        public void afterPropertiesSet() throws Exception{
        //从数据库找出有秒杀商品
        List<GoodsVo> list = goodsService.findGoodsVo();
        if(CollectionUtils.isEmpty(list)){
        return;
        }
        //把每个秒杀产品的库存信息加载到Redis里
        list.forEach(goodsVo -> {
        redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),
        goodsVo.getStockCount()));
        EmptyStockMap.put(goodsVo.getId(),false);
        }

        }
      • 接口里具体实现内存标记判断

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        @Autowired
        private MQSender mqSender;

        @RequestMapping("/doSeckill",method = RequestMethod.POST)
        public RespBean doSeckill(User user,Long goodsId){
        //判断用户是否存在
        //一堆代码判断是否重复抢购
        //判断是否重复抢购后
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //预减库存,每次执行自动-1
        Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
        //内存标记判断,减少Redis访问
        if(EmptyStockMap.get(goodsId)){
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        //如果库存减了之后小于零就执行这个情况,个人感觉这里可能会有问题
        if (stock < 0){
        //修改处,标记内存
        EmptyStockMap.put(goodsId,true);
        //每次执行自动加一,防止库存出现-1的情况
        valueOperations.increment("seckillGoods:" + goodsId);
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
        //这里是JsonUtil是自己编写的工具类,在下文
        mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
        //前端收到0,要作出"排队中"的响应
        return RespBean.success(0);
        }
  5. 轮询判断是否秒杀成功,因为之前只返回了一个排队中

    1. 具体操作

      • 在秒杀的Controller里再新建一个接口,专门获取秒杀结果,返回一个orderId代表成功,返回-1代表秒杀失败,返回0代表排队中

      • Controller

        1
        2
        3
        4
        5
        6
        7
        8
        @RequestMapping("/result",method = RequestMethod.POST)
        public RespBean getResult(User user, Long goodsId){
        if(user == null){
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
        }
        Long orderId = seckillOrderService.getResult(user,goodsId);
        return RespBean.success(orderId);
        }
      • Service层创建接口并实现(相关自动加载注解就不写了)

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        @Override
        public Long getResult(User user, Long goodsId){
        SeckillOrder seckillOrder = seckillOrderMapper.selectOne(new QueryWrapper<SeckillOrder)().eq("user_id", user.getId()).eq("goods_id",goodsId);
        //如果订单表里有这个用户下了这个产品的订单,就说明秒杀成功,返回订单编号。
        if(seckillOrder != null){
        return seckillOrder.getOrderId();
        }else if(redisTemplate.hasKey("isStockEmpty:" + goodsId)){
        //如果Redis里这个产品的isStockEmpty标记为是,就返回库存为空的标记
        return -1L;
        }else{
        //如果没有这个订单,就代表秒杀失败
        return 0L;
        }
        }
      • 在生成订单的时候要加上判断库存为空

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        @Transactional
        @Override
        public Order seckill(User user,GoodsVo goods){
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //秒杀商品表减库存操作...

        //判断是否还有库存
        if(seckillGoods.getStockCount() < 1){
        valueOperations.set("isStockEmpty:" + goods.getId(), "0");
        return null;
        }
        }
      • 接下来就是前端写轮询发送请求判断了,若返回-1就是秒杀失败不用轮询,若返回0排队中就要写设置间隔几秒发送请求到getResult接口。

  6. 脚本优化(再说)

    • 经历以上操作,超卖问题是解决了,但是存在一个问题,单个用户多次秒杀会使得redis库存多次减少并且超出限购。还会让redis库存扣完但是数据库没有,所以需要优化。**(这个方法好像没有用,自己预想是用用户标记)**

    • lua脚本实现分布式锁,具体作用看视频

    • image-20220405232633646

    • RedisConfig调用

    • image-20220405233014417

    • 在配置一个stock.lua判断库存

    • image-20220405234320336

    • RedisConfig配置

    • image-20220405234413655

    • Controller里判断库存修改

      1
      2
      @Autowired
      private RedsiScript<Long> script;
    • image-20220405234718836

  7. JSONUtil 或者Maven添加fastJson依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    package com.bank.seckill2022.utils;

    import com.fasterxml.jackson.core.JsonParseException;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.JavaType;
    import com.fasterxml.jackson.databind.JsonMappingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import java.io.IOException;
    import java.util.List;

    /**
    * 功能描述:
    * @param: Json工具类
    * @return:
    * @author 来自于网络
    * @date: 2022/3/25 23:36
    */

    public class JSONUtil {
    private static ObjectMapper objectMapper = new ObjectMapper();
    /**
    * 将对象转换成json字符串
    *
    * @param obj
    * @return
    */
    public static String object2JsonStr(Object obj) {
    try {
    return objectMapper.writeValueAsString(obj);
    } catch (JsonProcessingException e) {
    //打印异常信息
    e.printStackTrace();
    }
    return null;
    }
    /**
    * 将字符串转换为对象
    *
    * @param <T> 泛型
    */
    public static <T> T jsonStr2Object(String jsonStr, Class<T> clazz) {
    try {
    return objectMapper.readValue(jsonStr.getBytes("UTF-8"), clazz);
    } catch (JsonParseException e) {
    e.printStackTrace();
    } catch (JsonMappingException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    return null;
    }
    /**
    * 将json数据转换成pojo对象list
    * <p>Title: jsonToList</p>
    * <p>Description: </p>
    *
    * @param jsonStr
    * @param beanType
    * @return
    */
    public static <T> List<T> jsonToList(String jsonStr, Class<T> beanType) {
    JavaType javaType =
    objectMapper.getTypeFactory().constructParametricType(List.class, beanType);
    try {
    List<T> list = objectMapper.readValue(jsonStr, javaType);
    return list;
    } catch (Exception e) {
    e.printStackTrace();
    }
    return null;
    }
    }