暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

秒杀的消峰和限流(上)

Alleria Windrunner 2021-10-17
1342
前面两篇我们介绍了秒杀的隔离策略和流量控制,其目的是降低流量的相互耦合和量级,减少对系统的冲击。这节课我们将继续从技术角度来讨论秒杀系统的其他高可用手段——削峰和限流,通过削峰,让系统更加稳健。
削峰填谷概念一开始出现在电力行业,是调整用电负荷的一种措施,在互联网分布式高可用架构的演进过程中,也经常会采用类似的削峰填谷手段来构建稳定的系统。
削峰的方法有很多,可以通过业务手段来削峰,比如秒杀流程中设置验证码或者问答题环节;也可以通过技术手段削峰,比如采用消息队列异步化用户请求,或者采用限流漏斗对流量进行层层过滤。削峰又分为无损和有损削峰。本质上,限流是一种有损技术削峰;而引入验证码、问答题以及异步化消息队列可以归为无损削峰。
本篇我们先来看看秒杀的消峰。


流量削峰

前面的文章中,我介绍过秒杀的业务特点是库存少,最终能够抢到商品的人数取决于库存数量,而参与秒杀的人越多,并发数就越高,随之无效请求也就越多。但从业务方的角度来说,肯定是希望有更多的人参与进来,点击“立即秒杀”按钮体验秒杀的乐趣。
一般来说,我们支撑秒杀系统的硬件资源是有限的,它的处理能力是恒定的,当有秒杀活动的时候,很容易繁忙导致请求处理不过来,而没有活动的时候,机器又是低负载运转。但是为了保证用户的秒杀体验,一般情况下我们的处理资源只能按照忙的时候来预估,这会导致资源的一个浪费。这就好比交通存在早高峰和晚高峰的问题,所以有了外牌限行、尾号限行等多种错峰解决方案。
因此我们需要设计一些规则,延缓并发请求,甚至过滤掉无效的请求,让真正可以下单的请求越少越好。总结来说,削峰的本质,一是让服务端处理变得更加平稳,二是节省服务器的机器成本。
接下来,我们就重点介绍几个常用的削峰手段:验证码、问答题、消息队列、分层过滤和限流。我们还可以借鉴互联网大厂里都会采用什么样的手段,以及背后的思考逻辑。


验证码和问答题

在秒杀交易流程中,引入验证码和问答题,有两个目的:一是快速拦截掉部分刷子流量,防止机器作弊,起到防刷的作用;二是平滑秒杀的毛刺请求,延缓并发,对流量进行削峰。
让用户在秒杀前输入验证码或者做问答题,不同用户的手速有快有慢,这就起到了让 1s 的瞬时流量平均到 30s 甚至 1 分钟的平滑流量中,这样就不需要堆积过多的机器应对 1s 的瞬时流量了。
以下是流程图,我来解释一下。

设计验证码流程,一般是在用户进入详情页时,先判别秒杀活动是否已经开始,如果已经开始,同时秒杀活动也配置了需要校验验证码标识,那么就需要从秒杀系统获取图片验证码,并进行渲染;用户手工输入验证码后,提交给秒杀系统进行验证码校验,如果通过就跳转至秒杀结算页。
上图增加的红线部分就是引入了验证码的秒杀流程。当然,我这里介绍的,是把验证码功能作为秒杀系统的一个模块了,而大公司一般都会有单独的验证码服务,我们不用自己造轮子,只要进行系统对接就行了。
下面我简单介绍一下验证码的实现,通过上图得知,验证码服务需提供两个基本的功能:生成验证码和校验验证码。
生成验证码,先看接口设计如下:
    POST seckill/captchas.jpg?skuId=10001
    对应的后端代码实现:
      /**
      * 生成图片验证码
      */
      @RequestMapping(value="/seckill/captchas.jpg", method=RequestMethod.POST})
      @ResponseBody
      public SeckillResponse<String> genCaptchas(String skuId, HttpServletRequest request, HttpServletResponse response) {
      //从cookie中取出user
      String user = getUserFromCookie(request);
      //根据skuId和user生成图片
      BufferedImage img=createCaptchas(user, skuId);
      try {
      OutputStream out=response.getOutputStream();
      ImageIO.write(img, "JPEG", out);
      out.flush();
      out.close();
      return null;
      } catch (IOException e) {
      e.printStackTrace();
      return SeckillResponse.error(ErrorMsg.SECKILL_FAIL);
      }
      }


      /**
      * 生成验证码图片方法
      */
      public BufferedImage createCaptchas(String user, String skuId) {
      int width=90;
      int height=40;
      BufferedImage img=new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB);
      Graphics graph=img.getGraphics();
      graph.setColor(new Color(0xDCDCDC));
      graph.fillRect(0, 0, width, height);
      Random random=new Random();
      //生成验证码
      String formula=createFormula(random);
      graph.setColor(new Color(0,100,0));
      graph.setFont(new Font("Candara",Font.BOLD,24));
      //将验证码写在图片上
      graph.drawString(formula, 8, 24);
      graph.dispose();
      //计算验证码的值
      int vCode=calc(formula);
      //将计算结果保存到redis上面去,过期时间1分钟
      cacheMgr.set("CAPTCHA_"+user+"_"+skuId, vCode, 60000);
      return img;
      }


      private String createFormula(Random random) {
      private static char[]ops=new char[] {'+','-','*'};
      //生成10以内的随机数
      int num1=random.nextInt(10);
      int num2=random.nextInt(10);
      int num3=random.nextInt(10);
      char oper1=ops[random.nextInt(3)];
      char oper2=ops[random.nextInt(3)];
      String exp=""+num1+oper1+num2+oper2+num3;
      return exp;
      }


      private static int calc(String formula) {
      try {
      ScriptEngineManager manager=new ScriptEngineManager();
      ScriptEngine engine=manager.getEngineByName("JavaScript");
      return (Integer) engine.eval(formula);
      }catch(Exception e){
      e.printStackTrace();
      return 0;
      }
      }
      以上是自己生成图片验证码的方式,方便起见,你也可以用 Google 提供的 Kaptcha 包生成图片验证码。
      同时,为了让交互更加安全,避免被篡改,我们还可以加入签名机制,后端在返回给前端图片验证码的时候,同时返回一个签名,前端在点击“抢购”按钮的时候,把用户输入的验证码以及签名提交给后端服务进行验证。这个签名可以设计如下:

        signature=base64(timestamp,md5(timestamp,vCode,skuId,user,randomSalt)
        这里 timestamp 取生成验证码 vCode 时的时间戳,randomSalt 可以理解为后端的一个私钥。那么在前面代码的第 44 行,我们存入 Redis 的值就要换成这个 signature 了。
        当前端点击“抢购”按钮时,调用后端服务如下:
          POST /seckill/settlement.html?skuId=10001&signature=ad6543audhhw13dg&timestamp=1345611143&newCode=54
          接下来我们看校验验证码。校验的逻辑比较简单,从前端的 HTTP 请求里,取得 skuId、user、signature、timestamp 和 newCode,首先验证 timestamp 是否已经过期,然后根据用户输入的验证码内容 newCode 重新计算签名 newSignature,并和 Redis 里的 signature 进行比对,比对一致表示验证码校验通过。然后我们需要删掉 Redis 的内容,避免被重复验证,这样的话一个验证码就只会被验证一次了。


          消息队

          除了验证码和问答题,另一种削峰方式是异步消息队列。
          当服务 A 依赖服务 B 时,正常情况下服务 A 会直接通过 RPC 调用服务 B 的接口,当服务 A 调用的流量可控,且服务 B 的 TP99 和 QPS 能满足调用时,这是最简单直接的调用方式,没什么问题,目前大部分的微服务间调用也都是这样做的。
          但是,试想一下,如果服务 A 的流量非常高(假设 10 万 QPS),远远大于服务 B 所能支持的能力(假设 1 万 QPS),那么服务 B 的 CPU 很快就会升高,TP99 也随之变高,最终服务 B 被服务 A 的流量冲垮。
          这个时候,消息队列就派上用场了,我们把一步调用的直接紧耦合方式,通过消息队列改造成两步异步调用,让超过服务 B 范围的流量,暂存在消息队列里,由 B 根据自己的服务能力来决定处理快慢,这就是通过消息队列进行调用解耦的常见手段。

          常见的开源消息队列有 Kafka、RocketMQ 和 RabbitMQ 等,大厂的基础中间件部门一般也会根据自己公司的业务特点,自研适合自己的 MQ 系统。对一般的场景来说,我推荐你用 RocketMQ,应该能解决你大部分的问题。

          以上红色和灰色的部分,就是通过消息队列解耦后,详情页系统和秒杀系统各自处理的部分。因为解耦了,所以在第 6 步下单之后,其实是不知道秒杀结果的,因此在第 11 步,需要前端定期去查询秒杀结果反馈给用户。而在秒杀系统拉取消息队列进行处理的时候,也有个小技巧,那就是当前面的请求已经把库存消耗光之后,在缓存里设置占位符,让后续的请求快速失败,从而最快地进行响应。
          文章转载自Alleria Windrunner,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

          评论