高并发秒杀优化操作

178 阅读2分钟

1. 减少数据库的操作

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

    1. 具体操作

      //生成订单时
      redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goods.getId(),
                                     JsonUtil.object2JsonStr(seckillOrder));
      
      //秒杀操作前
      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. 具体操作

      • 配置初始化库存
      //接口调用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()));
          }
          
      }
      
      • 秒杀接口里配置库存减少操作
      @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

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

        @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配置

        @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

        @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消息,异步掉操作

        @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里设置个内存标记

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

        @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);
                }
                            
            }
        
      • 接口里具体实现内存标记判断

        @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

        @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层创建接口并实现(相关自动加载注解就不写了)

        @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;
            }
        }
        
      • 在生成订单的时候要加上判断库存为空

        @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.png

    • RedisConfig调用

    • image-20220405233014417.png

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

    • image-20220405234320336.png

    • RedisConfig配置

    • image-20220405234413655.png

    • Controller里判断库存修改

      @Autowired
      private RedsiScript<Long> script;
      
    • image-20220405234718836.png

  7. JSONUtil 或者Maven添加fastJson依赖

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