高并发大流量系统之限流技术[转载]
在如今的互联网已经作为社会基础设施的大环境下,层出不穷的营销玩法,一个接着一个的社会热点,以及互联网冰山之下的黑产、刷子的蓬勃发展,更加使得这个场景变的那么的需要去考虑、去顾忌。因为随时都有可能会涌入超出你预期的流量,然后压垮你的系统。
那么限流的作用就很显而易见了:只要系统没宕机,系统只是因为资源不够,而无法应对大量的请求,为了保证有限的系统资源能够提供最大化的服务能力,因而对系统按照预设的规则进行流量(输出或输入)限制的一种方法,确保被接收的流量不会超过系统所能承载的上限。
## 概述:
在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。缓存的目的是提升系统访问速度和增大系统能处理的容量,可谓是抗高并发流量的银弹;而降级是当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉,待高峰或者问题解决后再打开;而有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页),因此需有一种手段来限制这些场景的并发/请求量,即限流。
限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务(定向到错误页或告知资源没有了)、排队或等待(比如秒杀、评论、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。
一般开发高并发系统常见的限流有:限制总并发数(比如数据库连接池、线程池)、限制瞬时并发数(如nginx的limit_conn模块,用来限制瞬时并发连接数)、限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率);其他还有如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
先有缓存这个银弹,后有限流来应对618、双十一高并发流量,在处理高并发问题上可以说是如虎添翼,不用担心瞬间流量导致系统挂掉或雪崩,最终做到有损服务而不是不服务;限流需要评估好,不可乱用,否则会正常流量出现一些奇怪的问题而导致用户抱怨。
在实际应用时也不要太纠结算法问题,因为一些限流算法实现是一样的只是描述不一样;具体使用哪种限流技术还是要根据实际场景来选择,不要一味去找最佳模式,白猫黑猫能解决问题的就是好猫。
因在实际工作中遇到过许多人来问如何进行限流,因此本文会详细介绍各种限流手段。那么接下来我们从限流算法、应用级限流、分布式限流、接入层限流来详细学习下限流技术手段。
限流算法:
常见的限流算法有:令牌桶、漏桶。计数器也可以进行粗暴限流实现。
令牌桶算法:
令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:
1.假设限制2r/s,则按照500毫秒的固定速率往桶中添加令牌;
2.桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝;
3.当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上;
4.如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。
漏桶算法:
漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:
1.一个固定容量的漏桶,按照常量固定速率流出水滴;
2.如果桶是空的,则不需流出水滴;
3.可以以任意速率流入水滴到漏桶;
4.如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
令牌桶和漏桶对比:
1.令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;
2.漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
3.令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;
4.漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
5.令牌桶允许一定程度的突发,而漏桶主要目的是平滑流入速率;
6.两个算法实现可以一样,但是方向是相反的,对于相同的参数得到的限流效果是一样的。
另外有时候我们还使用计数器来进行限流,主要用来限制总并发数,比如数据库连接池、线程池、秒杀的并发数;只要全局总请求数或者一定时间段的总请求数设定的阀值则进行限流,是简单粗暴的总数量限流,而不是平均速率限流。
到此基本的算法就介绍完了,接下来我们首先看看应用级限流。
应用级限流:
如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要限制应用;可以使用池化技术来限制总资源数:连接池、线程池。比如分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出了可以等待或者抛异常。
限流某个接口的总并发/请求数:
如果接口可能会有突发访问情况,但又担心访问量太大造成崩溃,如抢购业务;这个时候就需要限制这个接口的总并发/请求数总请求数了;因为粒度比较细,可以为每个接口都设置相应的阀值。
适合对业务无损的服务或者需要过载保护的服务进行限流,如抢购业务,超出了大小要么让用户排队,要么告诉用户没货了,对用户来说是可以接受的。而一些开放平台也会限制用户调用某个接口的试用请求量,也可以用这种计数器方式实现。这种方式也是简单粗暴的限流,没有平滑处理,需要根据实际情况选择使用;
限流某个接口的时间窗请求数:
即一个时间窗口内的请求数,如想限制某个接口/服务每秒/每分钟/每天的请求数/调用量。如一些基础服务会被很多其他系统调用,比如商品详情页服务会调用基础商品服务调用,但是怕因为更新量比较大将基础服务打挂,这时我们要对每秒/每分钟的调用量进行限速;
一、怎么做「限流」
从前面聊到的内容中我们也知道,限流最好能“限”在一个系统处理能力的上限附近,所以:
通过「压力测试」等方式获得系统的能力上限在哪个水平是第一步。
其次,就是制定干预流量的策略。比如标准该怎么定、是否只注重结果还是也要注重过程的平滑性等。
最后,就是处理“被干预掉”的流量。能不能直接丢弃?不能的话该如何处理?
获得系统能力的上限
第一步不是我们这次内容的重点,说起来就是对系统做一轮压测。可以在一个独立的环境进行,也可以直接在生产环境的多个节点中选择一个节点作为样本来压测,当然需要做好与其他节点的隔离。
一般我们做压测为了获得2个结果,「速率」和「并发数」。前者表示在一个时间单位内能够处理的请求数量,比如xxx次请求/秒。后者表示系统在同一时刻能处理的最大请求数量,比如xxx次的并发。从指标上需要获得「最大值」、「平均值」或者「中位数」。后续限流策略需要设定的具体标准数值就是从这些指标中来的。
1
2
3 1题外话:从精益求精的角度来说,其他的诸如cpu、网络带宽以及内存的耗用也可以作为参照因素。
2
3
制定干预流量的策略
常用的策略就4种,我给它起了一个简单的定义——「两窗两桶」。两窗就是:固定窗口、滑动窗口,两桶就是:漏桶、令牌桶。
固定窗口
固定窗口就是定义一个“固定”的统计周期,比如1分钟或者30秒、10秒这样。然后在每个周期统计当前周期中被接收到的请求数量,经过计数器累加后如果达到设定的阈值就触发「流量干预」。直到进入下一个周期后,计数器清零,流量接收恢复正常状态。
这个策略最简单,写起代码来也没几行。
1
2
3
4
5
6
7
8
9
10 1全局变量 int totalCount = 0; //有一个「固定周期」会触发的定时器将数值清零。
2
3if(totalCount > 限流阈值) {
4return; //不继续处理请求。
5}
6totalCount++;
7
8// do something...
9
10
固定窗口有一点需要注意的是,假如请求的进入非常集中,那么所设定的「限流阈值」等同于你需要承受的最大并发数。所以,如果需要顾忌到并发问题,那么这里的「固定周期」设定的要尽可能的短。因为,这样的话「限流阈值」的数值就可以相应的减小。甚至,限流阈值就可以直接用并发数来指定。比如,假设固定周期是3秒,那么这里的阈值就可以设定为「平均并发数*3」。
不过不管怎么设定,固定窗口永远存在的缺点是:由于流量的进入往往都不是一个恒定的值,所以一旦流量进入速度有所波动,要么计数器会被提前计满,导致这个周期内剩下时间段的请求被“限制”。要么就是计数器计不满,也就是「限流阈值」设定的过大,导致资源无法充分利用。
「滑动窗口」可以改善这个问题。
滑动窗口
滑动窗口其实就是对固定窗口做了进一步的细分,将原先的粒度切的更细,比如1分钟的固定窗口切分为60个1秒的滑动窗口。然后统计的时间范围随着时间的推移同步后移。
同时,我们还可以得出一个结论是:如果固定窗口的「固定周期」已经很小了,那么使用滑动窗口的意义也就没有了。举个例子,现在的固定窗口周期已经是1秒了,再切分到毫秒级别能反而得不偿失,会带来巨大的性能和资源损耗。
滑动窗口大致的代码逻辑是这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 1全局数组 链表[] counterList = new 链表[切分的滑动窗口数量];
2//有一个定时器,在每一次统计时间段起点需要变化的时候就将索引0位置的元素移除,并在末端追加一个新元素。
3
4int sum = counterList.Sum();
5if(sum > 限流阈值) {
6return; //不继续处理请求。
7}
8
9int 当前索引 = 当前时间的秒数 % 切分的滑动窗口数量;
10counterList[当前索引]++;
11
12// do something...
13
14
虽然说滑动窗口可以改善这个问题,但是本质上还是预先划定时间片的方式,属于一种“预测”,意味着几乎肯定无法做到100%的物尽其用。
但是,「桶」模式可以做的更好,因为「桶」模式中多了一个缓冲区(桶本身)。
漏桶
首先聊聊「漏桶」吧。漏桶模式的核心是固定“出口”的速率,不管进来多少量,出去的速率一直是这么多。如果涌入的量多到桶都装不下了,那么就进行「流量干预」。
整个实现过程我们来分解一下。
控制流出的速率。这个其实可以使用前面提到的两个“窗口”的思路来实现。如果当前速率小于阈值则直接处理请求,否则不直接处理请求,进入缓冲区,并增加当前水位。
缓冲的实现可以做一个短暂的休眠或者记录到一个容器中再做异步的重试。
最后控制桶中的水位不超过最大水位。这个很简单,就是一个全局计数器,进行加加减减。
这样一来,你会发现本质就是:通过一个缓冲区将不平滑的流量“整形”成平滑的(高于均值的流量暂存下来补足到低于均值的时期),以此最大化计算处理资源的利用率。
实现代码的简化表示如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 1全局变量 int unitSpeed; //出口当前的流出速率。每隔一个速率计算周期(比如1秒)会触发定时器将数值清零。
2全局变量 int waterLevel; //当前缓冲区的水位线。
3
4if(unitSpeed < 速率阈值) {
5unitSpeed++;
6
7//do something...
8}
9else{
10if(waterLevel > 水位阈值){
11return; //不继续处理请求。
12}
13
14waterLevel++;
15
16while(unitSpeed >= 速率阈值){
17sleep(一小段时间)。
18}
19unitSpeed++;
20waterLevel--;
21
22//do something...
23}
24
25
更优秀的「漏桶」策略已经可以在流量的总量充足的情况下发挥你所预期的100%处理能力,但这还不是极致。
你应该知道,一个程序所在的运行环境中,往往不单单只有这个程序本身,会存在一些系统进程甚至是其它的用户进程。也就是说,程序本身的处理能力是会被干扰的,是会变化的。所以,你可以预估某一个阶段内的平均值、中位数,但无法预估具体某一个时刻的程序处理能力。又因此,你必然会使用相对悲观的标准去作为阈值,防止程序超负荷。
那么从资源利用率来说,有没有更优秀的方案呢?有,这就是「令牌桶」。
令牌桶
**令牌桶模式的核心是固定“进口”速率。**先拿到令牌,再处理请求,拿不到令牌就被「流量干预」。因此,当大量的流量进入时,只要令牌的生成速度大于等于请求被处理的速度,那么此刻的程序处理能力就是极限。
也来分解一下它的实现过程。
控制令牌生成的速率,并放入桶中。这个其实就是单独一个线程在不断的生成令牌。
控制桶中待领取的令牌水位不超过最大水位。这个和「漏桶」一样,就是一个全局计数器,进行加加减减。
大致的代码简化表示如下(看上去像「固定窗口」的反向逻辑):
全局变量 int tokenCount = 令牌数阈值; //可用令牌数。有一个独立的线程用固定的频率增加这个数值,但不大于「令牌数阈值」。
1
2
3
4
5
6
7
8
9 1if(tokenCount == 0){
2return; //不继续处理请求。
3}
4
5tokenCount--;
6
7//do something...
8
9
聪明的你可能也会想到,这样一来令牌桶的容量大小理论上就是程序需要支撑的最大并发数。的确如此,假设同一时刻进入的流量将令牌取完,但是程序来不及处理,将会导致事故发生。
所以,没有真正完美的策略,只有合适的策略。因此,根据不同的场景能够识别什么是最合适的策略是更需要锻炼的能力。下面z哥分享一些我个人的经验。
二、做「限流」的最佳实践
四种策略该如何选择?
首先,固定窗口。一般来说,如非时间紧迫,不建议选择这个方案,太过生硬。但是,为了能快速止损眼前的问题可以作为临时应急的方案。
其次,滑动窗口。这个方案适用于对异常结果「高容忍」的场景,毕竟相比“两窗”少了一个缓冲区。但是,胜在实现简单。
然后,漏桶。z哥觉得这个方案最适合作为一个通用方案。虽说资源的利用率上不是极致,但是「宽进严出」的思路在保护系统的同时还留有一些余地,使得它的适用场景更广。
最后,令牌桶。当你需要尽可能的压榨程序的性能(此时桶的最大容量必然会大于等于程序的最大并发能力),并且所处的场景流量进入波动不是很大(不至于一瞬间取完令牌,压垮后端系统)。
分布式系统中带来的新挑战
一个成熟的分布式系统大致是这样的。
每一个上游系统都可以理解为是其下游系统的客户端。然后我们回想一下前面的内容,可能你发现了,前面聊的「限流」都没有提到到底是在客户端做限流还是服务端做,甚至看起来更倾向是建立在服务端的基础上做。但是你知道,在一个分布式系统中,一个服务端本身就可能存在多个副本,并且还会提供给多个客户端调用,甚至其自身也会作为客户端角色。那么,在如此交错复杂的一个环境中,该如何下手做限流呢?我的思路是通过「一纵一横」来考量。
纵
都知道「限流」是一个保护措施,那么可以将它想象成一个盾牌。另外,一个请求在系统中的处理过程是链式的。那么,正如古时候军队打仗一样,盾牌兵除了有小部分在老大周围保护,剩下的全在最前线。因为盾的位置越前,能受益的范围越大。
分布式系统中最前面的是什么?接入层。如果你的系统有接入层,比如用nginx做的反向代理。那么可以通过它的ngx_http_limit_conn_module以及ngx_http_limit_req_module来做限流,是很成熟的一个解决方案。
如果没有接入层,那么只能在应用层以AOP的思路去做了。但是,由于应用是分散的,出于成本考虑你需要针对性的去做限流。比如ToC的应用必然比ToB的应用更需要做,高频的缓存系统必然比低频的报表系统更需要做,Web应用由于存在Filter的机制做起来必然比Service应用更方便。
那么应用间的限流到底是做到客户端还是服务端呢?
哥的观点是,从效果上客户端模式肯定是优于服务端模式的,因为当处于被限流状态的时候,客户端模式连建立连接的动作都省了。另一个潜在的好处是,与集中式的服务端模式相比,可以把少数的服务端程序的压力分散掉。但是在客户端做成本也更高,因为它是去中心化的,假如需要多个节点之间的数据共通的话,是一个很麻烦的事情。
所以,最终哥建议你:如果考虑成本就服务端模式,考虑效果就客户端模式。当然也不是绝对,比如一个服务端的流量大部分都来源于某一个客户端,那么就可以直接在这个客户端做限流,这也不失为一个好方案。
数据库层面的话,一般连接字符串中本身就会包含「最大连接数」的概念,就可以起到限流的作用。如果想做更精细的控制就只能做到统一封装的数据库访问层框架中了。
聊完了「纵」,那么「横」是什么呢?
横
不管是多个客户端,还是同一个服务端的多个副本。每个节点的性能必然会存在差异,如何设立合适的阈值?以及如何让策略的变更尽可能快的在集群中的多个节点生效?说起来很简单,引入一个性能监控平台和配置中心。但这些真真要做好不容易,后续我们再展开这块内容。
三、总结
限流就好比保险丝,根据你制定的标准,达到了就拉闸。喜欢我就我关注我吧~