前言
任何一个系统的运算、存储、网络资源都不是无限的,当系统资源不足以支撑外部超过预期的突发流量时,就应该要有取舍,建立面对超额流量自我保护的机制,而这个机制就是微服务中常说的“限流”。
限流的目标
考虑一个简单的场景:一个系统的最大处理能力是80TPS,在遇到100TPS请求的环境下,理想的限流目标是什么?
理想的限流目标就是能够完成80TPS的业务,只有20TPS的请求失败或被拒绝(这可能看上去是理所应当的)。但如果未做良好的设计,实际场景可能是100TPS涉及的请求都被部分处理,实际完成的业务量为0。
所以,一个健壮的系统要做到恰当的流量控制,需要妥善解决以下三个问题:
- 依据什么限流?
要不要控制流量、要控制哪些流量、控制力度要有多大,等等,这些操作都没法在系统设计阶段静态地给出确定的结论,必须根据系统此前一段时间的运行状况,甚至未来一段时间的预测情况来动态决定。 - 具体如何限流?
要想解决系统具体是如何做到允许一部分请求能够通行,而另外一部分流量实行受控制的失败降级的问题,就必须要了解和掌握常用的服务限流算法和设计模式。 - 超额流量如何处理?
超额流量可以有不同的处理策略,也许会直接返回失败(如 429 Too Many Requests),或者被迫使它们进入降级逻辑,这种策略被称为否决式限流;也可能是让请求排队等待,暂时阻塞一段时间后继续处理,这种则被称为阻塞式限流。
流量统计指标
限流中的“流”到底指什么呢?要解答这个问题,我们得先梳理清楚经常用于衡量服务流量压力,但又比较容易混淆的三个指标的定义:
- 每秒事务数(Transactions per Second,TPS)
TPS 是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作(或者是需要被看做是原子性的业务操作)。比如对一笔订单的支付操作,不能允许只支付订单中的包装盒。 - 每秒请求数(Hits per Second,HPS)
HPS 是指每秒从客户端发向服务端的请求数(这里要把 Hits 理解为 Requests 而不是 Clicks)。如果只要一个请求就能完成一笔业务,那 HPS 与 TPS 是等价的。多数情况下,一笔业务都伴随多个请求。 - 每秒查询数(Queries per Second,QPS)
QPS 是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,那 QPS 和 HPS 是等价的,但在分布式系统中,一个请求的响应,往往要由后台多个服务节点共同协作来完成。
在逻辑概念上,可以理解为TPS包括了HPS包括了QPS,而数值上QPS>HPS>TPS。
总体来说,以上这三点都是基于调用计数的指标,而在整体目标上,我们当然最希望能够基于 TPS 来限流,因为信息系统最终是为人类用户提供服务的,用户并不关心业务到底是由多少个请求、多少个后台查询共同协作来实现的。但是因为TPS更接近客户端侧,不能准确地反映出服务端系统所承受的压力,所以实际操作会比较困难。目前来说,主流系统大多倾向于使用 HPS 作为首选的限流指标,因为它相对容易观察统计,而且能够在一定程度上反映系统当前以及接下来一段时间的压力。
但是限流指标并不存在任何必须遵循的权威法则,根据系统的实际需要,哪怕完全不选择基于调用计数的指标都是有可能的。举个简单的例子,下载、视频、直播等 I/O 密集型系统,往往会把每次请求和响应报文的大小作为限流指标。比如说,只允许单位时间通过 100MB 的流量;再比如网络游戏等基于长连接的应用,可能会把登录用户数作为限流指标,热门的网游往往超过一定用户数就会让你在登录前排队等候。
限流设计模式
业界内有一些常见、常用、被实践证明有效的设计模式可以参考使用,包括流量计数器、滑动时间窗、漏桶和令牌桶这四种。
流量计数器模式
最容易想到的一种做限流的方法,就是设置一个计算器,根据当前时刻的流量计数结果是否超过阈值来决定是否限流。
这种做法很直观,而且有些简单的限流就是这么实现的,但它并不严谨,如:
- 即使每一秒的统计流量都没有超过 80 TPS,也不能说明系统没有遇到过大于 80 TPS 的流量压力。(一秒框定的范围内可能超出80TPS,但却没被统计器计算到)
- 即使连续若干秒的统计流量都超过了 80 TPS,也不能说明流量压力就一定超过了系统的承受能力。(要看系统的设计,比如超时机制)
流量计数器模式缺陷的根源在于,它只是针对时间点进行离散的统计。因此为了弥补该缺陷,一种名为“滑动时间窗”的限流模式就被设计了出来,它可以实现平滑的基于时间片段的统计。
滑动时间窗模式
滑动窗口算法(Sliding Window Algorithm)在计算机科学的很多领域中都有成功的应用,比如编译原理中的窥孔优化(Peephole Optimization)、TCP 协议的阻塞控制(Congestion Control)等都使用到了滑动窗口算法。而对分布式系统来说,无论是服务容错中对服务响应结果的统计,还是流量控制中对服务请求数量的统计,也都经常要用到滑动窗口算法。
滑动时间窗的工作过程:
当频率固定的定时器被唤醒时,(实际中常用双端队列代替数组)
- 将数组最后一位的元素丢弃掉,并把所有元素都后移一位,然后在数组第一个插入一个新的空元素。这个步骤即为“滑动窗口”。
- 将计数器中所有统计信息写入到第一位的空元素中。
- 对数组中所有元素进行统计,并复位清空计数器数据供下一个统计周期使用。
这种模式也有一些缺点,它通常只适用于否决式限流,对于超过阈值的流量就必须强制失败或降级,很难进行阻塞等待处理,也就很难在细粒度上对流量曲线进行整形,起不到削峰填谷的作用。
漏桶模式
在计算机网络中,专门有一个术语“流量整形”(Traffic Shaping),用来描述如何限制网络设备的流量突变,使得网络报文以比较均匀的速度向外发送。流量整形通常都需要用到缓冲区来实现,当报文的发送速度过快时,首先在缓冲区中暂存,然后在控制算法的调节下,均匀地发送这些被缓冲的报文。
这里常用的控制算法,有漏桶算法(Leaky Bucket Algorithm)和令牌桶算法(Token Bucket Algorithm)两种,这两种算法的思路截然相反,但达到的效果又是相似的。
针对限流模式的话,你可以把“请求”想像成是“水”,水来了都先放进池子里,水池同时又以额定的速度出水,让请求进入系统中。这样,如果一段时间内注水过快的话,水池还能充当缓冲区,让出水口的速度不至于过快。不过,由于请求总是有超时时间的,所以缓冲区的大小也必须是有限度的,当注水速度持续超过出水速度一段时间以后,水池终究会被灌满。此时,从网络的流量整形的角度看,就体现为部分数据包被丢弃;而从信息系统的角度看,就体现为有部分请求会遭遇失败和降级。
漏桶模式在代码实现上也非常简单,它其实就是一个以请求对象作为元素的先入先出队列(FIFO Queue),队列长度就相当于漏桶的大小,当队列已满时就拒绝新的请求进入。漏桶实现起来很容易,比较困难的地方只在于如何确定漏桶的两个参数:桶的大小和水的流出速率。
- 首先是桶的大小。如果桶设置得太大,那服务依然可能遭遇流量过大的冲击,不能完全发挥限流的作用;如果设置得太小,那很可能就会误杀掉一部分正常的请求,这种情况与流量计数器模式中举过的例子是一样的。
- 流出速率在漏桶算法中一般是个固定值,这对于固定拓扑结构的服务是很合适的;但同时你也应该明白,现实世界里系统的处理速度,往往会受到其内部拓扑结构变化和动态伸缩的影响。这也暴露出漏桶模式的缺陷。
令牌桶模式
对比来看,漏桶是从水池里往系统出水,令牌桶则是系统往排队机中放入令牌。
具体实现是,假设我们要限制系统在 X 秒内的最大请求次数不超过 Y,那我们可以每间隔 X/Y 时间,就往桶中放一个令牌,当有请求进来时,首先要从桶中取得一个准入的令牌,然后才能进入系统处理。任何时候,一旦请求进入桶中发现没有令牌可取了,就应该马上失败或进入服务降级逻辑。
与漏桶类似,令牌桶同样有最大容量,这意味着当系统比较空闲的时候,桶中的令牌累积到一定程度就不再无限增加,而预存在桶中的令牌便是请求最大缓冲的余量。
编码实现是:
- 让系统以一个由限流目标决定的速率向桶中注入令牌,比如要控制系统的访问不超过 100 次,速率即设定为 1/100=10 毫秒。
- 桶中最多可以存放 N 个令牌,N 的具体数量是由超时时间和服务处理能力共同决定的。如果桶已满,第 N+1 个进入的令牌就会被丢弃掉。
- 请求到时会先从桶中取走 1 个令牌,如果桶已空就进入降级逻辑。
总体来说,令牌桶模式的实现看似可能比较复杂,每间隔固定时间,我们就要把新的令牌放到桶中,但其实我们并不需要真的用一个专用线程或者定时器来做这件事情,只要在令牌中增加一个时间戳记录,每次获取令牌前,比较一下时间戳与当前时间,就可以轻易计算出这段时间需要放多少令牌进去,然后一次性放完全部令牌即可,所以真正编码时并不会显得很复杂。(是否还应该记录上一次发放令牌的时间戳)
分布式限流
此前,我们讨论的种种限流算法和模式全部是针对整个系统的限流,总是有意无意地假设或默认系统只提供一种业务操作,或者所有业务操作的消耗都是等价的,并不涉及不同业务请求进入系统的服务集群后,分别会调用哪些服务、每个服务节点处理能力有何差别等问题。另外,这些限流算法直接使用在单体架构的集群上确实是完全可行的,但到了微服务架构下,它们就最多只能应用于集群最入口处的网关上,对整个服务集群进行流量控制,而无法细粒度地管理流量在内部微服务节点中的流转情况。
所以,我们把前面介绍的限流模式都统称为单机限流,把能够精细控制分布式集群中每个服务消耗量的限流算法称为分布式限流。
分布式限流与单机限流最主要的区别是如何管理限流的统计指标。
单机限流很好办,指标都是存储在服务的内存当中;而分布式限流的目的是要让各个服务节点的协同限流。无论是将限流功能封装为专门的远程服务,还是在系统采用的分布式框架中有专门的限流支持,都需要把每个服务节点的内存中的统计数据给开放出来,让全局的限流服务可以访问到才行。
- 一种常见的简单分布式限流方法,是将所有服务的统计结果都存入集中式缓存(如 Redis)中,以实现在集群内的共享,并通过分布式锁、信号量等机制,解决这些数据在读写访问时的并发控制问题。这样,在可以共享统计数据的前提下,原本用于单机的限流模式,理论上也是可以应用于分布式环境中的,可是它的代价也显而易见:每次服务调用都必须要额外增加一次网络开销,所以这种方法的效率肯定是不高的,当流量压力大的时候,限流本身反倒会显著降低系统的处理能力。(只要集中式存储统计信息,就不可避免地会产生网络开销。)
- 一种改善办法是在令牌桶限流模式的基础上,进行“货币化改造”。即不把令牌看作是只有准入和不准入的“通行证”,而把它看作是数值形式的“货币额度”。
当请求进入集群时,首先在 API 网关处领取到一定数额的“货币”,不同等级用户的额度可以有所差异。我们将用户 A 的额度表示为 QuanityA。由于任何一个服务在响应请求时,都需要消耗集群中一定量的处理资源,所以在访问每个服务时都要求消耗一定量的“货币”。假设服务 X 要消耗的额度表示为 CostX,那当用户 A 访问了 N 个服务以后,他剩余的额度 LimitN 就会表示为:
LimitN = QuanityA - ∑NCostX
我们可以把剩余额度 LimitN 作为内部限流的指标,规定在任何时候,只要剩余额度 LimitN 小于等于 0 时,就不再允许访问其他服务了。另外,这时还必须先发生一次网络请求,重新向令牌桶申请一次额度,成功后才能继续访问,不成功则进入降级逻辑。除此之外的任何时刻,即 LimitN 不为 0 时,都无需额外的网络访问,因为计算 LimitN 是完全可以在本地完成的。
这种基于额度的限流方案,对限流的精确度会有一定的影响,比如可能存在业务操作已经进行了一部分服务调用,却无法从令牌桶中再获取到新额度,因“资金链断裂”而导致业务操作失败的情况。这种失败的代价是比较高昂的,它白白浪费了部分已经完成了的服务资源,但总体来说,它仍然是一种在并发性能和限流效果上,都相对折衷可行的分布式限流方案。
总结
对于分布式系统容错的设计,是必须要有且无法妥协的措施。但限流与容错不一样,做分布式限流从不追求“越彻底越好”,我们往往需要权衡方案的代价与收益。
附
此文章为3月Day03学习笔记,内容来源于极客时间《周志明的软件架构课》