Gateway 限流及令牌桶算法源码分析

1.为什么需要限流

1、大量正常用户高频访问导致服务器宕机
2、恶意用户高频访问导致服务器宕机
3、网页爬虫 ,对于这些情况我们需要对用户的访问进行限流访问

限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

2.Gateway在哪里可以实现限流

2.1Gateway 相关概念

Spring Cloud Gateway 是 Spring 官方基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等技术开发的网关,Spring Cloud Gateway 旨在为微服务架构提供一种简单而有效的统一的API路由管理方式。Spring Cloud Gateway 作为Spring Cloud 生态系中的网关,目标是替代 Zuul,其不仅提供统一的路由方式,并且基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/埋点,和限流等。Spring Cloud Gateway 可以看做是一个 Zuul 1.x 的升级版和代替品,比 Zuul 2 更早的使用 Netty 实现异步 IO,从而实现了一个简单、比 Zuul 1.x 更高效的、与 Spring Cloud 紧密配合的 API 网关。

Spring Cloud Gateway 里明确的区分了 Router 和 Filter,并且一个很大的特点是内置了非常多的开箱即用功能,并且都可以通过 SpringBoot 配置或者手工编码链式调用来使用。比如内置了 10 种 Router,使得我们可以直接配置一下就可以随心所欲的根据 Header、或者 Path、或者 Host、或者 Query 来做路由。比如区分了一般的 Filter 和全局 Filter,内置了 20 种 Filter 和 9 种全局 Filter,也都可以直接用。当然自定义 Filter 也非常方便。

  • Route: 网关基本构建块,由一个 ID、一个目标 URI,一组 predicate 和一组 filter 定义,若 predicate 为真,则路由匹配。
  • Predicate:输入类型是一个 ServerWebExchange,可以使用它来匹配来自 HTTP 请求的任何内容。
  • Filter:Gateway 中分为两种 filter,一个是 Gateway Filter 一个是 Global Filter,filter 会对请求和响应进行修改处理。

2.2Gateway 工作流程

Gateway 限流及令牌桶算法源码分析_第1张图片

客户端访问 Gateway 网关,Gateway 中 Handler Mapping 对请求 URL 进行处理,如果网关处理程序映射确定请求与路由匹配,则处理完之后交给 web Handler,web handler 会被 filter 进行过滤。Filter 中分成两部分,以在代理请求发送之前和之后运行逻辑。其中前半部分代码是处理请求的代码,处理完成后调用真实被代理的服务,被代理的服务响应结果,结果会被 filter 中后半部分代码进行操作,操作完成后把结果返回给 web handler,再返回给 handler mapping,最终响应给客户端。

3.常见的限流方法

3.1计数器法

设置一个计数器 counter,每当一个请求过来的时候,counter+1,如果 counter 的值大于阈值且当前请求与第一个请求的间隔时间还在限定的时间之内,那么说明请求数过多;如果该请求与第一个
请求的间隔时间大于限定时间,且 counter 的值还在限流范围内,那么就重置 counter。

缺点:精度太低,无法处理临界问题。以 QPS = 100 举例,假设从第一个请求开始计时,每一个请求让计数器加一,当到达 100 以后,其他的请求都拒绝,如果一秒内前 200ms 请求数量到达了100,那么后面 800ms 中的所有请求都被拒绝了,导致出现“突刺现象”。

3.2滑动窗口法

将窗口更加细分,每个窗口都有自己的计数器,当总计算达到限定时,限流。这个滑动窗口只是将计算法变得更平滑而已。

缺点:治标不治本(仍会有突刺)。

假设时间周期为1min,将1min再分为2个小周期,统计每个小周期的访问数量,则可以看到,第一个时间周期内,访问数量为75,第二个时间周期内,访问数量为100,超过100的访问则被限流掉了。

3.3漏桶法

将容器比作一个漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。(利用队列实现)

缺点:无法应对短时间的突发流量。因为流出的是固定频率,其他流量只是放在桶中。
 

3.4令牌桶算法

 

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

  • 令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数
    累积到漏桶容量时,则新流入的请求被拒绝。
  • 令牌桶限制的是平均流入速率,允许突发请求,只要有令牌就可以处理;漏桶限制的是常量流出速率,即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2,从而平滑突发流入速率。

4.令牌桶算法源码分析

令牌桶算法底层实现依赖于 RedisRateLimiter 类。

4.1限流 java 核心代码

@Override
@SuppressWarnings("unchecked")
public Mono isAllowed(String routeId, String id) {
   // 判断RedisRateLimiter是否初始化了
   if (!this.initialized.get()) {
      throw new IllegalStateException("RedisRateLimiter is not initialized");
   }


   // 根据服务应用id获取限流配置
   Config routeConfig = loadConfiguration(routeId);

   // How many requests per second do you want a user to be allowed to do?
   int replenishRate = routeConfig.getReplenishRate();

   // How much bursting do you want to allow?
   int burstCapacity = routeConfig.getBurstCapacity();

   // How many tokens are requested per request?
   int requestedTokens = routeConfig.getRequestedTokens();

   try {
      // 获取redis中的统计数据查询key
      List keys = getKeys(id);
         

      // 下面这里是直接使用 redis 执行了一段 lua 脚本
      // 返回的 response 里主要有两部分内容,一个是判断本次流量是否通过;
      // 第二个是返回值是为令牌桶中剩余的令牌数。
      // The arguments to the LUA script. time() returns unixtime in seconds.
      List scriptArgs = Arrays.asList(replenishRate + "",
            burstCapacity + "", Instant.now().getEpochSecond() + "",
            requestedTokens + "");
      // allowed, tokens_left = redis.eval(SCRIPT, keys, args)
      Flux> flux = this.redisTemplate.execute(this.script, keys,
            scriptArgs);
      // .log("redisratelimiter", Level.FINER);
      return flux.onErrorResume(throwable -> Flux.just(Arrays.asList(1L, -1L)))
            .reduce(new ArrayList(), (longs, l) -> {
               longs.addAll(l);
               return longs;
            }).map(results -> {
               boolean allowed = results.get(0) == 1L;
               Long tokensLeft = results.get(1);
               Response response = new Response(allowed,
                     getHeaders(routeConfig, tokensLeft));

               if (log.isDebugEnabled()) {
                  log.debug("response: " + response);
               }

               return response;
            });
   }
   catch (Exception e) {
      /*
       * We don't want a hard dependency on Redis to allow traffic. Make sure to set
       * an alert so you know if this is happening too much. Stripe's observed
       * failure rate is 0.01%.
       */
      log.error("Error determining if user allowed from redis", e);
   }
   return Mono.just(new Response(true, getHeaders(routeConfig, -1L)));
}

4.2令牌桶限流算法

令牌桶算法都在 lua 脚本里面呈现:

源码连接:https://github.com/spring-cloud/spring-cloud-gateway/blob/e61028a8b79f66a3a907b8f199454f49a10fea80/spring-cloud-gateway-core/src/main/resources/META-INF/scripts/request_rate_limiter.lua

--当前限流的标识,可以是 ip,或者在 spring cloud 系统中,可以是一个服务的 serviceID
local tokens_key = KEYS[1]
--令牌桶刷新的时间戳,后面会被用来计算当前产生的令牌数
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)


--令牌生产的速率,如每秒产生 50 个令牌
local rate = tonumber(ARGV[1])
--令牌桶的容积大小,比如最大 100 个,那么系统最大可承载 100 个并发请求
local capacity = tonumber(ARGV[2])
--当前时间戳
local now = tonumber(ARGV[3])
--当前请求的令牌数量,Spring Cloud Gateway 中默认是1,也就是当前请求
local requested = tonumber(ARGV[4])


--计算填满桶需要多长时间
local fill_time = capacity/rate
--得到填满桶的2倍时间作为redis中key时效的时间,避免冗余太多无用的key
local ttl = math.floor(fill_time*2)


--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1])
--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2])
--redis.log(redis.LOG_WARNING, "now " .. ARGV[3])
--redis.log(redis.LOG_WARNING, "requested " .. ARGV[4])
--redis.log(redis.LOG_WARNING, "filltime " .. fill_time)
--redis.log(redis.LOG_WARNING, "ttl " .. ttl)


--获取桶中剩余的令牌,如果桶是空的,就将他填满
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end
--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens)


--获取当前令牌桶最后的刷新时间,如果为空,则设置为0
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end
--redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed)


--计算最后一次刷新令牌到当前时间的时间差
local delta = math.max(0, now-last_refreshed)
--计算当前令牌数量,这个地方是最关键的地方,通过剩余令牌数 + 时间差内产生的令牌得到当前总令牌数量
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
--设置标识 allowad 接收当前令牌桶中的令牌数是否大于请求的令牌结果
local allowed = filled_tokens >= requested
--设置当前令牌数量
local new_tokens = filled_tokens
--是否申请到了令牌
local allowed_num = 0
--如果allowed为true,则将当前令牌数量重置为通中的令牌数 - 请求的令牌数,并且设置allowed_num标识为1
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end


--redis.log(redis.LOG_WARNING, "delta " .. delta)
--redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens)
--redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num)
--redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens)


--将当前令牌数量写回到redis中,并重置令牌桶的最后刷新时间
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)


--返回当前是否申请到了令牌,以及当前桶中剩余多少令牌
return { allowed_num, new_tokens }

5.令牌桶算法两种代码实现

5.1KeyResolver

(1)IP 限流

获取请求用户 ip 作为限流 key。

@Bean
public KeyResolver hostAddrKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}

(2)用户限流

获取请求用户 id 作为限流 key。

@Bean
public KeyResolver userKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));
}

(3)接口限流

获取请求地址的 uri 作为限流 key。

@Bean
KeyResolver apiKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getPath().value());
}

5.2 KeyResolver + yaml配置

public class KeyResolverConfig implements KeyResolver{
    /**
     * Mono, String 代表令牌分给谁
     * 根据请求地址限流
     * @param exchange
     * @return
     */
    @Override
    public Mono resolve(ServerWebExchange exchange) {
        String path = exchange.getRequest().getPath().value();
        System.out.println("path: " + path);
        return Mono.just(path);
    }
}

yaml:

cloud:
    gateway:
      routes:
        - id: routeId  # 路由 id
          uri: http://baidu.com  # 匹配后提供服务的路由地址
          predicates:
            - Path=/test/**  # 路径相匹配的进行路由
          filters:
            - name: RequestRateLimiter
              args:
                keyResolver: '#{@keyResolverConfig}'  # 使用 SpringEL 表达式,从Spring容器中找对象,并赋值
                redis-rate-limiter.replenishRate: 1   # 每秒中生成的令牌数量
                redis-rate-limiter.burstCapacity: 5   # 令牌桶中的上限容量

5.3 KeyResolver + RouteLocator

@Configuration
public class RouteConfig {

    // 每秒中生成的令牌数量
    @Value("${spring.cloud.gateway.redis-rate-limiter.replenishRate:30}")
    private Integer replenishRate;


    // 令牌桶中的上限容量
    @Value("${spring.cloud.gateway.redis-rate-limiter.burstCapacity:50}")
    private Integer burstCapacity;


    /**
     * 针对路径限流
     * @return
     */
    @Bean
    public KeyResolver pathResolver() {
        return exchange -> Mono.just(Objects.requireNonNull(
                exchange.getRequest().getPath().value()
        ));
    }


    /**
     * 路由配置
     * @param builder
     * @param pathResolver
     * @return
     */
    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder, KeyResolver pathResolver) {
        RouteLocatorBuilder.Builder routes = builder.routes();

        routes
                /**
                 * 配置第一个路由
                 * id 任意,这里path代表,localhost:8805/test/**,并针对 /test/** uri 进行鉴权限流等操作
                 * 之后跳转到 www.baidu.com
                 */
                .route("path_route_id", r -> r.path("/test/**")
                    .filters(f -> f
                            // 鉴权规则
                            // .filter(xxx)
                            // 限流
                            .requestRateLimiter()
                            .rateLimiter(RedisRateLimiter.class,
                                    config -> config.setReplenishRate(replenishRate).setBurstCapacity(burstCapacity))
                            .configure(config -> config.setKeyResolver(pathResolver))
                            // 熔断
                            // .hystrix(xxx)
                      // 经过鉴权、限流、熔断之后要转发的地址
                    ).uri("http://baidu.com"))


                /**
                 * 配置第二个路由
                 */
                .route("path_route_id2", r -> r.path("/test1/**")
                    .filters(f -> f
                            .requestRateLimiter()
                            .rateLimiter(RedisRateLimiter.class,
                                    config -> config.setReplenishRate(2).setBurstCapacity(5))
                            .configure(config -> config.setKeyResolver(pathResolver))

                    ).uri("http://baidu.com"))
                .build();

        return routes.build();
    }
}

6.JMeter 压测

上面两种方式压测结果一致,这里只选一种说明。

JMeter 配置如下:

 

Gateway 限流及令牌桶算法源码分析_第2张图片

 

Gateway 限流及令牌桶算法源码分析_第3张图片

上图是第一轮请求的拦截结果,由于代码中设置的令牌桶上限容量为 5,所以在代码运行一段时间后桶中的令牌达到上限,所以第一轮 10 次请求只有 5 次通过。

Gateway 限流及令牌桶算法源码分析_第4张图片

上图是第二轮请求拦截结果,由于在第一轮(1秒内)令牌消耗完毕,而 yaml 中每秒产生的令牌数量为 1,所以在第二轮(第二秒)会产生一个令牌,此时桶中只有一个令牌,因此只通过了一个请求。

你可能感兴趣的:(java)