spring cloud Greenwich 学习笔记(八)spring cloud gateway 高并发限流 源码分析

文章目录

  • 概述
  • 计数器算法
  • 漏斗算法
  • 令牌桶算法
  • 限流方式
    • 应用级限流
    • 分布式限流
  • 接入层限流
  • spring cloud gateway + redis + lua实现限流
    • lua脚本实现的令牌桶算法-源码分析

springcloud系列学习笔记目录参见博主专栏 spring boot 2.X/spring cloud Greenwich。
由于是一系列文章,所以后面的文章可能会使用到前面文章的项目。文章所有代码都已上传GitHub:https://github.com/liubenlong/springcloudGreenwichDemo
本系列环境:Java11;springboot 2.1.1.RELEASE;springcloud Greenwich.RELEASE;MySQL 8.0.5;

概述

在高并发场景下,经常会遇到流量激增,超过服务可承受范围的情况。这种情况下就需要限流。限流算法很多种,常用的有漏斗算法、令牌桶算法。

计数器算法

这种算法是最简单粗暴的。假如一秒最多支撑100个请求,那么维护一个计数器(单机可以使用AtomicLong,分布式可以使用redis+lua),每收到一个请求,计数器加一。当计数器达到最大值100时,后面来的请求直接忽略。
该方案实现简单,但是存在问题就是“突刺现象”。即可能一秒内的前10毫秒就收到了100个请求,后面的时间都浪费掉了。

漏斗算法

漏桶算法思路很简单,水(请求)先进入到漏桶里,这里请求的速度可以是任意的。漏桶以一定的速度出水(匀速消费请求数据),当水流入速度过大会直接溢出(或者返回友好的提示消息),可以看出漏桶算法能强行限制数据的传输速率。并且可以允许短时间内的激增流量,削峰填谷。
spring cloud Greenwich 学习笔记(八)spring cloud gateway 高并发限流 源码分析_第1张图片

令牌桶算法

漏斗算法存在一个问题,就是消费都是匀速的,如果有突然流量,也是慢慢悠悠的执行。令牌桶算法提供了一个令牌工厂,匀速产生令牌存到令牌桶中。==每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。==令牌工厂会持续不断的生产令牌,当请求比较少时,令牌桶中会存在比较多的可用令牌。当流量激增时,这些令牌都是可用的,固可以支持一定程度的瞬时流量(前提是你的系统支持这个流量上限)。
总体来讲令牌桶算法可以保证在限制调用的平均速率的同时还允许一定程度的突发调用。
spring cloud Greenwich 学习笔记(八)spring cloud gateway 高并发限流 源码分析_第2张图片

限流方式

限流可以由很多方式,每种方式都有其应用场景及优缺点。

应用级限流

  • 可以使用atomicLong进行粗暴限流(不推荐)
  • guava的RateLimiter提供了令牌桶算法的限流。

分布式限流

  • redis + lua
  • nginx + lua

接入层限流

一般采用NGINX+lua实现

spring cloud gateway + redis + lua实现限流

本文主要介绍spring cloud gateway + redis + lua实现限流。spring cloud gateway采用的是令牌桶算法。
首先引入redis依赖,这里引入的是支持响应式编程规范的redis-reactive。有不了解的请参见笔者文章 响应式编程规范 和 spring boot 2.1学习笔记【十九】SpringBoot 2 集成响应式redis reactive。

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redis-reactiveartifactId>
dependency>

在application.yml中引入redis配置

spring:
  application:
    name: springcloud-gateway-helloworld
  redis:
    host: localhost
    port: 6379
    database: 0

接下来改造method_route,添加RequestRateLimiter

--- # Method--请求Method
spring:
  cloud:
    gateway:
      routes:
      - id: method_route
        uri: http://httpbin.org:80/
        predicates:
        - Method=GET
        filters:
        - name: RequestRateLimiter
          args:
            key-resolver: '#{@hostAddrKeyResolver}' #用于限流的键的解析器的 Bean 对象的名字。它使用 SpEL 表达式根据#{@beanName}从 Spring 容器中获取 Bean 对象
            redis-rate-limiter.replenishRate: 1 #令牌桶填充速率(其实也就是希望用户平均每秒执行多少请求。但是令牌桶优点是允许瞬间的激增请求)
            redis-rate-limiter.burstCapacity: 3 #令牌桶总容量。
  profiles: method_route

编写限流键的解析器,这里我们根据hostaddress进行限流:

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

public class HostAddrKeyResolver implements KeyResolver {
    /**
     * 指定根据什么限流
     * 这里根据HostAddress限流
     * @param exchange
     * @return
     */
    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
    }
}

将HostAddrKeyResolver 注入到spring容器中

/**
 * 注册HostAddrKeyResolver到spring中
 * @return
 */
@Bean
public HostAddrKeyResolver hostAddrKeyResolver() {
    return new HostAddrKeyResolver();
}

启动服务,访问http://127.0.0.1:8095/get,可以正常返回结果。读者可以使用jmeter或者手动通过浏览器发起请求,来观察返回结果。假如每秒请求2个,当令牌桶的令牌消耗完后,会发现有的成功,有的失败了。
同时去redis中查看key:

127.0.0.1:6379> keys *
1) "request_rate_limiter.{127.0.0.1}.timestamp"
2) "request_rate_limiter.{127.0.0.1}.tokens"

这两个值是springcloud getway通过lua脚本写入到redis的。我们来看一下lua脚本。lua脚本的位置
spring cloud Greenwich 学习笔记(八)spring cloud gateway 高并发限流 源码分析_第3张图片

lua脚本实现的令牌桶算法-源码分析

-- 这两个key是固定的,一个是token,一个是时间戳。
local tokens_key = KEYS[1]      --上面的request_rate_limiter.{127.0.0.1}.tokens
local timestamp_key = KEYS[2]  --上面的request_rate_limiter.{127.0.0.1}.timestamp

local rate = tonumber(ARGV[1])          -- 令牌桶填充速率,对应配置中的redis-rate-limiter.replenishRate
local capacity = tonumber(ARGV[2])   -- 令牌桶总容量,对应配置中的redis-rate-limiter.burstCapacity
local now = tonumber(ARGV[3])          --当前时间,单位是秒
local requested = tonumber(ARGV[4]) --写死的,值是 1

local fill_time = capacity/rate      --满负荷下 消耗完令牌需要几秒
local ttl = math.floor(fill_time*2)  --超过这个等待时间,请求丢弃

-- 剩余可用tokens
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end

--最后刷新时间,也就是最后一个请求执行时间
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end


local delta = math.max(0, now-last_refreshed)
-- 这个delta和filled_tokens 是关键,每隔一秒生成N个令牌存到令牌桶中就是通过这两个参数计算的。
-- filled_tokens :取(令牌桶总容量, 总的可用token(目前剩余的token+最后一次请求到现在应该创建的token))的小值,即执行到这一步剩余的token数量
local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) 
--这一步是关键,判断是否获取到令牌
local allowed = filled_tokens >= requested 
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then 
  --如果获取到令牌,则redis缓存中的令牌就扣除相应数量
  new_tokens = filled_tokens - requested
  allowed_num = 1
end

redis.call("setex", tokens_key, ttl, new_tokens)   -- 设置新的令牌数量
redis.call("setex", timestamp_key, ttl, now)          --设置当前请求时间

-- 返回{可用数量, 新的剩余令牌数量}
return { allowed_num, new_tokens }

上面的lua脚本实现了令牌的计数以及新令牌的生成。
每消耗一个令牌,通过new_tokens = filled_tokens - requested这一行计算剩余令牌,然后存储到redis中,时间是capacity/rate的两倍,实例中就是(3/1)*2=6秒。
新的令牌生成也是通过这个lua脚本实现的,这里做了一个优化,并不回类似于定时任务一样,每隔一秒对tokens_key加1。而是通过deltafilled_tokens计算的,通过当前时间与上次执行的时间间隔计算出来的。

  1. 假如说当前时间是100秒,已经使用了一个令牌,还剩余两个令牌,redis中存储6秒,也就是tokens_key在redis中会持续到106秒。
  2. 如果102秒又收到一个请求,那么delta = 102-100=2;filled_tokens = math.min(3, 2+(2*1)) = 3。如此一来,就又填充满了令牌桶。
  3. 如果中间时间间隔超过6秒,redis中的tokens_key和timestamp_key都会被删除,此时新请求过来,会重新计算填充令牌桶。这样在没有请求的时候不会频繁修改redis,造成资源浪费。

springcloud系列学习笔记目录参见博主专栏 spring boot 2.X/spring cloud Greenwich。
由于是一系列文章,所以后面的文章可能会使用到前面文章的项目。文章所有代码都已上传GitHub:https://github.com/liubenlong/springcloudGreenwichDemo
本系列环境:Java11;springboot 2.1.1.RELEASE;springcloud Greenwich.RELEASE;MySQL 8.0.5;

你可能感兴趣的:(spring,cloud,spring,boot,2.X/spring,cloud,Greenwich)