保护系统不会在过载的情况下出现问题,我们就需要限流。

我们在一些系统中都可以看到这样的设计,比如,我们的数据库访问的连接池,还有我们的线程池,还有Nginx下的用于限制瞬时并发连接数的limit_conn模块,限制每秒平均速率的limit_req模块,还有限制MQ的生产速,等等。

限流的策略

限流的目的是通过对并发访问进行限速,相关的策略一般是,一旦达到限制的速率,那么就会触发相应的限流行为。一般来说,触发的限流行为如下。

限流的实现方式

计数器方式

最简单的限流算法就是维护一个计数器Counter,当一个请求来时,就做加一操作,当一个请求处理完后就做减一操作。如果这个Counter大于某个数了(我们设定的限流阈值),那么就开始拒绝请求以保护系统的负载了。

这个算法足够得简单粗暴。

队列算法

在这个算法下,请求的速度可以是波动的,而处理的速度则是非常均速的。这个算法其实有点像一个FIFO的算法。

在上面这个FIFO的队列上,我们可以扩展出一些别的玩法。

一个是有优先级的队列,处理时先处理高优先级的队列,然后再处理低优先级的队列。 如下图所示,只有高优先级的队列被处理完成后,才会处理低优先级的队列。

有优先级的队列可能会导致低优先级队列长时间得不到处理。为了避免低优先级的队列被饿死,一般来说是分配不同比例的处理时间到不同的队列上,于是我们有了带权重的队列。

如下图所示。有三个队列的权重分布是3:2:1,这意味着我们需要在权重为3的这个队列上处理3个请求后,再去权重为2的队列上处理2个请求,最后再去权重为1的队列上处理1个请求,如此反复。

队列流控是以队列的方式来处理请求。如果处理过慢,那么就会导致队列满,而开始触发限流。

但是,这样的算法需要用队列长度来控制流量,在配置上比较难操作。如果队列过长,导致后端服务在队列没有满时就挂掉了。一般来说,这样的模型不能做push,而是pull方式会好一些。

漏斗算法 Leaky Bucket

漏斗算法可以参看Wikipedia的相关词条 Leaky Bucket

下图是一个漏斗算法的示意图

我们可以看到,就像一个漏斗一样,进来的水量就好像访问流量一样,而出去的水量就像是我们的系统处理请求一样。当访问流量过大时这个漏斗中就会积水,如果水太多了就会溢出。

一般来说,这个“漏斗”是用一个队列来实现的,当请求过多时,队列就会开始积压请求,如果队列满了,就会开始拒绝请求。很多系统都有这样的设计,比如TCP。当请求的数量过多时,就会有一个sync backlog的队列来缓冲请求,或是TCP的滑动窗口也是用于流控的队列。

我们可以看到,漏斗算法其实就是在队列请求中加上一个限流器,来让Processor以一个均匀的速度处理请求。

令牌桶算法Token Bucket

关于令牌桶算法,主要是有一个中间人。在一个桶内按照一定的速率放入一些token,然后,处理程序要处理请求时,需要拿到token,才能处理;如果拿不到,则不处理。

下面这个图很清楚地说明了这个算法。

从理论上来说,令牌桶的算法和漏斗算法不一样的是,漏斗算法中,处理请求是以一个常量和恒定的速度处理的,而令牌桶算法则是在流量小的时候“攒钱”,流量大的时候,可以快速处理。

然而,我们可能会问,Processor的处理速度因为有队列的存在,所以其总是能以最大处理能力来处理请求,这也是我们所希望的方式。因此,令牌桶和漏斗都是受制于Processor的最大处理能力。无论令牌桶里有多少令牌,也无论队列中还有多少请求。总之,Processor在大流量来临时总是按照自己最大的处理能力来处理的。

但是,试想一下,如果我们的Processor只是一个非常简单的任务分配器,比如像Nginx这样的基本没有什么业务逻辑的网关,那么它的处理速度一定很快,不会有什么瓶颈,而其用来把请求转发给后端服务,那么在这种情况下,这两个算法就有不一样的情况了。

漏斗算法会以一个稳定的速度转发,而令牌桶算法平时流量不大时在“攒钱”,流量大时,可以一次发出队列里有的请求,而后就受到令牌桶的流控限制。

另外,令牌桶还可能做成第三方的一个服务,这样可以在分布式的系统中对全局进行流控,这也是一个很好的方式。

基于响应时间的动态限流

上面的算法有个不好的地方,就是需要设置一个确定的限流值。这就要求我们每次发布服务时都做相应的性能测试,找到系统最大的性能值。

当然,性能测试并不是很容易做的。有关性能测试的方法请参看我在CoolShell上的这篇文章《性能测试应该怎么做》。虽然性能测试比较不容易,但是还是应该要做的。

然而,在很多时候,我们却并不知道这个限流值,或是很难给出一个合适的值。其基本会有如下的一些因素:

基于上述这些原因,我们限流的值是很难被静态地设置成恒定的一个值。

我们想使用一种动态限流的方式。这种方式,不再设定一个特定的流控值,而是能够动态地感知系统的压力来自动化地限流。

这方面设计的典范是TCP协议的拥塞控制的算法。TCP使用RTT - Round Trip Time 来探测网络的延时和性能,从而设定相应的“滑动窗口”的大小,以让发送的速率和网络的性能相匹配。这个算法是非常精妙的,我们完全可以借鉴在我们的流控技术中。

我们记录下每次调用后端请求的响应时间,然后在一个时间区间内(比如,过去10秒)的请求计算一个响应时间的P90或P99值,也就是把过去10秒内的请求的响应时间排个序,然后看90%或99%的位置是多少。

这样,我们就知道有多少请求大于某个响应时间。如果这个P90或P99超过我们设定的阈值,那么我们就自动限流。

这个设计中有几个要点。

我在现在创业中的Ease Gateway的产品中实现了这个算法。

限流的设计要点

限流主要是有四个目的。

  1. 为了向用户承诺SLA。我们保证我们的系统在某个速度下的响应时间以及可用性。

  2. 同时,也可以用来阻止在多租户的情况下,某一用户把资源耗尽而让所有的用户都无法访问的问题。

  3. 为了应对突发的流量。

  4. 节约成本。我们不会为了一个不常见的尖峰来把我们的系统扩容到最大的尺寸。而是在有限的资源下能够承受比较高的流量。

在设计上,我们还要有以下的考量。

小结

好了,我们来总结一下今天分享的主要内容。

首先,限流的目的是为了保护系统不在过载的情况下导致问题。接着讲了几种限流的策略。然后讲了,限流的算法,包括计数器、队列、漏斗和令牌桶。然后讨论了如何基于响应时间来限流。最后,我总结了限流设计的要点。下篇文章中,我们讲述降级设计。希望对你有帮助。

也欢迎你分享一下你实现过怎样的限流机制?

文末给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。