1、大量正常用户高频访问导致服务器宕机
2、恶意用户高频访问导致服务器宕机
3、网页爬虫 ,对于这些情况我们需要对用户的访问进行限流访问
限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
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 也非常方便。
客户端访问 Gateway 网关,Gateway 中 Handler Mapping 对请求 URL 进行处理,如果网关处理程序映射确定请求与路由匹配,则处理完之后交给 web Handler,web handler 会被 filter 进行过滤。Filter 中分成两部分,以在代理请求发送之前和之后运行逻辑。其中前半部分代码是处理请求的代码,处理完成后调用真实被代理的服务,被代理的服务响应结果,结果会被 filter 中后半部分代码进行操作,操作完成后把结果返回给 web handler,再返回给 handler mapping,最终响应给客户端。
设置一个计数器 counter,每当一个请求过来的时候,counter+1,如果 counter 的值大于阈值且当前请求与第一个请求的间隔时间还在限定的时间之内,那么说明请求数过多;如果该请求与第一个
请求的间隔时间大于限定时间,且 counter 的值还在限流范围内,那么就重置 counter。
缺点:精度太低,无法处理临界问题。以 QPS = 100 举例,假设从第一个请求开始计时,每一个请求让计数器加一,当到达 100 以后,其他的请求都拒绝,如果一秒内前 200ms 请求数量到达了100,那么后面 800ms 中的所有请求都被拒绝了,导致出现“突刺现象”。
将窗口更加细分,每个窗口都有自己的计数器,当总计算达到限定时,限流。这个滑动窗口只是将计算法变得更平滑而已。
缺点:治标不治本(仍会有突刺)。
假设时间周期为1min,将1min再分为2个小周期,统计每个小周期的访问数量,则可以看到,第一个时间周期内,访问数量为75,第二个时间周期内,访问数量为100,超过100的访问则被限流掉了。
将容器比作一个漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。(利用队列实现)
缺点:无法应对短时间的突发流量。因为流出的是固定频率,其他流量只是放在桶中。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的
令牌、或者直接拒绝。
令牌桶算法底层实现依赖于 RedisRateLimiter 类。
@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)));
}
令牌桶算法都在 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 }
(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());
}
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 # 令牌桶中的上限容量
@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();
}
}
上面两种方式压测结果一致,这里只选一种说明。
JMeter 配置如下:
上图是第一轮请求的拦截结果,由于代码中设置的令牌桶上限容量为 5,所以在代码运行一段时间后桶中的令牌达到上限,所以第一轮 10 次请求只有 5 次通过。
上图是第二轮请求拦截结果,由于在第一轮(1秒内)令牌消耗完毕,而 yaml 中每秒产生的令牌数量为 1,所以在第二轮(第二秒)会产生一个令牌,此时桶中只有一个令牌,因此只通过了一个请求。