You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

07乾坤大挪移秒杀的削峰和限流

你好,我是志东,欢迎和我一起从零打造秒杀系统。

前面两节课我们讲了秒杀的隔离策略和流量控制,其目的是降低流量的相互耦合和量级,减少对系统的冲击。这节课我们将继续从技术角度来讨论秒杀系统的其他高可用手段——削峰和限流,通过削峰,让系统更加稳健。

削峰填谷概念一开始出现在电力行业,是调整用电负荷的一种措施,在互联网分布式高可用架构的演进过程中,也经常会采用类似的削峰填谷手段来构建稳定的系统。

削峰的方法有很多,可以通过业务手段来削峰,比如秒杀流程中设置验证码或者问答题环节;也可以通过技术手段削峰,比如采用消息队列异步化用户请求,或者采用限流漏斗对流量进行层层过滤。削峰又分为无损和有损削峰。本质上,限流是一种有损技术削峰;而引入验证码、问答题以及异步化消息队列可以归为无损削峰。

我们先来看一下电商平台线上真实场景下的秒杀流量图,因为数据保密的需要,这里我隐去了具体的流量数字。但是,你可以看到这个图有个非常明显的特点,就是毛刺特别大,流量几秒内爬升到峰值,然后马上掉下来。不管是口罩、茅台,还是春运的火车票,都符合这样的流量特点。

我们现在需要做的就是通过削峰和限流,把这超大的瞬时流量平稳地承接下来,落到秒杀系统里。这就犹如武侠小说里,众人从高塔纵身跳下,张无忌运用乾坤大挪移,把对众人伤害极大的垂直自由落体运动改变为水平运动,使之安然脱险。

流量削峰

前面的章节中,我介绍过秒杀的业务特点是库存少,最终能够抢到商品的人数取决于库存数量,而参与秒杀的人越多,并发数就越高,随之无效请求也就越多。但从业务方的角度来说,肯定是希望有更多的人参与进来,点击“立即秒杀”按钮体验秒杀的乐趣。

从上面的流量监控图可以看到,在秒杀开始的时刻,会出现巨大的瞬时流量,这个流量对资源的消耗也是巨大且瞬时的。

一般来说,我们支撑秒杀系统的硬件资源是有限的,它的处理能力是恒定的,当有秒杀活动的时候,很容易繁忙导致请求处理不过来,而没有活动的时候,机器又是低负载运转。但是为了保证用户的秒杀体验,一般情况下我们的处理资源只能按照忙的时候来预估,这会导致资源的一个浪费。这就好比交通存在早高峰和晚高峰的问题,所以有了外牌限行、尾号限行等多种错峰解决方案。

因此我们需要设计一些规则,延缓并发请求,甚至过滤掉无效的请求,让真正可以下单的请求越少越好。总结来说,削峰的本质,一是让服务端处理变得更加平稳,二是节省服务器的机器成本。

接下来,我们就重点学习几个常用的削峰手段:验证码、问答题、消息队列、分层过滤和限流。顺便看看互联网大厂里都会采用什么样的手段,以及背后的思考逻辑。

验证码和问答题

在秒杀交易流程中,引入验证码和问答题,有两个目的:一是快速拦截掉部分刷子流量,防止机器作弊,起到防刷的作用;二是平滑秒杀的毛刺请求,延缓并发,对流量进行削峰。

让用户在秒杀前输入验证码或者做问答题不同用户的手速有快有慢这就起到了让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);
        //根据skuIduser生成图片
        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应该能解决你大部分的问题。

以上是通过MQ进行调用解耦的基本思路现在我们回到秒杀的场景看看应该怎么设计呢请看下图

以上红色和蓝色的部分就是通过消息队列解耦后详情页系统和秒杀系统各自处理的部分。因为解耦了所以在第6步下单之后其实是不知道秒杀结果的因此在第11步需要前端定期去查询秒杀结果反馈给用户。而在秒杀系统拉取消息队列进行处理的时候也有个小技巧那就是当前面的请求已经把库存消耗光之后在缓存里设置占位符让后续的请求快速失败从而最快地进行响应。

限流

削峰的方式,前面介绍了验证码/问答题以及消息队列,这些方式使流量峰值变得更加平滑,但也在一定程度上降低了抢购体验,容易引发用户咨询和投诉。那有没有更好的解决方式呢?接下来我们学习下限流,看看如何通过限流实现削峰。

限流是系统自我保护的最直接手段,再厉害的系统,总有所能承载的能力上限,一旦流量突破这个上限,就会引起实例宕机,进而发生系统雪崩,带来灾难性后果。

第一讲的时候,我有和你提到过流量漏斗的概念,对于秒杀流程来说,从用户开始参与秒杀,到秒杀成功支付完成,实际上经历了很多的系统链路调用,中间有非常庞杂的系统在支撑,比如有商详、风控、登录、限购、购物车以及订单等很多交易系统。

那么对于秒杀的瞬时流量,如果不加筛选,不做限制,直接把流量传递给下游各个系统,对整个交易系统都是非常大的挑战,也是很大的资源浪费,所以主流的做法是从上游开始,对流量进行逐级限流,分层过滤,优质的有效的流量最终才能参与下单。

这是系统的流量漏斗示意图,通过风控和防刷筛选刷子流量,通过限购和预约校验过滤无效流量,通过限流丢弃多余流量,最终秒杀系统给到下游的流量就是非常优质且少量的了。

限流常用的算法有令牌桶和漏桶,有关这两个算法的专业介绍,你可以参考:https://hansliu.com/posts/2020/11/what-is-token-bucket-and-leaky-bucket-algorithms.html

下面我们针对demo-nginx和demo-web两个应用介绍一下具体的限流方法。

demo-nginx网关限流

先开始准备工作俗话说工欲善其事必先利其器在开发之前我们先把Nginx的日志给配置起来方便我们后续开发的调试与验证。

Nginx日志配置Nginx主要有两种类型的日志文件。

一个是error_log用来记录我们的系统日志以及主动打印的业务日志配置语法为

	error_log <日志文件路径> <日志级别>;

另一个是access_log这个日志主要用来记录我们的请求和返回相关的信息配置语法为

	access_log <日志文件路径> <日志格式定义的名称>;

如果想自定义输出日志格式需要使用log_format来实现下面我们就来配置一下这两种日志。

首先我们在nginx.conf中定义一个名为access的日志格式如下图所示

图片

然后在domain.com中配置error_log和access_log如下图所示

图片

这里我使用了内置变量以及自定义变量$user_id通过set_by_lua_block定义并赋值。现在我们已经把日志配置好了接下来就开始今天的重头戏吧就从Nginx限流开始讲起。

这里的Nginx限流主要是依赖Nginx自带的限流功能针对请求的来源IP或者自定义的一个关键参数来做限流比如用户ID。其配置限流规则的语法为

limit_req_zone <变量名> zone=<限流规则名称>:<内存大小> rate=<速率阈值>r/s;

解释一下:

  • 以上limit_req_zone是关键字<变量名>是指定根据什么来限流;
  • zone是关键字<限流规则名称>是定义规则名称,后续代码中可以指定使用哪个规则;
  • <内存大小>是指声明多大内存来支撑限流的功能;
  • rate是关键字可以指定限流的阈值单位r/s意为每秒允许通过的请求这个算法是使用令牌漏桶的思想来实现的。

那么明白了语法之后,下面我们就动手定义一个限流规则,看看实际效果。

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; 
    server {
        location /search/ {
            limit_req zone=one burst=2 nodelay;
        }
    }
}  

以上是基于IP地址进行限流的例子你可以根据实际的情况调整rate和burst的值在秒杀的场景下一般我们会把rate和burst设置的很低可以都为1即要求1个IP1秒内只能访问1次。

但根据IP地址设置限流时要慎重会存在误杀的情况特别像公司内用户他们的出口IP就那么几个很容易就触发了限流所以我一般在参与阿里、苏宁或京东的秒杀活动时都会切换到4G网络避免用公司网络。

除了基于IP限流外我们还可以设计基于用户的userId进行限流。

limit_req_zone $user_id zone=limit_by_user:10m rate=1r/s; 

demo-web应用层限流

以上是Nginx网关层的限流接下来我们进入应用层的限流。应用层的限流手段也是比较多的这里我们重点介绍通过线程池和API限流的方法。

线程池限流

Java原生的线程池原理相信你非常清楚我们可以通过自定义线程池配置最大连接数以请求处理队列长度以及拒绝策略等参数来达到限流的目的。当处理队列满而且最大线程都在处理时多余的请求就会被拒绝策略丢弃也就是被限流了。

API限流

上面介绍的线程池限流可以看做是一种并发数限流对于并发数限流来说实际上服务提供的QPS能力是和后端处理的响应时长有关系的在并发数恒定的情况下TP99越低QPS就越高。

然而大部分情况是我们希望根据QPS多少来进行限流这时就不能用线程池策略了。不过我们可以用Google提供的RateLimiter开源包自己手写一个基于令牌桶的限流注解和实现在业务API代码里使用。当然了大厂中都会有通用的限流机制你直接用就行了。

/**	
 * 自定义注解  限流	
 */	

@Target({ElementType.PARAMETER, ElementType.METHOD})	
@Retention(RetentionPolicy.RUNTIME)	
@Documented	
public @interface MyRateLimit {	
     String description() default "";	
}

我们自定义一个切面:


/**	
 * 限流 AOP	
 */	

@Component	
@Scope	
@Aspect	
public class LimitAspect {	
    //引用RateLimiter内部是基于令牌桶实现的	
    private static RateLimiter rateLimiter = RateLimiter.create(100.0);	

    //定义限流注解的pointcut	
    @Pointcut("@annotation(com.ecommerce.seckill.aop.MyRateLimit)")  	
    public void MyRateLimitAspect() {	
    }	

    @Around("MyRateLimitAspect()")	
    public  Object around(ProceedingJoinPoint joinPoint) { 	
        Boolean flag = rateLimiter.tryAcquire();	
        Object obj = null;	
        try {	
            if(flag){	
                obj = joinPoint.proceed();	
            }	
        } catch (Throwable e) {	
            e.printStackTrace();	
        }	
        return obj;	
    }	
}

业务层代码实现:

@Override	
@MyRateLimit	
@Transactional	
public SeckillResponse initData(String skuId, String userName) {	
    //此次为业务代码实现	
}

小结

这节课我们介绍了削峰的多种手段,有验证码、问答题、消息队列以及限流等。实际上,这些削峰的方式都可以达到控制流量的目的,你可以根据自己的情况进行选择。

验证码是一种非常常见的防刷手段,大多数网站的登录模块中,为避免被机器人刷,都会加入图片验证码。而在秒杀系统中,我们除了用验证码来防刷外,还有一个目的就是通过验证码进行削峰,达到流量整形的目的。除了图片验证码,你一定也见过短信和语音验证码,那为什么在秒杀系统的削峰中,我们通常会选择图片验证码呢?主要还是出于成本和用户体验的考虑。

消息队列是常用的应用解耦方式,通过把同步调用改造成异步消息,消费方可以根据自己的能力来处理请求,而不用担心被瞬时流量打垮。当然了,如果库存已经卖完,那么消费方在处理请求的时候,可以快速失败,这样也不用担心消息的长期积压。

最后,我介绍了几种限流的方式,和其他削峰方式相比,限流是有损的。限流实际上是根据服务自身的容量,无差别地丢弃多余流量,对于被丢弃的流量来说,这块的体验是受损的。另外,因为秒杀流量会经历很多交易系统,所以我们在设计时需要从起始流量开始,分层过滤,逐级限流,这样流量在最后的下单环节就是少量而可控的了。

另外,在这一节课开始的时候,我有留下一个小伏笔,就是在介绍了各种削峰手段后,互联网大厂在实践中一般都是怎么选择的呢?其实,如果你去体验下像天猫、京东的秒杀,就会发现他们总体是比较类似的,基本不会使用验证码或答题这两种方式,因为对于头部电商平台来说,体验可能不是那么友好。他们比较偏向采用非公平的抢购策略,也就是有损的逐级限流和分层过滤,背后最重要的考虑其实就是兼顾了体验与系统资源。

思考题

我们在介绍削峰手段的时候,有提到过问答题也是一种削峰方式,但是在这一讲中,因为不常用的关系,我略去了问答题的设计。这里我想请你思考一下,如果让你来设计秒杀的问答题系统,将其作为一种削峰方式,你会怎么设计呢?

以上就是这节课的全部内容,欢迎你在评论区和我讨论问题,交流经验!