为什么需要限流?
由于互联网公司的流量巨大,系统上线会做一个流量峰值的评估,尤其是像各种秒杀促销活动,为了保证系统不被巨大的流量压垮,会在系统流量到达一定阈值时,拒绝掉一部分流量。
限流会导致用户在短时间内(这个时间段是毫秒级的)系统不可用,一般我们衡量系统处理能力的指标是每秒的QPS或者TPS,假设系统每秒的流量阈值是1000,理论上一秒内有第1001个请求进来时,那么这个请求就会被限流。
比如web服务、对外API,这种类型的服务有以下几种可能导致机器被拖垮:
- 用户增长过快
- 因为某个热点事件(秒杀、微博热搜)
- 竞争对象爬虫
- 恶意的刷单
无法预知的突发流量,因此当遇到瞬时请求量激增时,如果不加以控制,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。
限流(Ratelimiting)指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。
限流可以应对:
- 热点业务带来的突发请求;
- 调用方bug导致的突发请求;
- 恶意攻击请求。
因此,对于公开的接口最好采取限流措施。
限流的算法
- 计数器(固定窗口)
- 滑动窗口
- 漏桶
- 令牌桶
计数器(固定窗口)
计数器算法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter,具体算法的示意图如下:
具体的伪代码如下:
public static long timeStamp = System.currentTimeMillis();
public static int reqCount = 0;
// 时间窗口内最大请求数
public static final int limit = 10;
// 时间窗口ms
public static final long interval = 1000;
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
boolean isTrigger = grant();
System.out.println(isTrigger);
}
}
public static boolean grant() {
long now = System.currentTimeMillis();
if (now < timeStamp + interval) {
// 在时间窗口内
reqCount++;
// 判断当前时间窗口内是否超过最大请求控制数
return reqCount <= limit;
} else {
timeStamp = now;
// 超时后重置
reqCount = 1;
return true;
}
}
这个算法虽然简单,但是有一个十分致命的问题,那就是临界问题,我们看下图:
从上图中我们可以看到,假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,这种情况,其实是符合我们上述规则的。因为在0:00-0:59这个区间用户确实没有超过我们设置的100这个最大范围,1:00-1:59这个区间也是一样。
但是,其实这个用户在 0:59-1:00这1秒里,瞬间发送了200个请求,这种情况使用固定窗口的计数器就很明显不符合我们的初衷。
那么如何解决这个问题呢?
滑动窗口算法(rolling window)
为了解决这个问题,我们引入了滑动窗口算法。如果学过TCP网络协议的话,那么一定对滑动窗口这个名词不会陌生。下面这张图,很好地解释了滑动窗口算法:
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口 划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。
那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格 子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时这个
时间窗口内
的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触 发了限流。
我再来回顾一下刚才的计数器算法,我们可以发现,计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。
由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
总结
滑动窗口算法是计数器算法的一种改进,将原来的一个时间窗口划分成多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。 在临界位置的突发请求都会被算到时间窗口内,因此可以解决计数器算法的临界问题。
滑动窗口与固定窗口的关系
滑动窗口只有一个窗口就是固定窗口
漏桶算法(Leaky Bucket)
漏桶算法由流量容器、流量入口和出口组成。其中流量出口流速即为我们期望的限速值,比如 100 QPS。漏桶算法除了具备限流能力,还具备流量整型功能。下面我们通过一张图来了解漏桶算法。
如上图,流入漏桶流量的流速是不恒定的,经过漏桶限速后,流出流量的速度是恒定的。需要说明的是,漏桶的容量是有限的,一旦流入流量超出漏桶容量,这部分流量只能被丢弃了。
缺点
如图所示,不管流量多大,超出的部分都会直接丢弃,即使服务器还有大量空闲资源也是直接丢弃,无法处理突发流量。那么如何解决这个问题呢?下面介绍令牌桶算法。
漏桶算法的缺陷也很明显,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。
令牌桶算法(Token Bucket)
它的运行过程是这样的,一个令牌工厂按照设定值定期向令牌桶发放令牌。当令牌桶满了后,多出的令牌会被丢弃掉。每当一个请求到来时,该请求对应的线程会从令牌桶中取令牌。如果遇到突发情况,初期由于令牌桶中存放了很多个令牌,因此允许多个请求同时取令牌。当桶中没有令牌后,无法获取到令牌的请求可以丢弃,或者重试。下面我们来看一下的令牌桶示意图:
令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法。
漏桶算法和令牌桶算法比较
漏桶算法 | 令牌桶算法 | |
---|---|---|
请求何时拒绝 | 流入请求速率任意,常量固定速率流出请求。当流入请求数积累到漏桶容量时,则拒绝新请求 | 固定速率往桶中添加令牌,如果桶中令牌不够,则拒绝新请求 |
速率限制 |
限制常量流出速率(流出速率是固定值),从而 平滑突发流入速率 |
限制平均流入速率, 允许一定程度的突发请求 (支持一次拿多个令牌) |
一句话总结两者关系
漏桶取的速度是固定的,令牌桶放的速度是固定的。
参考
https://www.infoq.cn/article/qg2tx8fyw5vt-f3hh673
https://gongfukangee.github.io/2019/04/04/Limit/
https://www.cnblogs.com/linjiqin/p/9707713.html