在软件架构领域,“限流”与“熔断”是两个经常会被同时提及的概念,它们都是系统高可用不可缺少的重要武器。
 
熔断是指在一个系统中,如果服务出现了过载现象,为了防止造成整个系统故障而切断服务的机制。它是一种十分有用的过载保护机制,一般会有下边这几种状态:
 

 
我们来考虑一个稍微极端一点的场景:如果系统流量不是很稳定,并且流量高峰时都会触发熔断,那么频繁的流量变化就意味着系统将一直在熔断的三种状态中不断切换。
 
这导致的结果是每次从开启熔断到关闭熔断的期间,大量用户将无法正常使用系统服务。这种情况下系统层面的可用性大致是这样的:
 

 
另外,资源利用率也很低,上图波谷的时间段资源都是未充分利用的。
 
由此可见,光有熔断是远远不够的。所以还需要限流机制。
 
限流
 
限流是对系统按照预设的规则进行流量限制的一种机制,它确保接收的流量不会超过系统所能承载的上限,以保证系统的可用性。与熔断不同,限流并不切断服务,因此服务会一直可用。
 
怎么做限流?
 
限制流量要限在哪个值好呢?
 
系统如果能将接收的流量持续保持在高位,但又不超过系统所能承载的上限,会是更有效率的运作模式,因为这会将前边提到的波谷填满。
 

 
也就是说限流最好能限在一个系统处理能力的上限附近,所以关于怎么做限流,第一步就是:通过压力测试等方式获得系统的能力上限在哪个水平。
 
除了获得这个限流的值,更主要的一步是具体怎么去限制这些流量,也就是制定限流策略,比如标准该怎么定、是只注重结果还是也要注重过程的平滑性等。
 
最后还需要考虑如何处理那些被限制了的流量,这些流量能不能直接丢弃?不能的话该如何处理?
 
获得系统能力上限、处理被限制流量
 
获得系统能力上限,简单地讲就是对系统做一轮压测。可以在一个独立的环境进行,也可以直接在生产环境的多个节点中选择一个节点作为样本来压测,当然需要做好与其它节点的隔离。
 
一般我们做压测是为了获得 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% 物尽其用。
 

 
桶模式可以做得更好,因为它多了一个缓冲区(桶本身)。 
 
漏桶
 
漏桶模式的核心是固定“出口”的速率,不管进来多少量,出去的速率一直是这么多。如果涌入的量多到桶都装不下了,那么就进行流量干预。
 

 
整个实现过程我们来分解一下:
 
 
 - 控制流出的速率。这个其实可以使用前面提到的两个窗口思路来实现,如果当前速率小于阈值则直接处理请求,否则不直接处理请求,进入缓冲区,并增加当前水位。
- 缓冲的实现可以做一个短暂的休眠或者记录到一个容器中再做异步的重试。
- 最后控制桶中的水位不超过最大水位。这个很简单,就是一个全局计数器,进行加加减减。
可以发现这其中的本质就是:通过一个缓冲区将高于均值的流量暂存下来补足到低于均值的时期,将不平滑的流量“整形”成平滑的,以此最大化计算处理资源的利用率。
 

 
实现代码的简化表示如下:
 
全局变量 int unitSpeed;  //出口当前的流出速率。每隔一个速率计算周期(比如1秒)会触发定时器将数值清零。
全局变量 int waterLevel; //当前缓冲区的水位线。
 
if(unitSpeed < 速率阈值) {
    unitSpeed++;
    
    //do something...
}
else{
    if(waterLevel > 水位阈值){
        return; //不继续处理请求。
    }
    
    waterLevel++;
    
    while(unitSpeed >= 速率阈值){
        sleep(一小段时间)。
    }
    unitSpeed++;
    waterLevel--;
        
    //do something...
} 
这种更优秀的漏桶策略已经可以在流量总量充足的情况下发挥你预期的 100% 处理能力,但这还不是极致。
 
因为一个程序所在的运行环境中,往往不单单只有这个程序本身,还会存在一些系统进程甚至是其它的用户进程。也就是说,程序本身的处理能力是会被干扰的,是会变化的。所以,你可以预估某一个阶段内的平均值、中位数,但无法预估具体某一个时刻的程序处理能力。因此,你必然会使用相对悲观的标准去作为阈值,防止程序超负荷,这就使得资源的利用率不会达到极致。
 

 
那么从资源利用率的角度来说,有没有更优秀的方案呢?有,这就是令牌桶。
 
令牌桶
 
令牌桶模式的核心是固定“进口”速率。先拿到令牌,再处理请求,拿不到令牌就被流量干预。因此,当大量的流量进入时,只要令牌的生成速度大于等于请求被处理的速度,那么此刻的程序处理能力就是极限。
 

 
也来分解一下它的实现过程:
 
 
 - 控制令牌生成的速率,并放入桶中。这个其实就是单独一个线程在不断地生成令牌。
- 控制桶中待领取的令牌水位不超过最大水位。这个和漏桶一样,就是一个全局计数器,进行加加减减。 
大致的代码简化表示如下(看上去像固定窗口的反向逻辑):
 
全局变量 int tokenCount = 令牌数阈值; //可用令牌数。有一个独立的线程用固定的频率增加这个数值,但不大于「令牌数阈值」。
 
if(tokenCount == 0){
    return; //不继续处理请求。
}
 
tokenCount--;
 
//do something... 
但是这样一来令牌桶的容量大小理论上就是程序需要支撑的最大并发数。的确如此,假设同一时刻进入的流量将令牌取完,但是程序来不及处理,将会导致事故发生。
 
所以,没有真正完美的策略,只有合适的策略。因此,根据不同的场景选择最适合的策略才是更重要的。下面分享一些我选择这四种策略的经验。
 
做限流的最佳实践
 
固定窗口
 
一般来说,如非时间紧迫,不建议选择这个方案,它太过生硬。但是,为了能快速解决眼前的问题,那么它可以作为临时应急的方案。
 
滑动窗口
 
这个方案适用于对异常结果高容忍的场景,毕竟相比“两窗”少了一个缓冲区。但是,它胜在实现简单。
 
漏桶
 
我觉得这个方案最适合作为一个通用方案。虽说资源的利用率并不极致,但是宽进严出的思路在保护系统的同时还留有一些余地,使得它的适用场景更广。
 
令牌桶
 
当你需要尽可能地压榨程序的性能(此时桶的最大容量必然会大于等于程序的最大并发能力),并且所处的场景流量进入波动不是很大时(不至于一瞬间取完令牌,压垮后端系统),可以使用这个策略。 
 
分布式系统中带来的新挑战
 
一个成熟的分布式系统大致是这样的:
 

 
每一个上游系统都可以理解为是其下游系统的客户端。然后我们回想一下前面的内容,可能你发现了,前面聊的限流都没有提到到底是在客户端做限流还是服务端做,甚至看起来更倾向是建立在服务端的基础上做。但是在一个分布式系统中,一个服务端本身就可能存在多个副本,并且还会提供给多个客户端调用,甚至其自身也会作为客户端角色。那么,在如此复杂的环境中,该如何下手做限流呢?我的思路是通过“一纵一横”来考量。 
 
纵
 
都知道限流是一个保护措施,那么可以将它想象成一个盾牌。另外,一个请求在系统中的处理过程是链式的。那么,正如古时候军队打仗一样,盾牌兵除了有小部分在老大周围保护,剩下的全在最前线。因为盾的位置越前,能受益的范围越大。
 
分布式系统中最前面的是什么?接入层。如果你的系统有接入层,比如用 nginx 做的反向代理,那么可以通过它的 ngx_http_limit_conn_module 以及 ngx_http_limit_req_module 来做限流,这是很成熟的一个解决方案。
 
如果没有接入层,那么只能在应用层以 AOP 的思路去做了。但是,由于应用是分散的,出于成本考虑你需要针对性地去做限流。比如 To C 的应用必然比 To B 的应用更需要做,高频的缓存系统必然比低频的报表系统更需要做,Web 应用由于存在 Filter 的机制做起来必然比 Service 应用更方便。
 
那么应用间的限流到底是做到客户端还是服务端呢?
 
我的观点是,从效果上看客户端模式肯定是优于服务端模式的,因为当处于被限流状态的时候,客户端模式连建立连接的动作都省了。另一个潜在的好处是,与集中式的服务端模式相比,可以把少数的服务端程序的压力分散掉。但是在客户端做成本也更高,因为它是去中心化的,假如需要多个节点之间的数据共通的话,会是一个很麻烦的事情。
 
所以,我建议:如果考虑成本就选择服务端模式,考虑效果就选择客户端模式。当然也不是绝对,比如一个服务端的流量大部分都来源于某一个客户端,那么就可以直接在这个客户端做限流,这也不失为一个好方案。
 
数据库层面的话,一般连接字符串中本身就会包含最大连接数的概念,就已经可以起到限流作用了。如果想做更精细的控制就只能做到统一封装的数据库访问层框架中了。
 
聊完了纵,那么横是什么呢?
 
横
 
不管是多个客户端,还是同一个服务端的多个副本,每个节点的性能必然会存在差异,如何设立合适的阈值?以及如何让策略的变更尽可能快的在集群中的多个节点生效?说起来很简单,引入一个性能监控平台和配置中心。但这些真真要做好并不容易,本文暂时不展开。
 
作者介绍
 
张帆(Zachary),7 年电商行业经验,5 年开发团队管理经验,4 年互联网架构经验。专注大型系统架构、分布式系统。
 
本文系作者投稿文章。欢迎投稿。
 
投稿内容要求
 
 
 - 互联网技术相关,包括但不限于开发语言、网络、数据库、架构、运维、前端、DevOps(DevXXX)、AI、区块链、存储、移动、安全、技术团队管理等内容。
- 文章不需要首发,可以是已经在开源中国博客或网上其它平台发布过的。但是鼓励首发,首发内容被收录可能性较大。
- 如果你是记录某一次解决了某一个问题(这在博客中占绝大比例),那么需要将问题的前因后果描述清楚,最直接的就是结合图文等方式将问题复现,同时完整地说明解决思路与最终成功的方案。
- 如果你是分析某一技术理论知识,请从定义、应用场景、实际案例、关键技术细节、观点等方面,对其进行较为全面地介绍。
- 如果你是以实际案例分享自己或者公司对诸如某一架构模型、通用技术、编程语言、运维工具的实践,那么请将事件相关背景、具体技术细节、演进过程、思考、应用效果等方面描述清楚。
- 其它未尽 case 具体情况具体分析,不虚的,文章投过来试试先,比如我们并不拒绝就某个热点事件对其进行的报导、深入解析。
投稿方式
 
 
重要说明
 
 
 - 作者需要拥有所投文章的所有权,不能将别人的文章拿过来投递。
- 投递的文章需要经过审核,如果开源中国编辑觉得需要的话,将与作者一起进一步完善文章,意在使文章更佳、传播更广。
- 文章版权归作者所有,开源中国获得文章的传播权,可在开源中国各个平台进行文章传播,同时保留文章原始出处和作者信息,可在官方博客中标原创标签。