在软件架构领域,“限流”与“熔断”是两个常常会被同时说起的概念,它们都是系统高可用不可缺乏的重要武器。前端
熔断是指在一个系统中,若是服务出现了过载现象,为了防止形成整个系统故障而切断服务的机制。它是一种十分有用的过载保护机制,通常会有下边这几种状态:nginx
咱们来考虑一个稍微极端一点的场景:若是系统流量不是很稳定,而且流量高峰时都会触发熔断,那么频繁的流量变化就意味着系统将一直在熔断的三种状态中不断切换。数据库
这致使的结果是每次从开启熔断到关闭熔断的期间,大量用户将没法正常使用系统服务。这种状况下系统层面的可用性大体是这样的:编程
另外,资源利用率也很低,上图波谷的时间段资源都是未充分利用的。后端
因而可知,光有熔断是远远不够的。因此还须要限流机制。数组
限流是对系统按照预设的规则进行流量限制的一种机制,它确保接收的流量不会超过系统所能承载的上限,以保证系统的可用性。与熔断不一样,限流并不切断服务,所以服务会一直可用。缓存
限制流量要限在哪一个值好呢?安全
系统若是能将接收的流量持续保持在高位,但又不超过系统所能承载的上限,会是更有效率的运做模式,由于这会将前边提到的波谷填满。网络
也就是说限流最好能限在一个系统处理能力的上限附近,因此关于怎么作限流,第一步就是:经过压力测试等方式得到系统的能力上限在哪一个水平。架构
除了得到这个限流的值,更主要的一步是具体怎么去限制这些流量,也就是制定限流策略,好比标准该怎么定、是只注重结果仍是也要注重过程的平滑性等。
最后还须要考虑如何处理那些被限制了的流量,这些流量能不能直接丢弃?不能的话该如何处理?
得到系统能力上限,简单地讲就是对系统作一轮压测。能够在一个独立的环境进行,也能够直接在生产环境的多个节点中选择一个节点做为样原本压测,固然须要作好与其它节点的隔离。
通常咱们作压测是为了得到 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 年互联网架构经验。专一大型系统架构、分布式系统。
本文系做者投稿文章。欢迎投稿。