spring cloud gateway限流 漏桶算法和令牌桶算法初识

前言

在高并发的应用中,限流是一个绕不开的话题。限流可以保障我们的 API 服务对所有用户的可用性,也可以防止网络攻击。

限流算法

令牌桶是一种限速算法,与之相对的是漏桶。常用的平滑的限流算法有两种:漏桶算法和令牌桶算法:

漏桶算法

漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)。因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。

令牌桶算法

令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定 1/QPS 时间间隔(如果 QPS=100,则间隔是 10ms)往桶里加入 Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了。新请求来临时,会各自拿走一个 Token,如果没有 Token 可拿了就阻塞或者拒绝服务。令牌桶的另外一个好处是可以方便的改变速度。一旦需要提高速率,则按需提高放入桶中的令牌的速率。一般会定时(比如 100 毫秒)往桶中增加一定数量的令牌,有些变种算法则实时的计算应该增加的令牌的数量。

  • 令牌限速

当进行任务的操作时,消耗一定的令牌,后台以一定的速率生产令牌。在没有令牌的情况下,就阻塞任务,或者拒绝服务。令牌的生产速率,代表了大部分情况下的平均流速。

  • 桶限峰值

的作用就是存储令牌,消耗的令牌都是从中获取。桶的作用是用来限制流速的峰值,当桶中有额外令牌的时候,实际的流速就会高于限定的令牌生产速率。假设令牌生产速率为v,桶大小为b,处理时间为t,则实际流量速度为V=v+b/t

  • 额外消耗

为了保证功能的完整,后台必须保证令牌生产,而且是持续服务,不能中断。同时,为了功能的正确作用,当桶满了以后,后续生产的令牌会溢出,不会存储到桶内部。

基本使用

1.消费

public static void main(String[] args) {
        Bandwidth limit = Bandwidth.simple(10, Duration.ofSeconds(1));
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        if(bucket.tryConsume(1)){
            System.out.println("do something");
        }else{
            System.out.println("do nothing");
        }
    }

Bandwidth:带宽,也就是每秒能够通过的流量,自动维护令牌生产。

Bucket:桶,不论状态,或是令牌的消费,bucket是我们操作的入口。

tryConsume:尝试消费n个令牌,返回布尔值,表示能够消费或者不能够消费,给我们判断依据。

为了简单理解可以尝试一下如下代码。

public static void main(String[] args) {
        Bandwidth limit = Bandwidth.simple(1, Duration.ofSeconds(1));
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        while(true){
            if(bucket.tryConsume(1)){
                System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }else{
                try{
                    System.out.println("waiting...");
                    Thread.sleep(200);
                }catch (Exception e){
                }
            }
        }
    }

2.阻塞

    public static void main(String[] args) throws InterruptedException {
        Bandwidth limit = Bandwidth.simple(1, Duration.ofSeconds(1));
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        while(true){
            bucket.asScheduler().consume(1);
            System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date()));
        }
    }

asScheduler会进行阻塞,直到获取令牌才进行后续语句的执行。

3.探针

public static void main(String[] args) throws InterruptedException {
        Bandwidth limit = Bandwidth.simple(5, Duration.ofSeconds(1));
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        while(true){
            ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
            if(probe.isConsumed()){
                System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
                        +"\t剩余令牌: "+ probe.getRemainingTokens());
            }else{
                System.out.println("waiting...");
                Thread.sleep(200);
            }
        }
    }

tryConsumeAndReturnRemaining:获取探针

isConsumed:判断是否能消耗

getRemainingTokens:查询剩余令牌数量

4.桶控制

public static void main(String[] args) throws InterruptedException {
        long bucketSize = 9;
        Refill filler = Refill.greedy(2, Duration.ofSeconds(1));
        Bandwidth limit = Bandwidth.classic(bucketSize, filler);
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        while(true){
            ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
            if(probe.isConsumed()){
                System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
                        +"\t剩余令牌: "+ probe.getRemainingTokens());
            }else{
                Thread.sleep(2000);
            }
        }
    }

Refiller:填充速度

bucketSize:桶容量

classic:用Refiller创建Bandwidth

可以观察到初始容量有10个,休眠两秒,每次消耗刚好四个,验证Refill功能。

稍微深入

1.初始化令牌个数

public static void main(String[] args) throws InterruptedException {
        long bucketSize = 9;
        Refill filler = Refill.greedy(2, Duration.ofSeconds(1));
        Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(5);
        Bucket bucket = Bucket4j.builder().addLimit(limit).build();
        while(true){
            ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
            if(probe.isConsumed()){
                System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
                        +"\t剩余令牌: "+ probe.getRemainingTokens());
            }else{
                Thread.sleep(2000);
            }
        }
    }

withInitialTokens:桶初始化令牌数。一般情况下,桶容量初始化时默认是满的,可以设置初始化时桶内的令牌数。

2.杜绝贪婪

public static void main(String[] args) throws InterruptedException {
        long bucketSize = 10;
        Refill filler = Refill.greedy(10, Duration.ofSeconds(1));
        Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(0);
        Bucket bucket = Bucket4j.builder()
                .addLimit(limit)
                .build();
        while(true){
            if(bucket.tryConsume(1)){
                System.out.println(new SimpleDateFormat("HH:mm:ss:SSS").format(new Date()));
            }
        }
    }

运行一下,就能够大致了解Refill的贪婪了。它总是那么急,我们的10/s,施行下去结果变成了1/ms。如果我们必须是瞬间生成10个,这就违背我们的意愿了。

Refill filler = Refill.intervally(10, Duration.ofSeconds(1));
这样,我们的意愿就不会因为贪婪而有所扭曲了。
 

3.手动添加

bucket.addTokens(500);

不等创建,自己手动添加,适用于回滚。

public static void main(String[] args) throws InterruptedException {
        long bucketSize = 10;
        Refill filler = Refill.intervally(10, Duration.ofSeconds(1));
        Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(0);
        Bucket bucket = Bucket4j.builder()
                .addLimit(limit)
                .build();
        bucket.addTokens(500);
        while(true){
            if(bucket.tryConsume(1)){
                try{
                    throw new Exception("create a Exception");
                } catch (Exception e) {
                    bucket.addTokens(1);
                }
            }
        }
    }

4.时钟

从上面的贪婪,可以发现,Bucket4j的时钟是以毫秒进行衡量的,如果想要微秒之类的更细粒度操控,需要自己设置

// 毫秒
Bucket4j.builder().withMillisecondPrecision().build;
// 微秒
Bucket4j.builder().withNanosecondPrecision().build()

withMillisecondPrecision:毫秒

withNanosecondPrecision:微秒

担心系统时钟不准,可以采用自己的时钟。

Bucket4j.builder().withCustomTimePrecision(new MyClock()).build()

withCustomTimePrecision:指定时钟

5.配置热替换

BucketConfiguration config = Bucket4j.configurationBuilder()
    .addLimit(Bandwidth.simple(1, Duration.ofSeconds(2)))
    .build();
bucket.replaceConfiguration(config);

时机到了,就可以更换配置。业务繁忙的时候令牌带宽可以适当的放宽,没有请求的时候也可以适当的减缓令牌生成。毕竟生成令牌也会占用资源不是。

6.波动令牌

Bucket bucket = Bucket4j.builder()
       .addLimit(Bandwidth.simple(1000, Duration.ofMinutes(1)))
       .addLimit(Bandwidth.simple(50, Duration.ofSeconds(1)))
       .build();

当添加多个BandWidth,都会生效。考虑总体的满足,在情况允许的情况下会尽量满足全部的要求。如例子,如果满足第二个条件,第一个条件必定会被打破,但是也不是一直不会满足第二个条件。因此,在整体上不超过带宽,但是允许不全部占用的情况。令牌的生成也不必时刻都是火力全开,为了满足全部的限制,有时候不得不消极怠工。这种波动,打破了平均生产的境况,从而允许动态速率的生成令牌。
 

限流实现

在 Gateway 上实现限流是个不错的选择,只需要编写一个过滤器就可以了。我们这里采用令牌桶算法,Google Guava 的RateLimiter、Bucket4j、RateLimitJ 都是一些基于此算法的实现,只是他们支持的 back-ends(JCache、Hazelcast、Redis 等)不同罢了,你可以根据自己的技术栈选择相应的实现。

我们这里使用 Bucket4j,引入依赖坐标:

compile group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '4.4.1'//限流,令牌桶

添加一个过滤器:

package com.zoo.gateway.filter;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @Author: xf
 * @Date: 2019/8/14 15:38
 * @Version 1.0
 */
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RateLimitByIpGatewayFilter implements GatewayFilter, Ordered {

    int capacity;//桶的最大容量,即能装载 Token 的最大数量
    int refillTokens;//每次 Token 补充量
    Duration refillDuration;//补充 Token 的时间间隔
    private static final Map CACHE = new ConcurrentHashMap<>();
    private static final boolean enableRateLimit = true;//开关

    private Bucket createNewBucket() {
        Refill refill = Refill.of(refillTokens, refillDuration);
        Bandwidth limit = Bandwidth.classic(capacity, refill);
        return Bucket4j.builder().addLimit(limit).build();
    }

    @Override
    public int getOrder() {
        return -2;
    }

    @Override
    public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
         if (!enableRateLimit){
             return chain.filter(exchange);
         }
        String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
        Bucket bucket = CACHE.computeIfAbsent(ip, k -> createNewBucket());

        if (bucket.tryConsume(1)) {
            return chain.filter(exchange);
        } else {
            exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
            return exchange.getResponse().setComplete();
        }
    }


}

在这个实现中,我们使用了 IP 来进行限制,当达到最大流量就返回429 Too Many Requests (太多请求)错误。这里我们简单使用一个 Map 来存储 bucket,所以也决定了它只能单点使用,如果是分布式的话,可以采用 Hazelcast 或 Redis 等解决方案。

然后我们在Route添加这个过滤器,这里指定了 bucket 的容量为 10 且每一秒会补充 1 个 Token。

    @Bean
    public RouteLocator customerRouteLocator(RouteLocatorBuilder builder) {
        // @formatter:off
        return builder.routes()
                .route(r -> r.path("/api/test/**")
                        .filters(f -> f.stripPrefix(2)
                                .filter(new RateLimitByIpGatewayFilter(10, 1, Duration.ofSeconds(1))))
                        .uri("lb://zebra-test")
                        .order(0)
                        .id("zebra-test")
                )
                .build();
        // @formatter:on
    }

启动服务并多次快速刷新改接口,就会看到 Tokens 的数量在不断减小,等一会又会增加上来。

RequestRateLimiter

(未完待续)

 

你可能感兴趣的:(SpringCould)