美文网首页
秒杀系统的设计

秒杀系统的设计

作者: 倚仗听江 | 来源:发表于2020-08-27 07:15 被阅读0次

    秒杀这个业务场景是我们生活中比较常见的,这次就来总结一下秒杀系统设计的要点

    使用Redis进行不同级别的缓存

    因为秒杀场景的特殊性,所以必然会有很高的并发量。如果我们让这些大量的请求直接打到我们的数据库上,那我们的数据库是必然撑不住的,这就会对我们的秒杀活动造成很大的影响。所以我们就可以使用Redis把一部分的请求拦在数据库外,实在不行了再去访问数据库。
    我们可以使用不同层级和粒度的缓存对系统做优化改造。比如:对服务端手动渲染商品列表做页面缓存,对商品详情静态化来利用客户端浏览器的缓存,对热点数据做对象级的缓存。

    • 页面缓存:在将从缓存中取出的html代码用thymeleafViewResolver视图解析器手动渲染成html页面,返回给客户端。
    @RequestMapping(value="/to_list", produces="text/html")
    @ResponseBody
    public String list(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user) {
        model.addAttribute("user", user);
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        model.addAttribute("goodsList", goodsList);
        WebContext ctx = new WebContext(request,response,
                servletContext,request.getLocale(), model.asMap() );
        //手动渲染
        String html thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
        if(!StringUtils.isEmpty(html)) {
            redisService.set(GoodsKey.getGoodsList, "", html);
        }
        return html;
    }
    
    

    注意:只有那些很少改变或者是对实时性要求不高的那些页面才应该使用页面缓存

    • 使用内存标记,减少redis的访问(在系统初始化的时候直接将剩余商品的件数保存在redis中,并使用一个HashMap来保存商品是否已经秒杀完毕。在用户要进行秒杀之前,先访问这个HashMap,来确定商品是否秒杀完毕,以此来减少对redis的访问)
      这里实现了InitializingBean接口,通过调用afterPropertiesSet方法来实现系统的初始化。
    private HashMap<Long, Boolean> localOverMap =  new HashMap<Long, Boolean>();
    
        /**
         * 系统初始化
         * */
        public void afterPropertiesSet() throws Exception {
            List<GoodsVo> goodsList = goodsService.listGoodsVo();
            if(goodsList == null) {
                return;
            }
            for(GoodsVo goods : goodsList) {
                redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
                localOverMap.put(goods.getId(), false);
            }
        }
    

    如果内存标记都已经返回true,那么就代表已经没有库存了,也就没有访问redis的必要了。

    //内存标记,减少redis访问
    boolean over = localOverMap.get(goodsId);
     if(over) {
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
    
    • 使用redis预减库存,若库存已经为0,则可直接访问错误信息,避免了后续业务逻辑的执行,并将HashMap中对应的商品的value值置为false。
    //预减库存
    Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);
    if(stock < 0) {
        localOverMap.put(goodsId, true);
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
    
    • 秒杀系统写少读多,如果单机的redis顶不住的话,那么就可以做redis的集群、主从同步、读写分离。


      redis集群.png

      由于我们大量的使用了redis,所以我们也需要注意redis的缓存穿透、缓存击穿、缓存雪崩问题。

    超卖问题

    只要是一个秒杀系统,就必然会存在超卖问题。不同用户在读请求的时候,发现商品库存足够,然后同时发起请求,进行秒杀操作,减库存,导致库存减为负数。

    关于这个问题的解决方案有很多,比如我们可以使用悲观锁来解决这个问题。当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作。(关于mysql中锁的内容可以看看我的这篇文章 https://www.jianshu.com/p/6c4e1e8998d3)。

    //悲观锁语法
    SELECT * FROM test WHERE id = 1 FOR UPDATE
    

    但是这样的话每个线程都会去请求这一把锁,等它更新完库存了再释放锁。这样的效率就太低了,一般用的不多。
    我们还可以使用乐观锁来解决这个问题
    如果要使用乐观锁,那么我们就要给个商品库存一个版本号version字段,在每次我们读取库存的时候把版本号也读取出来,当这一个线程去执行扣减库存的操作的时候,去判断数据库当前的版本号是否是刚刚读取出来的版本号,如果不是则秒杀失败
    Dao层:

    /**
         * 查询商品库存
         * @param id 商品id
         * @return
         */
        @Select("SELECT * FROM goods WHERE id = #{id}")
        Goods getStock(@Param("id") int id);
    
    /**
         * 乐观锁方案扣减库存
         * @param id 商品id
         * @param version 版本号
         * @return
         */
        @Update("UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock > 0 AND version = #{version}")
        int decreaseStockForVersion(@Param("id") int id, @Param("version") int version);
    

    Service层:

    /**
         * 扣减库存
         * @param gid 商品id
         * @param uid 用户id
         * @return SUCCESS 1 FAILURE 0
         */
        @Transactional
        public int sellGoods(int gid, int uid) {
    
            // 获取库存
            Goods goods = goodsDao.getStock(gid);
            if (goods.getStock() > 0) {
                // 乐观锁更新库存
                int update = goodsDao.decreaseStockForVersion(gid, goods.getVersion());
                // 更新失败,说明其他线程已经修改过数据,本次扣减库存失败,可以重试一定次数或者返回
                if (update == 0) {
                    return 0;
                }
                // 库存扣减成功,生成订单
                Order order = new Order();
                order.setUid(uid);
                order.setGid(gid);
                int result = orderDao.insertOrder(order);
                return result;
            }
            // 失败返回
            return 0;
        }
    

    我觉得这有点像CAS的思想,通过版本号来判断当前数据是否被别人更改过。

    秒杀地址隐藏

    假设我们把我们的秒杀地址直接暴露在外面,那么有些人就可以通过浏览器的开发者模式来获取到你真实的秒杀地址。对于这个问题我们可以先加一个时间的校验。

     GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
            long startAt = goods.getStartDate().getTime();
            long endAt = goods.getEndDate().getTime();
            long now = System.currentTimeMillis();
            int miaoshaStatus = 0;
            int remainSeconds = 0;
            if(now < startAt ) {//秒杀还没开始,倒计时
                miaoshaStatus = 0;
                remainSeconds = (int)((startAt - now )/1000);
            }else  if(now > endAt){//秒杀已经结束
                miaoshaStatus = 2;
                remainSeconds = -1;
            }else {//秒杀进行中
                miaoshaStatus = 1;
                remainSeconds = 0;
            }
    

    但仅仅是这样的话还是不够的,如果我们的秒杀的真实地址被人知道了,就可以写一个脚本不断的获取北京时间,在秒杀开始的毫秒级别时就可以请求。而且机器能在短时间内发送大量的请求,绝对会对我们的秒杀活动造成巨大的影响。
    那怎么办呢?
    我们可以把我们的秒杀地址动态化,也就是通过MD5之类的加密算法去处理我们的秒杀地址,存入 redis缓存中,根据前端请求的url获取path。 判断与缓存中的字符串是否一致,一致就认为请求是正常的。这就是秒杀链接加盐,用这样的方法来阻止恶意用户直接请求我们的秒杀地址。

    @RequestMapping(value="/path", method=RequestMethod.GET)
    @ResponseBody
    public Result<String> getMiaoshaPath( MiaoshaUser user,
                                             @RequestParam("goodsId")long goodsId,
                                             @RequestParam(value="verifyCode", defaultValue="0")int verifyCode) {
            if(user == null) {
                return Result.error(CodeMsg.SESSION_ERROR);
            }
            boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
            if(!check) {
                return Result.error(CodeMsg.REQUEST_ILLEGAL);
            }
            String path  =miaoshaService.createMiaoshaPath(user, goodsId);
            return Result.success(path);
        }
    
    
    
    public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
            if(user == null || goodsId <=0) {
                return null;
            }
            String str = MD5Util.md5(UUIDUtil.uuid()+"123456");
            redisService.set(MiaoshaKey.getMiaoshaPath, ""+user.getId() + "_"+ goodsId, str);
            return str;
        }
    

    接口限流防刷

    关于这个问题我们可以通过一个自定义注解来完成。

    @Retention(RUNTIME)
    @Target(METHOD)
    public @interface AccessLimit {
        int seconds();
        int maxCount();
        boolean needLogin() default true;
    }
    

    若是我们的方法需要限流防刷,那我们就可以给那个方法打上那个注解。然后在拦截器(HandlerInterceptorAdapter)里进行判断,看方法是否使用了AccessLimit注解修饰

    @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
                throws Exception {
            if(handler instanceof HandlerMethod) {
                MiaoshaUser user = getUser(request, response);
                UserContext.setUser(user);
                HandlerMethod hm = (HandlerMethod)handler;
                AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
                if(accessLimit == null) {
                    return true;
                }
                int seconds = accessLimit.seconds();
                int maxCount = accessLimit.maxCount();
                boolean needLogin = accessLimit.needLogin();
                String key = request.getRequestURI();
                if(needLogin) {
                    if(user == null) {
                        render(response, CodeMsg.SESSION_ERROR);
                        return false;
                    }
                    key += "_" + user.getId();
                }else {
                    //do nothing
                }
                AccessKey ak = AccessKey.withExpire(seconds);
                Integer count = redisService.get(ak, key, Integer.class);
                if(count  == null) {
                     redisService.set(ak, key, 1);
                }else if(count < maxCount) {
                     redisService.incr(ak, key);
                }else {
                    render(response, CodeMsg.ACCESS_LIMIT_REACHED);
                    return false;
                }
            }
    

    对于同一个用户来说,第一次请求过来后,将url和用户id拼接作为key,1作为value值设置到redis中,并设置过期时间。用户下次请求过来,采用redis的自增,当value值超过最大访问次数时,拒绝用户访问。主要的思路就是将访问次数放入缓存,key为URI+User ID。

    RabbitMQ异步下单

    即使我们使用redis做了不同层级和粒度的缓存,但可能对于数据库的压力还是太大。
    所以我们可以采用异步下单的办法。当用户的秒杀请求完成了一些前置性的判断(如重复秒杀、库存不足)。那么我们就可以将这个请求封装入队,同时给前端返回一个code (0),即代表返回排队中。前端接收到数据后,显示排队中,并根据商品id轮询请求服务器(考虑200ms轮询一次)。后端RabbitMQ监听秒杀的通道,如果有消息过来,获取到传入的信息,执行真正的秒杀之前,要判断数据库的库存,判断是否重复秒杀,然后执行秒杀事务(秒杀事务是一个原子操作:库存减1,下订单,写入秒杀订单)

    在确认完秒杀的验证性信息后,让用户的请求入队,使用Direct交换机,进行异步下单。

    //入队
    MiaoshaMessage mm = new MiaoshaMessage();
    mm.setUser(user);
    mm.setGoodsId(goodsId);
    sender.sendMiaoshaMessage(mm);
    return Result.success(0);//排队中
    

    在前端不断轮询秒杀的结果:

    function countDown() {
            var remainSeconds = $("#remainSeconds").val();
            var timeout;
            if (remainSeconds > 0) {//秒杀还没开始,倒计时
                $("#buyButton").attr("disabled", true);
                timeout = setTimeout(function () {
                    $("#countDown").text(remainSeconds - 1);
                    $("#remainSeconds").val(remainSeconds - 1);
                    countDown();
                }, 1000);
            } else if (remainSeconds == 0) {//秒杀进行中
                $("#buyButton").attr("disabled", false);
                if (timeout) {
                    clearTimeout(timeout);
                }
                $("#miaoshaTip").html("秒杀进行中");
            } else {//秒杀已经结束
                $("#buyButton").attr("disabled", true);
                $("#miaoshaTip").html("秒杀已经结束");
            }
        }
    

    在消息的接受端监听对应的消息队列,执行秒杀请求

    @RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
    public void receive(String message) {
        log.info("receive message:"+message);
        MiaoshaMessage mm  = RedisService.stringToBean(message, MiaoshaMessage.class);
        MiaoshaUser user = mm.getUser();
        long goodsId = mm.getGoodsId();
    
        GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        int stock = goods.getStockCount();
        if(stock <= 0) {
            return;
        }
        //判断是否已经秒杀到了
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            return;
        }
        //减库存 下订单 写入秒杀订单
        miaoshaService.miaosha(user, goods);
    }
    

    秒杀请求必须是一个原子性操作,因此加上@Transactional注解,减库存、下订单、写入秒杀订单在这个业务场景下是原子性的,若有一个不成功则全部回滚。

    @Transactional
    public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
        //减库存 下订单 写入秒杀订单
        boolean success = goodsService.reduceStock(goods);
        if(success) {
            return orderService.createOrder(user, goods);
        }else {
            setGoodsOver(goods.getId());
            return null;
        }
    }
    

    做完上面的这些,我们还可以对我们的秒杀系统做熔断和降级,万一我们的秒杀系统出了问题,还不至于影响其他的系统。我们还可以对Mysql需要设置索引的地方去设置索引,加快SQL语句的执行。因为我们的Tomcat只能有几百的并发量,所以我们还可以,使用Nginx高性能服务器做服务的负载均衡。如果是分布式应用,还应该考虑分布式事务的问题。


    秒杀系统架构.png

    参考:https://www.bilibili.com/read/cv7061296


    好了,以上就是我对于秒杀系统的设计的一些理解,本人才疏学浅,如有不对,还望批评指正。

    相关文章

      网友评论

          本文标题:秒杀系统的设计

          本文链接:https://www.haomeiwen.com/subject/vioxsktx.html