Spring Cloud Gateway 限流概述

357 阅读7分钟

       网关(或者说边缘网关)作为外部流量进入内部服务的第一道”关卡“,除了”路由“这个本质工作之外,往往会承载很多边缘功能,”限流“便是其中很重要的一项,本文我们来聊聊 SCG 限流模块的设计思路和内置的实现方式。 

限流基本概念

限流是一个比较宽泛的概念,以下几类都是限流的范畴: 

  • 对访问频率的限制,这就是我们平常所说的对QPS限流
  • 对连接数的限制 对传输速率的限制(比如下载速度、视频流的加载速度等) 
  • 对客户端名单的限制(比如用户、ip黑白名单) 

        如果要用一句话来描述限流的话,那就是系统在某个时间窗口对资源访问做限制,当请求达到一定的并发数或者速率时对其进行等待、排队、甚至拒绝。理论上来说,大部分ToC的系统都或多或少会采用一些限流的手段,有的自己做了简单实现,有的依赖中间件实现,有的依赖网关层的限流能力实现。 

        今天我们聊 SCG 的限流就是网关层面的限流,更准确的说是 SCG 网关针对 QPS 的限流,他可以是分钟级,也可以是秒级的。 SCG 网关限流有两个目的: 

  • 一是保护自身服务,防止因流量过大导致自己崩溃. 
  • 二是保护 upstream 服务,防止下游服务崩溃. 

        对于第一个目的,我们往往通过本地限流(Local Rate Limiter)功能来达成。本地限流比较简单,执行过程中不需要依赖第三方组件来相互同步限流数据,典型的实现方法有: 

  • - 基于 concurrnet 包中自带的信号量或者生产者消费者模式来实现限流器 
  • - 基于 Guava 提供的 RateLimiter 来实现本地限流器 
  • - 基于系统的前置 proxy 层设置限流,nginx 或者 envoy 都有这样的功能,只需适当配置就可以实现一个可靠的限流器 

           对于第二个目的,仅仅通过本地限流比较难以达到,这个时候我们需要一个全局限流器(Global Rate Limiter),一种简单可行的方案是基于 Redis 来实现。

限流算法

无论是本地限流器还是全局限流器,底层实现的算法无外乎以下几种:

1) 时间窗口算法

这是一种计数方法,就是在一个时间窗口内对请求进行计数,当达到设置的阀值时就拒绝访问。比如限流目标QPS为10,那么一秒就是一个时间窗口,限流阀值为10,当1秒内请求已经到达10个之后触发限流,简单粗暴的就是丢弃(对客户端一个限流的提示让其重试)。时间窗口算法根据窗口是否固定不变又可以细分为两种:

  • 固定时间窗口
  • 滑动时间窗口

固定时间窗口实现思路如下:

  • 使用一个计数器记录单位时间内的访问数量
  • 当访问数量没有达到阀值,就允许访问;当访问数量达到阀值则拒绝
  • 当时间窗口过去后,计数器重新计数。

上图就是一个以一秒作为固定时间窗口的例子,他的实现很简单明了:0-1秒计数,1-2秒计数,以此类推。但他的缺陷也是明显的: 比如限速为10,0-0.5秒没有请求,0.5-1秒来了10个请求,1.0-1.5秒又来了10个请求,那么相当于0.5-1.5秒这一秒处理了20个请求。这就是临界值问题导致限流失败。 再比如,0-1秒来了20个请求,那么其中先到的10个属于正常请求,后面的10个请求会被限流(直接丢弃),1-2秒没有请求进来,系统空闲。而实际上我们很容易认为1-2秒可以处理0-1秒被限制的请求。

滑动窗口是固定窗口的优化,他能解决临界值导致限流失败的问题,以及解决一部分流量平滑性问题,其优化思路:将一个时间窗口在分为n份,每一份里都有独立的计数器c,每当请求进来,就会求n个计数器的和,当达到阀值就触发限流,没达到则当前小格子计数器加1. 下图是以1秒作为滑动窗口,每个窗口又细分为5等分的示意图

当时间窗口滑动一小格之后,c1 跑到黄色格子里。很容易发现,格子划分的越小,这个滑动窗口越平滑,请求越均匀。时间窗口算法简答做法是借助一个信号量来实现,下面是一个固定时间窗口的示例代码。

public class TimeWindowLocalRateLimiter {
    private int qps;
    private AtomicInteger val;
    private AtomicBoolean running = new AtomicBoolean(false);
    private final Executor executor = Executors.newSingleThreadExecutor();

    @PostConstruct
    public void init(){
        if( qps <= 0 ){
            throw new IllegalArgumentException("limitValue in rate limiter could not be negative");
        }
        val = new AtomicInteger(qps);
        startRefreshTask();
    }
    public boolean isAllow(){
        int x = val.decrementAndGet();
        return x >= 0;
    }
    private void startRefreshTask(){
        executor.execute(() -> {
            while(running.get()){
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    log.warn("interrupted");
                }
                val.set(qps);
            }
        });
    }
}

2) 漏桶算法

漏桶算法是说有一个漏桶,水匀速地从底部漏出,同时有个水笼头随机的注水进来。水龙头就等于是系统收到客户端请求,所有请求进来都进这个漏桶里,桶满了就溢出(也就是丢弃请求),桶底匀速漏出的水就等于系统平滑的处理请求。无论请求量有多大,系统处理请求是恒定的。下面是网上比较形象的示意图:

很明显较时间窗口算法,漏桶算法可以解决请求处理的平滑性,但也存在一个问题,比如请求延迟问题,突发流量处理缺陷。下面是java的一个实现示例:

public class LeakBucketRateLimiter {
    private static final String QUEUE_ITEM = "x";
    private final double leakRate;
    private long lastLeakTime;
    private final ArrayBlockingQueue<String> queue;
    private final Executor executor = Executors.newSingleThreadExecutor();
    private final Lock lock = new ReentrantLock();
    private volatile boolean leaking = true;

    public LeakBucketRateLimiter(int capacity, double leakRate) {
        this.leakRate = leakRate;
        this.lastLeakTime = System.currentTimeMillis();
        queue = new ArrayBlockingQueue<>(capacity);
        startLeakTask();
    }

    /**
     * 表示一个请求进来进行入桶操作,桶满则被限流
     *
     * @return
     */
    public boolean isAllow() {
        leakWater();
        return queue.offer(QUEUE_ITEM);
    }

    private void startLeakTask() {
        executor.execute(this::leakWater);
    }

    private void leakWater() {
        while (leaking) {
            if (lock.tryLock()) {
                try {
                    long now = System.currentTimeMillis();
                    int leakNum = (int) ((now - lastLeakTime) * leakRate);
                    for (int i = 0; i < leakNum; i++) {
                        if (queue.poll() == null) {
                            break;
                        }
                    }
                    lastLeakTime = now;
                } finally {
                    lock.unlock();
                }
            }
            sleepOneSecond();
        }
    }

}

3) 令牌算法

令牌桶算法应该是目前应用最广泛的限流算法。首先有一个固定容量的令牌桶,令牌生成器匀速发放令牌注入桶中,桶满了就暂停发放令牌。当请求过来时,会首先去令牌桶获取一个令牌,获取成功则请求被处理,获取不到则拒绝服务。令牌生成器解决日常限流,桶的容量设置可以应对一定的突发流量。 下图是网上比较形象的令牌桶算法示意图

令牌桶算法 令牌桶算法也可以借助Queue来实现,下面是一个简单的java实现示例

public class TokenBucketRateLimiter {
    private static final String QUEUE_ITEM = "x";
    private final ArrayBlockingQueue<String> queue;

    private final int generateTokenNumEverySecond;
    private final Executor executor = Executors.newSingleThreadExecutor();
    private volatile boolean running = true;

    public TokenBucketRateLimiter(int limit, int periodInSecond, int generateTokenNumEverySecond) {
        this.queue = new ArrayBlockingQueue<>(limit);
        this.generateTokenNumEverySecond = generateTokenNumEverySecond;
    }

    @PostConstruct
    public void init(){
        //初始状态加满队列,表示当前可以处理这么多请求
       for(;;){
           if(!queue.offer(QUEUE_ITEM)){
               break;
           }
       }
       executor.execute(this::generateToken);
    }

    public boolean isAllow(){
        return queue.poll() != null;
    }

    private void generateToken(){
        while(running){
            for(int i = 0; i < generateTokenNumEverySecond; i++){
                if(!queue.offer(QUEUE_ITEM)){
                    break;
                }
            }
            sleepOneSecond();
        }
    }

}

**当然,以上仅仅是代码示例,帮助我们理解对应的算法,离实际生产还差的很远。**一般对于local限流,有兴趣可以研究一下 Guava com.google.common.util.concurrent.RateLimiter的实现。或者可以研究一下Bucket4j,这是一个基于令牌桶算法实现的强大的限流库,它不仅支持单机限流,还支持通过诸如 Hazelcast、Ignite、Coherence、Infinispan 或其他兼容 JCache API (JSR 107) 规范的分布式缓存实现分布式限流 科普完限流的先关概念之后,我们来看一下 Spring Cloud Gateway 对限流模块的设计。

SCG中的限流设计

SCG 提供了一个叫做 RequestRateLimiterGatewayFilterFactory 的 Filter,其功能就是做限流用的。该 Filter 内部抽象了两个接口

  • RateLimiter: 该接口有一个核心限流方法
Mono<boolean> isAllow(String routeId, String id) 

看的出来他是针对当前执行的routeId这个路由的, 而 id 参数标识了进行限流的一个 key,毕竟网关限流要考虑到upstream的多重需求,不仅仅是对网关服务本身的限流。

  • KeyResolver:用来提供限流 key 的一个接口

显然 RateLimiter 中核心限流方法中的 id 参数其实是由 KeyResolver 提供的 key

另外,SCG 有一个内置基于 Redis 的限流实现: RedisRateLimiter。该类是基于 Redis 实现的一个”令牌“算法,从对应 Config 参数就能看得出来:

burstCapacity:令牌桶的容量大小 replenishRate:每秒钟生产的令牌数,也就是填充令牌桶的速度 requestedTokens:每一个请求需要获取的令牌数,一般是1 

算法的逻辑是封装在 Lua 脚本里,主要是利用 Lua 的原子性,避免并发访问时可能出现请求量超出上限的现象。所以,整体的实现类图如下:

基于 SCG 的这个设计,我们一方面可以很方便的使用限流功能,请看下面例子:

    1. clone代码分支:gitee.com/wlscode/scg…
    1. 启动 MockserverApplication,我们有一个upstream api: http://localhost:8080/r1/hello
    1. 配置 GatewayServiceApplication(带上参数 -Dspring.profiles.active=ratelimit),我们有如下配置:

    • id: rt-test uri: http://localhost:8080 predicates:
      • Path=/v1/{apiCode}/** filters:
      • StripPrefix=2
      • name: RequestRateLimiter args: key-resolver: '#{@pathKeyResolver}' rateLimiter: '#{@redisRateLimiter}' redis-rate-limiter.replenishRate: 1 #令牌每秒填充速度 redis-rate-limiter.burstCapacity: 1 #桶大小 redis-rate-limiter.requestedTokens: 1 #默认是1,每次请求消耗的令牌数

它代表了QPS=1的限流,并且桶的大小也是1,并不处理突发流量的情况。

    1. 运行 RateLimitTest1,它会以每秒20个请求的速度请求三次

运行日志里会有很多 Response 429 TOO_MANY_REQUESTS,表示被限流了,而每秒钟成功返回的只有1个

最后,基于这个设计,我们还是非常容易扩展,比如我们想换一个 RateLimiter 的实现,我们可以自己实现一个 XxxRateLimiter,其内部可以基于上文所说的 Guava 或者 Bucket4j。然后修改配置:

rateLimiter: '#{@你实现的RateLimiter bean 名字}'key-resolver: '#{@你的keyResolver bean 名字}'

具体如何扩展且看下一篇细聊