前言
限流是分布式系统中不可缺少的应对突发大流量的重要手段之一,能够让系统具有更好的弹性能力。
限流场景
限流应用的场景也非常的多,许多灵感从现实生活中也都能找到,比如节假日一些游玩景点的限流,购物品对每人的购买数量的限制,吃饭排队等餐的限制等等,你会发现生活中的限流无处无在。
回到我们的系统服务中,当然也可以完成这样的事情,不过除了这些正常的访问请求之外,在互联网中你难免要遭遇一些恶意的攻击,通过限流的手段也可以有效防止一些流量攻击。
谈了这么多概念上的东西,现在让我们看看限流到底具体可以限什么?
- 1、针对接口,对某些接口做限制,比如1秒钟内最多接收100个请求。
要注意单个恶意用户频访问把所有请求资源占用的情况
-
2、针对用户,每个用户1分钟内针对某个请求只能发起3次。
-
3、针对IP,与用户差不多,都是可以用来作为请求访问的唯一标识,IP还可以做黑名单处理。
-
4、针对下载,比如资源下载时,普通用户和会员用户,针对普通用户可以限制下载的数据报文大小。
-
5、针对业务,比如短信发送、密码错误输入的次数等等。
-
6、针对服务,这种和用户、IP类似,只不过调用方是服务,无论是服务内部之间,还是对外提供的第三方接口,都应该有所保护,特别是对外提供的接口,有时候还会做按调用次数收费。
-
7、针对爬虫,服务高峰时,你肯定不希望还被爬虫访问。
总之,限流要做的事就是在系统的承受范围内,接收所有正常的请求,拦截不合理的请求,当遇到突发流量时,采取弃卒保车的手段,保护整个系统的正常运转。
限流之后
当你把用户请求拦截在大门外之后,你要如何告诉用户呢?这就是我们在限流之后要考虑的事情,一般我们会采取降级服务、直接拒绝访问、排队等待,还是用现实中的场景来举例,当你餐厅吃饭时,如果遇到饭桌满了,服务员可能会让你取号排队等待,并且为了让你等待时不那么无聊,还会提供一些小零食、小游戏之类的,这就有点排队等待、降级服务的感觉,拒绝访问就很容易理解了,你一定是之前做了什么坏事,你被拉入黑名单了,兄弟!
如果你抢过秒杀活动就一定不会陌生,每一次的点击,如能快速的返回给你系统繁忙,请稍后再试!那么请面对现实,你已经被拦在了第一道大门之外。
限流算法
说了这么多,我相信你已经对限流有了基本的认识,接下来就来了解一下具体有哪些主流的限流算法!
固定窗口法(计数器法)
固定窗口法很容易理解,简单粗暴,一次请求一次记录,并准备一个单位时间内的访问阈值,超过这个阈值就触发限流,因为是在固定窗口时间内的计数算法,所有又叫计数器法,在单机场景中很容易通过JDK的Atomic、或者Semaphore来实现,Hystrix中也有基于Semaphore实现限流的API,分布式系统中,也可以利用像Redis这样的中间件来完成。
计数器算法的好处就是足够简单,容易实现,但缺点也很明显,就是临界点问题,固定窗口的时间内如果流量不够平滑,很容易造成出现超过原定阈值的两倍请求量。
滑动窗口法(计数器法)
为了解决固定窗口算法的临界问题,于是人们就想到可以将一个大的时间窗口划分成更多的小的时间窗口,然后随着时间的前进删除时间走过的相应的小窗口,这就是滑动窗口算法。
滑动窗口法中会为每个小时间窗口都设置一个计数器,然后把设置的阈值,也就是总请求次数,平均分给每个小时间窗口,小时间窗口的计数器之和就等于大时间窗口的阈值。比如一个接口一分钟限制调用60次,1分钟就可以理解为一个窗口,可以把1分钟分割为10个单元格,每个单元格就是6秒,一个窗口内可以调用10次。
实际上滑动窗口法依赖于时间片的划分是否足够细,越细的划分粒度,限流的效果就越平滑,但对于性能的影响也越大,越粗的划分,自然控制就越不精确,当划分粒度为1时,就退化成了固定窗口法。
漏桶算法
漏桶算法如其名称一样,请求先放入一个大的漏桶中,然后再通过漏桶以固定的速率均匀的流出,不管外部的请求频率如何,到服务里面都是以固定的频率来请求。
漏桶算法虽然也比较简单,但却不能应对实际场景,因为漏桶算法的缺陷在于不能很好的处理突然暴增的流量。
令牌桶算法
正是由于漏桶算法面对突发流量时处理能力的不足,于是人们又想到了令牌桶算法,令牌桶会以一个恒定的速率向固定容量大小桶中放入令牌,当有流量来时则取走一个或多个令牌,当桶中没有令牌则将当前请求丢弃或阻塞。
令牌桶算法包含生成令牌和消费令牌两部分逻辑。
-
生成令牌:按照设定的频率固定生成令牌,并放入桶中,直到桶被放满为止,比如设定桶的容量为10,每秒生成1个令牌,那么在没有消费的情况下,10秒桶被放满。
-
消费令牌:每次请求到来时,都会先从桶中获取令牌,如果能获取到则正常接收请求,如果获取不到则触发限流逻辑。
令牌桶与漏桶算法逻辑基本上差不多,只不过一个以固定的速率处理请求,一个根据桶中是否有令牌来处理请求,只是通过这一个改变,就让令牌桶具备了处理突发流量的能力,比如桶容量为10,还是1秒生成1个,假设平时请求很少,空闲超过了10秒,那么桶中的令牌数肯定为10,突然来了10个并发的请求,那么令牌桶算法允许一次性拿出全部的10个令牌来处理请求,而如果是漏桶算法,则任然只能以固定的频率来处理(比如1秒处理1个请求)。当然,让系统拥有处理突发流量的能力虽然很好,但也要注意控制,设置合理的桶的大小,以免接入过多的突然流量把系统打垮。
常用实现框架
一般来说限流当然越前置越好,把请求拦在大门外面对服务的影响才最小,从后端处理的角度来看,一般分为网关层的限流与业务层的限流,而网关层又可以进一步分为流量网关(Nginx)和业务网关(Gateway)等。
Nginx层
Nginx层提供了一些模块,可以简单的配置即可实现基本的限流,模块采用漏桶算法实现限流。
- ngx_http_limit_req_module 限流量
- ngx_http_limit_conn_module 限连接数
除了使用模块实现,当然也可以使用一些脚本语言来完成限流,例如经典的Nginx + Lua + Redis组合
Gateway(自带使用令牌桶算法实现)
Gateway也是自带了限流的实现,不过默认只有Redis版的实现,一般而言已经足够大多数业务场景,如何有特殊需求,也可以通过继承AbstractRateLimiter完成其他的需求。
Gateway限流,使用基本的令牌桶算法,并且依赖于Redis+Lua脚本以原子方式执行。
限流方法
public Mono<Response> isAllowed(String routeId, String id) {
if (!this.initialized.get()) {
throw new IllegalStateException("RedisRateLimiter is not initialized");
}
Config routeConfig = getConfig().getOrDefault(routeId, defaultConfig);
if (routeConfig == null) {
throw new IllegalArgumentException("No Configuration found for route " + 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();
try {
List<String> keys = getKeys(id);
// The arguments to the LUA script. time() returns unixtime in seconds.
List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "",
Instant.now().getEpochSecond() + "", "1");
// allowed, tokens_left = redis.eval(SCRIPT, keys, args)
Flux<List<Long>> 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<Long>(), (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脚本
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity/rate
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)
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))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
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.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
return { allowed_num, new_tokens }
网关的限流都比较简单,基本上根据提供好的模块,通过几个简单的参数配置即可完成。
业务层 Guava RateLimiter
业务层中的限流,代表性的有Google Guava库中的的RateLimiter,也是基于令牌桶算法的实现,并且做了升级,分为:平滑突发限流(SmoothBursty) 和平滑预热限流(SmoothWarmingUp),封装的API也比较简单,读者可自行查阅。
平滑预热限流(SmoothWarmingUp),官方给的一张图形象的进行了解释,使用预热时,storedPermits可以看成是向左移动(降到零),即使令牌数量足够,也不会一下次全部发完,而是会增加一定的时间平滑发放。
平滑突发限流
一次一个令牌。
public class Test {
public static void main(String[] args){
RateLimiter limiter = RateLimiter.create(5);
//一次性消费1个令牌
System.out.println(limiter.acquire(1));
//差不多等了1秒钟
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
}
}
一次5个令牌全部拿完,之后要等1秒才能补充完成。
public class Test {
public static void main(String[] args){
RateLimiter limiter = RateLimiter.create(5);
//一次性消费5个令牌
System.out.println(limiter.acquire(5));
//差不多等了1秒钟
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
//固定速率
System.out.println(limiter.acquire(1));
}
}
平滑预热限流
1秒5个令牌,3秒的预热期
public class Test {
public static void main(String[] args) {
RateLimiter limiter = RateLimiter.create(5, 3, TimeUnit.SECONDS);
while(true){
System.out.println(limiter.acquire(1));
}
}
}
前3秒逐渐减小等待时间,直接3秒时恢复到0.2秒一个令牌。
Hystrix、Sentinel 熔断
微服务中的一些熔断的组件也都考虑了限流的场景,比如NetFlix的Hystrix中提供了基于线程池和信号量的方式实现请求接口的限制,阿里的Sentinel也参考了Guava RateLimiter的方式实现了接口限流。
总结
在庞大的系统架构中,无论是基于业务本身的需求还是安全方面的考虑,你会发现限流的思想无处不在,本文也只是尝试总结了一些在后端开发时常见的限流场景,并通过抛砖引玉的方式进行了简单的介绍,如果你对某一块有兴趣或者需要使用时,也可以进行深入研究。