服务限流算法的几种实现

孤街浪徒 提交于 2020-03-15 22:01:57

一、场景描述

    由于业务应用系统的负载能力有限,为了防止非预期的请求对系统压力过大而拖垮业务应用系统。

    面对大流量时,如何进行流量控制?

    服务接口的流量控制策略:分流、降级、限流等。

    本文讨论限流策略,虽然降低了服务接口的访问频率和并发量,却换取服务接口和业务应用系统的高可用。

    实际场景中常用的限流策略:

    Nginx前端限流

        按照一定的规则如帐号、IP、系统调用逻辑等在Nginx层面做限流

    业务应用系统限流

        1、客户端限流

        2、服务端限流

    数据库限流

        红线区,力保数据库

 

    按照服务的调用方,可以分为以下几种类型服务

    1、与用户打交道的服务

        比如web服务、对外API,这种类型的服务有以下几种可能导致机器被拖垮:

  • 用户增长过快

  • 因为某个热点事件(微博热搜)

  • 竞争对象爬虫

  • 恶意的刷单

    这些情况都是无法预知的,不知道什么时候会有10倍甚至20倍的流量打进来,如果真碰上这种情况,扩容是根本来不及的

    2、对内的RPC服务

    一个服务A的接口可能被B C D E多个服务进行调用,在B服务发生突发流量时,直接把A服务给调用挂了,导致A服务对C D E 也无法提供服务。 这种情况时有发生,解决方案有两种:

    1、每个调用方采用线程池进行资源隔离

    2、使用限流手段对每个调用方进行限流

 

 

二、常用的限流算法

    常见的限流算法有:计数器、令牌桶、漏桶。

    1、计数器算法

        采用计数器实现限流有点简单粗暴,一般我们会限制一秒钟的能够通过的请求数,比如限流qps为100,算法的实现思路就是从第一个请求进来开始计时,在接下去的1s内,每来一个请求,就把计数加1,如果累加的数字达到了100,那么后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数。

        具体的实现可以是这样的:对于每次服务调用,可以通过 AtomicLong#incrementAndGet()方法来给计数器加1并返回最新值,通过这个最新值和阈值进行比较。

        这种实现方式,相信大家都知道有一个弊端:如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象”

    2、漏桶算法

        漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率.

        示意图如下:

   

        可见这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)。

        因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率.因此,漏桶算法对于存在突发特性的流量来说缺乏效率.

        在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。

        这种算法,在使用过后也存在弊端:无法应对短时间的突发流量。

    3、令牌桶算法

        令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解.随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了.

        新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务.

        令牌桶的另外一个好处是可以方便的改变速度. 一旦需要提高速率,则按需提高放入桶中的令牌的速率. 一般会定时(比如100毫秒)往桶中增加一定数量的令牌, 有些变种算法则实时的计算应该增加的令牌的数量.

        从某种意义上讲,令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。

        在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。

        放令牌这个动作是持续不断的进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置qps为100,那么限流器初始化完成一秒后,桶中就已经有100个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的100个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。

        实现思路:可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。

 

        google开源工具包guava: 限流工具类RateLimiter ​​​​​​

        1.通过Google开源的guava包,我们可以很轻松的创建一个令牌桶算法的限流器。

<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>18.0</version>
</dependency>

        通过RateLimiter类的create方法,创建限流器。

package ratelimite;
import com.google.common.util.concurrent.RateLimiter;

public class RateLimiterDemo {
    public static void main(String[] args) {
        testNoRateLimiter();
        testWithRateLimiter();
    }

    public static void testNoRateLimiter() {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            System.out.println("call execute.." + i);
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    public static void testWithRateLimiter() {
        long start = System.currentTimeMillis();
        RateLimiter limiter = RateLimiter.create(10.0);
        // 每秒不超过10个任务被提交 
        for (int i = 0; i < 10; i++) {
            limiter.acquire();
            // 请求RateLimiter, 超过permits会被阻塞 
            System.out.println("call execute.." + i);
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
}

        其实Guava提供了多种create方法,方便创建适合各种需求的限流器。在上述例子中,创建了一个每秒生成10个令牌的限流器,即100ms生成一个,并最多保存10个令牌,多余的会被丢弃。

        rateLimiter提供了acquire()和tryAcquire()接口

        1、使用acquire()方法,如果没有可用令牌,会一直阻塞直到有足够的令牌。

        2、使用tryAcquire()方法,如果没有可用令牌,就直接返回false。

        3、使用tryAcquire()带超时时间的方法,如果没有可用令牌,就会判断在超时时间内是否可以等到令牌,如果不能,就返回false,如果可以,就阻塞等待。

 

        spring cloud网关通过Zuul RateLimit 限流配置

        在pom文件中引入Zuul RateLimit的依赖:

<dependency>
    <groupId>com.marcosbarbero.cloud</groupId>
    <artifactId>spring-cloud-zuul-ratelimit</artifactId>
    <version>1.3.2.RELEASE</version>
</dependency>

        配置信息

更详细的配置解读下面有写,这里只是简单配置一下,以下这个配置就可以对服务进行限流了
zuul:
  routes: 你的路由配置
    test:
      path: 
      serviceId: 
  ratelimit:
    enabled: true
    policies:
      test: 路由名
        limit: 限制次数
        refresh-interval: 刷新时间
        type: 类型

        RateLimit源码简单分析

        本地让自己的一个服务配置为一分钟内该服务的API只能访问十次,超过十次,网关就会报错

zuul:
  routes:
    test:
      path: /api/test/**
      serviceId: hscf-cloud-test-9457
  ratelimit:
    enabled: true
    policies:
      test:
        limit: 10
        refresh-interval: 60
        type: origin  限流方式

        RateLimit类是继承ZuulFilter。filterType为“pre”表示在每一个API访问之前进行拦截,LIMIT_HEADER,REMAINING_HEADER,RESET_HEADER 这三个变量是获取配置中的访问次数,记录该时间内剩余的访问次数。

public class RateLimitFilter extends ZuulFilter {
    public static final String LIMIT_HEADER = "X-RateLimit-Limit";
    public static final String REMAINING_HEADER = "X-RateLimit-Remaining";
    public static final String RESET_HEADER = "X-RateLimit-Reset";

    public String filterType() {
        return "pre";
    }
    public int filterOrder() {
        return -1;
    }
    public boolean shouldFilter() {
        return this.properties.isEnabled() && this.policy(this.route()).isPresent();
    }
}

        在主体逻辑run()中进行判断。先通过 this.policy(route).ifPresent((policy) 判断policy配置信息是否存在,存在的话会读取到当前的限制值 和 剩余的限制值,最终判断剩余的限制值是否小于0,小于0的话就会报出太多请求的异常: TOO_MANY_REQUESTS(429, "Too Many Requests")

public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletResponse response = ctx.getResponse();
        HttpServletRequest request = ctx.getRequest();
        Route route = this.route();
        this.policy(route).ifPresent((policy) -> {
            String key = this.rateLimitKeyGenerator.key(request, route, policy);
            Rate rate = this.rateLimiter.consume(policy, key);
            response.setHeader("X-RateLimit-Limit", policy.getLimit().toString());
            response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(rate.getRemaining().longValue(), 0L)));
            response.setHeader("X-RateLimit-Reset", rate.getReset().toString());
            if(rate.getRemaining().longValue() < 0L) {
                ctx.setResponseStatusCode(HttpStatus.TOO_MANY_REQUESTS.value());
                ctx.put("rateLimitExceeded", "true");
                throw new ZuulRuntimeException(new ZuulException(HttpStatus.TOO_MANY_REQUESTS.toString(), HttpStatus.TOO_MANY_REQUESTS.value(), (String)null));
            }
        });
        return null;
}

        控制台的异常信息,异常code为429,也就是太多请求的异常:TOO_MANY_REQUESTS(429, "Too Many Requests")

        RateLimit详细的配置信息解读

zuul:

    ratelimit:

        key-prefix: your-prefix  #对应用来标识请求的key的前缀

        enabled: true

        repository: REDIS  #对应存储类型(用来存储统计信息)

        behind-proxy: true  #代理之后

        default-policy: #可选 - 针对所有的路由配置的策略,除非特别配置了policies

             limit: 10 #可选 - 每个刷新时间窗口对应的请求数量限制

             quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

              refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

               type: #可选 限流方式

                    - user  #通过请求路径区分
                    - origin  #通过客户端IP地址区分
                    - url   #通过登录用户名进行区分,也包括匿名用户

          policies:

                myServiceId: #特定的路由

                      limit: 10 #可选- 每个刷新时间窗口对应的请求数量限制

                      quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

                      refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

                      type: #可选 限流方式

                          - user

                          - origin

                          - url

 

集群限流

    前面讨论的几种算法都属于单机限流的范畴,但是业务需求五花八门,简单的单机限流,根本无法满足他们。

    比如为了限制某个资源被每个用户或者商户的访问次数,5s只能访问2次,或者一天只能调用1000次,这种需求,单机限流是无法实现的,这时就需要通过集群限流进行实现。

    如何实现?

    为了控制访问次数,肯定需要一个计数器,而且这个计数器只能保存在第三方服务,比如redis。

    大概思路:每次有相关操作的时候,就向redis服务器发送一个incr命令,比如需要限制某个用户访问/index接口的次数,只需要拼接用户id和接口名生成redis的key,每次该用户访问此接口时,只需要对这个key执行incr命令,在这个key带上过期时间,就可以实现指定时间的访问频率。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!