背景

在如今的互联网已经作为社会基础设施的大环境下,上面的这个场景其实离我们并不是那么远,同时也会显得没那么极端。

例如,层出不穷的营销玩法,一个接着一个的社会热点,以及互联网冰山之下的黑产、刷子的蓬勃发展,更加使得这个场景变的那么的需要去考虑、去顾忌。

因为随时都有可能会涌入超出你预期的流量,然后压垮你的系统。

那么限流的作用就很显而易见了:只要系统没宕机,系统只是因为资源不够,而无法应对大量的请求,为了保证有限的系统资源能够提供最大化的服务能力,因而对系统按照预设的规则进行流量(输出或输入)限制的一种方法,确保被接收的流量不会超过系统所能承载的上限。

一、怎么做「限流」

从前面聊到的内容中我们也知道,限流最好能“限”在一个系统处理能力的上限附近,所以:

1、通过「压力测试」等方式获得系统的能力上限在哪个水平是第一步。

2、其次,就是制定干预流量的策略。比如标准该怎么定、是否只注重结果还是也要注重过程的平滑性等。

3、最后,就是处理“被干预掉”的流量。能不能直接丢弃?不能的话该如何处理?

获得系统能力的上限

第一步不是我们这次内容的重点,说起来就是对系统做一轮压测。可以在一个独立的环境进行,也可以直接在生产环境的多个节点中选择一个节点作为样本来压测,当然需要做好与其他节点的隔离。

一般我们做压测为了获得2个结果,「速率」和「并发数」。

前者表示在一个时间单位内能够处理的请求数量,比如xxx次请求/秒。后者表示系统在同一时刻能处理的最大请求数量,比如xxx次的并发。从指标上需要获得「最大值」、「平均值」或者「中位数」。后续限流策略需要设定的具体标准数值就是从这些指标中来的。

题外话:从精益求精的角度来说,其他的诸如cpu、网络带宽以及内存的耗用也可以作为参照因素。

制定干预流量的策略

常用的策略就4种,我给它起了一个简单的定义——「两窗两桶」。

两窗就是:固定窗口、滑动窗口,两桶就是:漏桶、令牌桶。

固定窗口

固定窗口就是定义一个“固定”的统计周期,比如1分钟或者30秒、10秒这样。

然后在每个周期统计当前周期中被接收到的请求数量,经过计数器累加后如果达到设定的阈值就触发「流量干预」。

直到进入下一个周期后,计数器清零,流量接收恢复正常状态。

固定窗口

这个策略最简单,写起代码来也没几行。

全局变量 int totalCount = 0;  //有一个「固定周期」会触发的定时器将数值清零。

if(totalCount > 限流阈值) {

    return; //不继续处理请求。

}

totalCount++;

// do something...

固定窗口有一点需要注意的是,假如请求的进入非常集中,那么所设定的「限流阈值」等同于你需要承受的最大并发数。

所以,如果需要顾忌到并发问题,那么这里的「固定周期」设定的要尽可能的短。因为,这样的话「限流阈值」的数值就可以相应的减小。

甚至,限流阈值就可以直接用并发数来指定。比如,假设固定周期是3秒,那么这里的阈值就可以设定为「平均并发数*3」。

不过不管怎么设定,固定窗口永远存在的缺点是:由于流量的进入往往都不是一个恒定的值,所以一旦流量进入速度有所波动,要么计数器会被提前计满,导致这个周期内剩下时间段的请求被“限制”。

要么就是计数器计不满,也就是「限流阈值」设定的过大,导致资源无法充分利用。

「滑动窗口」可以改善这个问题。

滑动窗口

滑动窗口其实就是对固定窗口做了进一步的细分,将原先的粒度切的更细,比如1分钟的固定窗口切分为60个1秒的滑动窗口。

然后统计的时间范围随着时间的推移同步后移。

滑动窗口

同时,我们还可以得出一个结论是:如果固定窗口的「固定周期」已经很小了,那么使用滑动窗口的意义也就没有了。举个例子,

现在的固定窗口周期已经是1秒了,再切分到毫秒级别能反而得不偿失,会带来巨大的性能和资源损耗。

滑动窗口大致的代码逻辑是这样:

全局数组 链表[]  counterList = new 链表[切分的滑动窗口数量];
//有一个定时器,在每一次统计时间段起点需要变化的时候就将索引0位置的元素移除,并在末端追加一个新元素。
int sum = counterList.Sum();
if(sum > 限流阈值) {
    return; //不继续处理请求。
}

int 当前索引 = 当前时间的秒数 % 切分的滑动窗口数量;
counterList[当前索引]++;
// do something...

虽然说滑动窗口可以改善这个问题,但是本质上还是预先划定时间片的方式,属于一种“预测”,意味着几乎肯定无法做到100%的物尽其用。

但是,「桶」模式可以做的更好,因为「桶」模式中多了一个缓冲区(桶本身)。

漏桶

首先聊聊「漏桶」吧。漏桶模式的核心是固定“出口”的速率,不管进来多少量,出去的速率一直是这么多。

如果涌入的量多到桶都装不下了,那么就进行「流量干预」。

漏桶

整个实现过程我们来分解一下。

  1. 控制流出的速率。这个其实可以使用前面提到的两个“窗口”的思路来实现。如果当前速率小于阈值则直接处理请求,否则不直接处理请求,进入缓冲区,并增加当前水位。

  2. 缓冲的实现可以做一个短暂的休眠或者记录到一个容器中再做异步的重试。

  3. 最后控制桶中的水位不超过最大水位。这个很简单,就是一个全局计数器,进行加加减减。

这样一来,你会发现本质就是:通过一个缓冲区将不平滑的流量“整形”成平滑的(高于均值的流量暂存下来补足到低于均值的时期),以此最大化计算处理资源的利用率。

实现代码的简化表示如下:

全局变量 int unitSpeed;  //出口当前的流出速率。每隔一个速率计算周期(比如1秒)会触发定时器将数值清零。

全局变量 int waterLevel; //当前缓冲区的水位线。

if(unitSpeed < 速率阈值) {

    unitSpeed++;
    //do something...
}else{
    if(waterLevel > 水位阈值){
        return; //不继续处理请求。
    }

    waterLevel++;
    while(unitSpeed >= 速率阈值){
        sleep(一小段时间)
    }

    unitSpeed++;
    waterLevel--;
    //do something...
}

更优秀的「漏桶」策略已经可以在流量的总量充足的情况下发挥你所预期的100%处理能力,但这还不是极致。

你应该知道,一个程序所在的运行环境中,往往不单单只有这个程序本身,会存在一些系统进程甚至是其它的用户进程。

也就是说,程序本身的处理能力是会被干扰的,是会变化的。所以,你可以预估某一个阶段内的平均值、中位数,但无法预估具体某一个时刻的程序处理能力。

又因此,你必然会使用相对悲观的标准去作为阈值,防止程序超负荷。

那么从资源利用率来说,有没有更优秀的方案呢?有,这就是「令牌桶」。

令牌桶

令牌桶模式的核心是固定“进口”速率。

先拿到令牌,再处理请求,拿不到令牌就被「流量干预」。

因此,当大量的流量进入时,只要令牌的生成速度大于等于请求被处理的速度,那么此刻的程序处理能力就是极限。

令牌桶

也来分解一下它的实现过程。

  1. 控制令牌生成的速率,并放入桶中。这个其实就是单独一个线程在不断的生成令牌。

  2. 控制桶中待领取的令牌水位不超过最大水位。这个和「漏桶」一样,就是一个全局计数器,进行加加减减。

大致的代码简化表示如下(看上去像「固定窗口」的反向逻辑):

全局变量 int tokenCount = 令牌数阈值; //可用令牌数。有一个独立的线程用固定的频率增加这个数值,但不大于「令牌数阈值」。

if(tokenCount == 0){
    return; //不继续处理请求。
}
tokenCount--;
//do something...

聪明的你可能也会想到,这样一来令牌桶的容量大小理论上就是程序需要支撑的最大并发数。

的确如此,假设同一时刻进入的流量将令牌取完,但是程序来不及处理,将会导致事故发生。

所以,没有真正完美的策略,只有合适的策略。因此,根据不同的场景能够识别什么是最合适的策略是更需要锻炼的能力。

最佳实践

四种策略该如何选择?

首先,固定窗口。一般来说,如非时间紧迫,不建议选择这个方案,太过生硬。但是,为了能快速止损眼前的问题可以作为临时应急的方案。

其次,滑动窗口。这个方案适用于对异常结果「高容忍」的场景,毕竟相比“两窗”少了一个缓冲区。但是,胜在实现简单。

然后,漏桶。个人觉得这个方案最适合作为一个通用方案。虽说资源的利用率上不是极致,但是「宽进严出」的思路在保护系统的同时还留有一些余地,使得它的适用场景更广。

最后,令牌桶。当你需要尽可能的压榨程序的性能(此时桶的最大容量必然会大于等于程序的最大并发能力),并且所处的场景流量进入波动不是很大(不至于一瞬间取完令牌,压垮后端系统)。

分布式系统中带来的新挑战

新挑战

每一个上游系统都可以理解为是其下游系统的客户端。

然后我们回想一下前面的内容,可能你发现了,前面聊的「限流」都没有提到到底是在客户端做限流还是服务端做,甚至看起来更倾向是建立在服务端的基础上做。

但是你知道,在一个分布式系统中,一个服务端本身就可能存在多个副本,并且还会提供给多个客户端调用,甚至其自身也会作为客户端角色。

那么,在如此交错复杂的一个环境中,该如何下手做限流呢?

我的思路是通过「一纵一横」来考量。

都知道「限流」是一个保护措施,那么可以将它想象成一个盾牌。另外,一个请求在系统中的处理过程是链式的。那么,正如古时候军队打仗一样,盾牌兵除了有小部分在老大周围保护,剩下的全在最前线。因为盾的位置越前,能受益的范围越大。

分布式系统中最前面的是什么?接入层。

如果你的系统有接入层,比如用nginx做的反向代理。

那么可以通过它的ngx_http_limit_conn_module以及ngx_http_limit_req_module来做限流,是很成熟的一个解决方案。

如果没有接入层,那么只能在应用层以AOP的思路去做了。

但是,由于应用是分散的,出于成本考虑你需要针对性的去做限流。比如ToC的应用必然比ToB的应用更需要做,高频的缓存系统必然比低频的报表系统更需要做,Web应用由于存在Filter的机制做起来必然比Service应用更方便。

那么应用间的限流到底是做到客户端还是服务端呢?

个人的观点是,从效果上客户端模式肯定是优于服务端模式的,因为当处于被限流状态的时候,客户端模式连建立连接的动作都省了。另一个潜在的好处是,与集中式的服务端模式相比,可以把少数的服务端程序的压力分散掉。但是在客户端做成本也更高,因为它是去中心化的,假如需要多个节点之间的数据共通的话,是一个很麻烦的事情。

所以,最终个人建议你:如果考虑成本就服务端模式,考虑效果就客户端模式。当然也不是绝对,比如一个服务端的流量大部分都来源于某一个客户端,那么就可以直接在这个客户端做限流,这也不失为一个好方案。

数据库层面的话,一般连接字符串中本身就会包含「最大连接数」的概念,就可以起到限流的作用。如果想做更精细的控制就只能做到统一封装的数据库访问层框架中了。

聊完了「纵」,那么「横」是什么呢?

不管是多个客户端,还是同一个服务端的多个副本。每个节点的性能必然会存在差异,如何设立合适的阈值?

以及如何让策略的变更尽可能快的在集群中的多个节点生效?

说起来很简单,引入一个性能监控平台和配置中心。

但这些真真要做好不容易,后续我们再展开这块内容。

拓展阅读

Bloom Filter

Cache 之旅系列

ActiveMQ

参考资料

限流