网关(或者说边缘网关)作为外部流量进入内部服务的第一道”关卡“,除了”路由“这个本质工作之外,往往会承载很多边缘功能,”限流“便是其中很重要的一项,本文我们来聊聊 SCG 限流模块的设计思路和内置的实现方式。
限流基本概念
限流是一个比较宽泛的概念,以下几类都是限流的范畴:
- 对访问频率的限制,这就是我们平常所说的对QPS限流
- 对连接数的限制 对传输速率的限制(比如下载速度、视频流的加载速度等)
- 对客户端名单的限制(比如用户、ip黑白名单)
如果要用一句话来描述限流的话,那就是系统在某个时间窗口对资源访问做限制,当请求达到一定的并发数或者速率时对其进行等待、排队、甚至拒绝。理论上来说,大部分ToC的系统都或多或少会采用一些限流的手段,有的自己做了简单实现,有的依赖中间件实现,有的依赖网关层的限流能力实现。
今天我们聊 SCG 的限流就是网关层面的限流,更准确的说是 SCG 网关针对 QPS 的限流,他可以是分钟级,也可以是秒级的。 SCG 网关限流有两个目的:
- 一是保护自身服务,防止因流量过大导致自己崩溃.
- 二是保护 upstream 服务,防止下游服务崩溃.
对于第一个目的,我们往往通过本地限流(Local Rate Limiter)功能来达成。本地限流比较简单,执行过程中不需要依赖第三方组件来相互同步限流数据,典型的实现方法有:
- - 基于 concurrnet 包中自带的信号量或者生产者消费者模式来实现限流器
- - 基于 Guava 提供的 RateLimiter 来实现本地限流器
- - 基于系统的前置 proxy 层设置限流,nginx 或者 envoy 都有这样的功能,只需适当配置就可以实现一个可靠的限流器
对于第二个目的,仅仅通过本地限流比较难以达到,这个时候我们需要一个全局限流器(Global Rate Limiter),一种简单可行的方案是基于 Redis 来实现。
限流算法
无论是本地限流器还是全局限流器,底层实现的算法无外乎以下几种:
1) 时间窗口算法
这是一种计数方法,就是在一个时间窗口内对请求进行计数,当达到设置的阀值时就拒绝访问。比如限流目标QPS为10,那么一秒就是一个时间窗口,限流阀值为10,当1秒内请求已经到达10个之后触发限流,简单粗暴的就是丢弃(对客户端一个限流的提示让其重试)。时间窗口算法根据窗口是否固定不变又可以细分为两种:
- 固定时间窗口
- 滑动时间窗口
固定时间窗口实现思路如下:
- 使用一个计数器记录单位时间内的访问数量
- 当访问数量没有达到阀值,就允许访问;当访问数量达到阀值则拒绝
- 当时间窗口过去后,计数器重新计数。
上图就是一个以一秒作为固定时间窗口的例子,他的实现很简单明了:0-1秒计数,1-2秒计数,以此类推。但他的缺陷也是明显的: 比如限速为10,0-0.5秒没有请求,0.5-1秒来了10个请求,1.0-1.5秒又来了10个请求,那么相当于0.5-1.5秒这一秒处理了20个请求。这就是临界值问题导致限流失败。 再比如,0-1秒来了20个请求,那么其中先到的10个属于正常请求,后面的10个请求会被限流(直接丢弃),1-2秒没有请求进来,系统空闲。而实际上我们很容易认为1-2秒可以处理0-1秒被限制的请求。
滑动窗口是固定窗口的优化,他能解决临界值导致限流失败的问题,以及解决一部分流量平滑性问题,其优化思路:将一个时间窗口在分为n份,每一份里都有独立的计数器c,每当请求进来,就会求n个计数器的和,当达到阀值就触发限流,没达到则当前小格子计数器加1. 下图是以1秒作为滑动窗口,每个窗口又细分为5等分的示意图
当时间窗口滑动一小格之后,c1 跑到黄色格子里。很容易发现,格子划分的越小,这个滑动窗口越平滑,请求越均匀。时间窗口算法简答做法是借助一个信号量来实现,下面是一个固定时间窗口的示例代码。
public class TimeWindowLocalRateLimiter {
private int qps;
private AtomicInteger val;
private AtomicBoolean running = new AtomicBoolean(false);
private final Executor executor = Executors.newSingleThreadExecutor();
@PostConstruct
public void init(){
if( qps <= 0 ){
throw new IllegalArgumentException("limitValue in rate limiter could not be negative");
}
val = new AtomicInteger(qps);
startRefreshTask();
}
public boolean isAllow(){
int x = val.decrementAndGet();
return x >= 0;
}
private void startRefreshTask(){
executor.execute(() -> {
while(running.get()){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
log.warn("interrupted");
}
val.set(qps);
}
});
}
}
2) 漏桶算法
漏桶算法是说有一个漏桶,水匀速地从底部漏出,同时有个水笼头随机的注水进来。水龙头就等于是系统收到客户端请求,所有请求进来都进这个漏桶里,桶满了就溢出(也就是丢弃请求),桶底匀速漏出的水就等于系统平滑的处理请求。无论请求量有多大,系统处理请求是恒定的。下面是网上比较形象的示意图:
很明显较时间窗口算法,漏桶算法可以解决请求处理的平滑性,但也存在一个问题,比如请求延迟问题,突发流量处理缺陷。下面是java的一个实现示例:
public class LeakBucketRateLimiter {
private static final String QUEUE_ITEM = "x";
private final double leakRate;
private long lastLeakTime;
private final ArrayBlockingQueue<String> queue;
private final Executor executor = Executors.newSingleThreadExecutor();
private final Lock lock = new ReentrantLock();
private volatile boolean leaking = true;
public LeakBucketRateLimiter(int capacity, double leakRate) {
this.leakRate = leakRate;
this.lastLeakTime = System.currentTimeMillis();
queue = new ArrayBlockingQueue<>(capacity);
startLeakTask();
}
/**
* 表示一个请求进来进行入桶操作,桶满则被限流
*
* @return
*/
public boolean isAllow() {
leakWater();
return queue.offer(QUEUE_ITEM);
}
private void startLeakTask() {
executor.execute(this::leakWater);
}
private void leakWater() {
while (leaking) {
if (lock.tryLock()) {
try {
long now = System.currentTimeMillis();
int leakNum = (int) ((now - lastLeakTime) * leakRate);
for (int i = 0; i < leakNum; i++) {
if (queue.poll() == null) {
break;
}
}
lastLeakTime = now;
} finally {
lock.unlock();
}
}
sleepOneSecond();
}
}
}
3) 令牌算法
令牌桶算法应该是目前应用最广泛的限流算法。首先有一个固定容量的令牌桶,令牌生成器匀速发放令牌注入桶中,桶满了就暂停发放令牌。当请求过来时,会首先去令牌桶获取一个令牌,获取成功则请求被处理,获取不到则拒绝服务。令牌生成器解决日常限流,桶的容量设置可以应对一定的突发流量。 下图是网上比较形象的令牌桶算法示意图
令牌桶算法 令牌桶算法也可以借助Queue来实现,下面是一个简单的java实现示例
public class TokenBucketRateLimiter {
private static final String QUEUE_ITEM = "x";
private final ArrayBlockingQueue<String> queue;
private final int generateTokenNumEverySecond;
private final Executor executor = Executors.newSingleThreadExecutor();
private volatile boolean running = true;
public TokenBucketRateLimiter(int limit, int periodInSecond, int generateTokenNumEverySecond) {
this.queue = new ArrayBlockingQueue<>(limit);
this.generateTokenNumEverySecond = generateTokenNumEverySecond;
}
@PostConstruct
public void init(){
//初始状态加满队列,表示当前可以处理这么多请求
for(;;){
if(!queue.offer(QUEUE_ITEM)){
break;
}
}
executor.execute(this::generateToken);
}
public boolean isAllow(){
return queue.poll() != null;
}
private void generateToken(){
while(running){
for(int i = 0; i < generateTokenNumEverySecond; i++){
if(!queue.offer(QUEUE_ITEM)){
break;
}
}
sleepOneSecond();
}
}
}
**当然,以上仅仅是代码示例,帮助我们理解对应的算法,离实际生产还差的很远。**一般对于local限流,有兴趣可以研究一下 Guava com.google.common.util.concurrent.RateLimiter的实现。或者可以研究一下Bucket4j,这是一个基于令牌桶算法实现的强大的限流库,它不仅支持单机限流,还支持通过诸如 Hazelcast、Ignite、Coherence、Infinispan 或其他兼容 JCache API (JSR 107) 规范的分布式缓存实现分布式限流 科普完限流的先关概念之后,我们来看一下 Spring Cloud Gateway 对限流模块的设计。
SCG中的限流设计
SCG 提供了一个叫做 RequestRateLimiterGatewayFilterFactory 的 Filter,其功能就是做限流用的。该 Filter 内部抽象了两个接口
- RateLimiter: 该接口有一个核心限流方法
Mono<boolean> isAllow(String routeId, String id)
看的出来他是针对当前执行的routeId这个路由的, 而 id 参数标识了进行限流的一个 key,毕竟网关限流要考虑到upstream的多重需求,不仅仅是对网关服务本身的限流。
- KeyResolver:用来提供限流 key 的一个接口
显然 RateLimiter 中核心限流方法中的 id 参数其实是由 KeyResolver 提供的 key
另外,SCG 有一个内置基于 Redis 的限流实现: RedisRateLimiter。该类是基于 Redis 实现的一个”令牌“算法,从对应 Config 参数就能看得出来:
burstCapacity:令牌桶的容量大小 replenishRate:每秒钟生产的令牌数,也就是填充令牌桶的速度 requestedTokens:每一个请求需要获取的令牌数,一般是1
算法的逻辑是封装在 Lua 脚本里,主要是利用 Lua 的原子性,避免并发访问时可能出现请求量超出上限的现象。所以,整体的实现类图如下:
基于 SCG 的这个设计,我们一方面可以很方便的使用限流功能,请看下面例子:
-
- clone代码分支:gitee.com/wlscode/scg…
-
- 启动 MockserverApplication,我们有一个upstream api: http://localhost:8080/r1/hello
-
-
配置 GatewayServiceApplication(带上参数 -Dspring.profiles.active=ratelimit),我们有如下配置:
- id: rt-test
uri: http://localhost:8080
predicates:
- Path=/v1/{apiCode}/** filters:
- StripPrefix=2
- name: RequestRateLimiter args: key-resolver: '#{@pathKeyResolver}' rateLimiter: '#{@redisRateLimiter}' redis-rate-limiter.replenishRate: 1 #令牌每秒填充速度 redis-rate-limiter.burstCapacity: 1 #桶大小 redis-rate-limiter.requestedTokens: 1 #默认是1,每次请求消耗的令牌数
-
它代表了QPS=1的限流,并且桶的大小也是1,并不处理突发流量的情况。
-
- 运行 RateLimitTest1,它会以每秒20个请求的速度请求三次
运行日志里会有很多 Response 429 TOO_MANY_REQUESTS,表示被限流了,而每秒钟成功返回的只有1个
最后,基于这个设计,我们还是非常容易扩展,比如我们想换一个 RateLimiter 的实现,我们可以自己实现一个 XxxRateLimiter,其内部可以基于上文所说的 Guava 或者 Bucket4j。然后修改配置:
rateLimiter: '#{@你实现的RateLimiter bean 名字}'key-resolver: '#{@你的keyResolver bean 名字}'
具体如何扩展且看下一篇细聊