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个请求,后面的时间都浪费掉了。
漏桶算法思路很简单,水(请求)先进入到漏桶里,这里请求的速度可以是任意的。漏桶以一定的速度出水(匀速消费请求数据),当水流入速度过大会直接溢出(或者返回友好的提示消息),可以看出漏桶算法能强行限制数据的传输速率。并且可以允许短时间内的激增流量,削峰填谷。
漏斗算法存在一个问题,就是消费都是匀速的,如果有突然流量,也是慢慢悠悠的执行。令牌桶算法提供了一个令牌工厂,匀速产生令牌存到令牌桶中。 每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。 令牌工厂会持续不断的生产令牌,当请求比较少时,令牌桶中会存在比较多的可用令牌。当流量激增时,这些令牌都是可用的,固可以支持一定程度的瞬时流量(前提是你的系统支持这个流量上限)。
总体来讲令牌桶算法可以保证在限制调用的平均速率的同时还允许一定程度的突发调用。
限流可以由很多方式,每种方式都有其应用场景及优缺点。
一般采用NGINX+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脚本的位置
-- 这两个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。而是通过delta
和filled_tokens
计算的,通过当前时间与上次执行的时间间隔计算出来的。
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;