You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

146 lines
19 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 10 | 雪崩(二):限流,抛弃超过设计容量的请求
你好,我是陈现麟。
通过上一节课的学习,我们了解了因为局部故障的正反馈循环而导致的雪崩,可以通过熔断来阻断,这样我们就为极客时间的后端系统,加上了熔断这一根保险丝,再也不用担心小故障被放大成一个全局的故障了,这让极客时间的后端系统,在稳定性上又向前跨进了一大步。
但是有的时候,我们明明知道一个服务的最高处理能力为 10 w QPS ,并且我们也知道这一次活动,这个服务的请求会超过 10 w QPS 。这个时候,如果只有熔断机制,我们就需要等待服务由于过载出现故障后触发熔断,然后再恢复正常,那么系统就是在被动地应对服务请求过载的问题。
其实这是一个典型的限流场景,那么,我们应该如何优雅地处理这个问题呢?在这节课中,我们将一起讨论,保障分布式系统稳定性的另一个方法——限流,从限流的原因入手,分析如何实现限流,再一起讨论限流机制要注意的关键问题,从这三个方面来分析,如何通过限流机制主动处理服务流程过载。
## 为什么需要限流
限流和熔断是经常一起出现的两个概念,都是用来解决服务过载问题的,那么在有了熔断机制后,为什么还需要限流呢?我认为主要有以下几个方面的原因。
**首先,熔断的处理方式不够优雅**。回到课程开始的例子,虽然在服务过载的时候,熔断可以避免雪崩的发生,但是熔断机制是被动感知故障,然后再进行处理的,它需要先让过载发生,等系统出现故障后,才会介入处理,让系统恢复到正常。
这样的处理方式会让系统产生不必要的抖动,如果是处理意料之外的过载问题,我们是可以接受的。但是,在明知道服务的服务能力的情况下,依然让故障发生,然后在事后进行被动处理,这个处理思路就不够优雅了。
**其次,熔断机制是最后底线**。虽然熔断可以解决雪崩问题,但是它应该作为系统稳定性保障的最后一道防线,我们没有必要时刻把它亮出来。正确使用熔断的思路应该是,在其他方法用尽之后,如果过载问题依旧存在,这时熔断才会被动触发。
所以,我们的系统虽然有熔断机制,保障雪崩不会出现,但是当熔断出现的时候,依然代表着我们的系统已经失控了。我们需要更主动地解决问题,防患于未然,而限流就可以达到这个目的。
**再次,在快速失败的时候,需要能考虑调用方的重要程度**。熔断是调用方依据响应结果自适应来触发的,在被调用方出现过载的时候,所有的调用方都将受到影响。但是很多时候,不同调用方的重要程度是不一样的,比如同样是查询用户信息的接口,在用户详情页面调用这个接口的重要程度,会高于评论列表页面,如果查询用户信息的接口出现过载了,我们要优先保障用户详情页面的调用是正常的。
**最后,在多租户的情况下,不能让一个租户的问题影响到其他的租户**,我们需要对每一个租户分配一定的配额,谁超过了就对谁进行限流,保证租户之间的隔离性。
## 如何实现限流
通过上面的讨论,我们了解到限流机制是熔断等其他机制无法替代的,是必须的,那么我们该如何实现限流机制呢?这里我们先介绍一下常见的限流算法,然后讨论单节点限流机制需要注意的问题,最后再讨论分布式场景下限流机制的权衡。
### 限流算法
限流算法是限流机制的基础和核心,并且后续关于限流机制的讨论,都会涉及相关的限流算法,所以我们先介绍最常用的四个限流算法:固定窗口、滑动窗口、漏桶和令牌桶算法,把它们两两结合来进行分析。
#### 固定窗口和滑动窗口
固定窗口就是定义一个“固定”的统计周期,比如 10 秒、30 秒或者 1 分钟,然后在每个周期里,统计当前周期中被接收到的请求数量,经过计数器累加后,如果超过设定的阈值就触发限流,直到进入下一个周期后,计数器清零,流量接收再恢复正常状态,如下图所示。
![](https://static001.geekbang.org/resource/image/db/bc/db7f90a8d308455a0a5361bec57093bc.jpg?wh=2284x1155)
假设我们现在设置的是 2 秒内不能超过 100 次请求,但是因为流量的进入往往都不是均匀的,所以固定窗口会出现以下两个问题。
第一,抗抖动性差。由于流量突增使请求超过预期,导致流量可能在一个统计周期的前 10 ms 内就达到了 100 次,给服务的处理能力造成一定压力,同时后面的 1990 ms 将会触发限流。这个问题虽然可以通过减小统计周期来改善,但是**因为统计周期变小,每个周期的阈值也会变小,一个小的流量抖动就会导致限流的发生,所以系统的抗抖动能力就变得更差了**。
第二,如果上一个统计周期的流量集中在最后 10 ms ,而现在这个统计周期的流量集中在前 10 ms **那么这 20 ms 的时间内会出现 200 次调用,这就超过了我们预期的 2 秒内不能超过 100 次请求的目的了**。这时候,我们就需要使用“滑动窗口”算法来改善这个问题了。
其实,滑动窗口就是固定窗口的优化,它对固定窗口做了进一步切分,将统计周期的粒度切分得更细,比如 1 分钟的固定窗口,切分为 60 个 1 秒的滑动窗口,然后统计的时间范围随着时间的推移同步后移,如下图所示。
![](https://static001.geekbang.org/resource/image/05/f5/05624d95130c3305d66367307b9da5f5.jpg?wh=2284x1219)
但是这里要注意一个问题,如果滑动窗口的统计窗口切分得过细,会增加系统性能和资源损耗的压力。同时,**滑动窗口和固定窗口一样面临抗抖动性差的问题**,“漏桶”算法可以进一步改进它们的问题。
#### 漏桶和令牌桶
我们可以在图中看到,“漏桶”就像一个漏斗,进来的水量就像访问流量一样,而出去的水量就像是我们的系统处理请求一样。当访问流量过大时,这个漏斗中就会积水,如果水太多了就会溢出。
![](https://static001.geekbang.org/resource/image/9c/40/9c18b9206a1920d190bb5a022ba32840.jpg?wh=2284x1667)
相对于滑动窗口和固定窗口来说,漏桶有两个改进点,第一,增加了一个桶来缓存请求,在流量突增的时候,可以先缓存起来,直到超过桶的容量才触发限流;第二,对出口的流量上限做了限制,使上游流量的抖动不会扩散到下游服务。这两个改进大大提高了系统的抗抖动能力,使漏桶有了流量整形的能力。
但是,漏桶提供流量整形能力有一定的代价,超过漏桶流出速率的请求,需要先在漏桶中排队等待,**其中流出速率是漏桶限流的防线,一般会设置得相对保守,可是这样就无法完全利用系统的性能,就增加了请求的排队时间**。
那么从资源利用率的角度来讲,有没有更好的限流方式呢?我们可以继续看下面介绍的“令牌桶”算法。
如图,我们可以看到,令牌桶算法的核心是固定“进口”速率,限流器在一个一定容量的桶内,按照一定的速率放入 Token ,然后在处理程序去处理请求的时候,需要拿到 Token 才能处理;如果拿不到,就进行限流。因此,当大量的流量进入时,只要令牌的生成速度大于等于请求被处理的速度,那么此时系统处理能力就是极限的。
![](https://static001.geekbang.org/resource/image/c8/e4/c8ab1920a032419fd2cf1516934e6fe4.jpg?wh=2284x1603)
根据漏桶和令牌桶的特点,我们可以看出,这两种算法都有一个“恒定”的速率和“可变”的速率。令牌桶以“恒定”的速率生产令牌,但是请求获取令牌的速率是“可变”的,桶里只要有令牌就直接发,令牌没了就触发限流;而漏桶只要桶非空,就以“恒定”的速率处理请求,但是请求流入桶的速率是“可变”的,只要桶还有容量,就可以流入,桶满了就触发限流。
这里我们也需要注意到,**“令牌桶”算法相对于“漏桶”,虽然提高了系统的资源利用率,但是却放弃了一定的流量整形能力**,也就是当请求流量突增的时候,上游流量的抖动可能会扩散到下游服务。
所以,计算机的世界没有银弹,一个方案总是有得必有失,一般来说折中的方案可能是使用最广泛的,这就是没有完美的架构,只有完美的 trade-off 的原因。
### 单节点限流
由于只有一个节点,不需要和其他的节点共享限流的状态信息,所以单节点限流的实现是比较简单的,我们可以基于内存来实现限流算法,让需要限流的请求先经历一遍限流算法,由限流算法来决定是正常执行,还是触发限流,这里需要注意两个问题。
**首先,限流机制作用的位置是客户端还是服务端,即选择客户端限流还是服务端限流**。一般来说,熔断机制作用的位置是客户端,限流机制作用的位置更多是服务端,因为熔断更强调自适应,让作用点分散在客户端是没有问题的,而限流机制则更强调控制,它的作用点在服务端的控制能力会更强。
但是,将作用点放置在服务端,会给服务端带来性能压力。如果将作用点放置在客户端,这就是一个天然的分布式模式,每一个调用方的客户端执行自己的限流逻辑,这部分我们会在下面的分布式限流中继续讨论。而将作用点放置在服务端时,服务端要执行所有请求的限流逻辑,就需要更多的内存来缓存请求,以及更多的 CPU 来执行限流逻辑。
我们可以考虑的一个策略是,在客户端实现限流策略的底线,比如,一个客户端对一个接口的调用不能超过 10000 并发,这是一个正常情况下完全不会达到的阈值,如果超过就进行客户端限流,避免客户端的异常流量对服务端造成压力。同时,因为这是一个非常粗粒度的阈值,设置好默认值后,几乎不会去修改,所以就缓解了客户端限流带来的阈值管理问题,之后就可以在服务端实现更精细和复杂的限流机制了。
**其次,如果触发限流后,我们应该直接抛弃请求还是阻塞等待,即否决式限流和阻塞式限流**。一般来说,如果我们可以控制流量产生的速率,那么阻塞式限流就是一个更好的选择,因为它既可以实现限流的目的,又不会抛弃请求;如果我们不能控制流量产生的速率,那么阻塞式限流将会因为请求积压,出现大量系统资源占用的情况,很容易引发雪崩,这时否决式限流将是更好的选择。
所以,对于在线业务的服务端场景来说,服务之间相互调用的请求流量主要是用户行为产生的,不论是客户端限流还是服务端限流,限流的作用点都处于流量的接收方,因为接收方不能控制流量产生的速率,所以超出阈值后通常直接丢弃,进行否决式限流。
而对于像消费 MQ 消息或者发送 Push 时,为了避免打挂所依赖的下游服务,我们可以通过对 MQ 消费或者发送 Push 的行为进行限速,来控制流量产生的速率,在这种情况下,如果超出阈值了,我们一般选择阻塞等待,进行阻塞式限流。
### 分布式限流
讨论完单节点限流后,我们还需要重点关注分布式限流,即为了系统高可用,每一个服务都会运行多个实例,所以我们在对某一服务进行限流的时候,就需要协调该服务的多个实例,统一进行限流。因为上文中对于单节点限流讨论的问题,在分布式限流场景同样适用,这里就不再赘述了。下面我们主要来讨论,在实现分布式场景下,如何来协同多个节点进行统一的限流。
**首先,最容易想到的一个方案是进行集中式限流**。单节点限流是在进程内的内存中实现限流器的,而对于分布式限流来说,我们可以借助一个外部存储来实现限流器,比如 Redis 。在分布式限流的场景下,我们一般选择令牌桶算法,但是这个方法的缺点是,每一次请求都需要先访问外部的限流器获取令牌,这将带来三个问题。
第一,限流器会成为系统的性能瓶颈,如果在系统的 QPS 非常高的情况下,限流器的压力是非常大的。虽然我们可以将请求,通过 Hash 策略扩展到多个限流器实例上,但是这也增加了系统的复杂性。复杂性是系统架构最大的敌人,我们一定要保持敏感。
第二,限流器的故障将会影响所有接入限流器的服务。不过,我们可以在限流器故障的情况下,进行降级处理,例如,如果服务访问限流器获取令牌出现了错误时,可以降级为直接进行调用,而不是抛弃请求。
第三,增加了调用的时延。每一次调用前,都需要先通过网络访问一次限流器,这是一个毫秒级别的时延。
**其次,另一个方案是将分布式限流进行本地化处理**。限流器在获得一个服务限额的总阈值后,将这个总阈值按一定的策略分配给服务的实例,每一个实例依据分配的阈值进行单节点限流。这里要注意的是,如果服务实例的性能不一样,在负载均衡层面,我们会考虑性能差异进行流量分配。在限流层面,我们也需要考虑这个问题,性能不同的实例,限流的阈值也不一样,性能好的节点,限流的阈值会更高。
但是,这个方式也有一个问题,该模式的分配比例模型,是依据统计意义来进行分配的,而现实中,具体到一个限流策略上,它的精确性可能会出现问题。比如有两个实例的服务,对一个用户限流为 10 QPS ,假设这两个实例的性能相同,每个实例限流的阈值为 5 QPS ,但是如果这个用户的流量,都被路由到其中的一个实例上,这就会导致该用户的流量,在 5 QPS 的时候就触发了限流,和我们的设计预期不一致了。
最后,我们来讨论一个折中的方案,这个方案建立在集中式限流的基础上,为了解决每次请求都需要,通过网络访问限流器获取令牌的问题,客户端只有在令牌数不足时,才会通过限流器获取令牌,并且一次获取一批令牌。**这个方案的令牌是由集中式限流器来生成的,但是具体限流是在本地化处理的,所以在限流的性能和精确性之间,就有了一个比较好的平衡**。
## 限流机制的关键问题
了解完限流的实现原理之后,我们就知道如何去实现一个限流器了,但是,在限流器实际落地的过程中,我们需要去配置限流的阈值,同时还要确保系统,不会因为触发了不必要的限流而导致故障,所以我们还需要思考下面两个关键问题。
### 如何确定限流的阈值
当我们对服务进行限流的时候,首先要面临的第一个问题是,确定服务触发限流的阈值。
一个最简单的方案是,根据经验设置一个比较保守,并且满足系统负载要求的阈值,在之后的使用中慢慢进行调整。但是这个方案会出现一个问题,我们预测的限流阈值不够准确,甚至会出现比较大的偏差,对于限流的阈值来说,不论过高还是过低都会出现问题,阈值过高则限流不会起作用,阈值过低则无法发挥出服务的性能。
另外,我们可以通过压力测试来决定限流的阈值。但是,压测的环境很难和线上环境保持一致,特别是在涉及缓存和存储的情况下,并且单个接口的压力测试不能反映出,正常运行情况下系统的状态。虽然全链路压测可以通过流量回放,一定程度上模拟线上真实流量的比例,但是它也只是用历史的流量比例来预测未来,并且这个工作量是非常大的。
同时,我们的系统在一个持续的迭代过程中,系统的性能可能会随着迭代而发生变化,所以限流的阈值设置好之后,还需要付出一定的维护成本。
### 限流可能会引入脆弱性
我们引入限流,本来是为了提高系统的稳定性,达到“反脆弱”的目的,但是,如果我们在分布式系统的复杂拓扑调用中,遍布限流功能,那么以后对每个服务的扩容,新功能的上线,以及调用拓扑结构的变更,就都有可能会导致局部服务流量的骤增,从而引发限流使业务有损。所以,限流可能会引入脆弱性,这是一个很值得讨论的问题。
限流机制的“反脆弱”也有可能会导致“脆弱”的出现,它的本质原因是,在限流的阈值设置后,我们很难适应调用拓扑、机器性能等等的变化,但是,在熔断的阈值里是可以自适应这些变化的,也就没有这个问题了。
所以,当我们决定对系统进行大规模限流设置时,需要谨慎地审视系统的限流能力和成熟度,判断它们是否能支撑起如此大规模的应用。
最后,通过这两个讨论,我认为使用限流机制比较好的一个方式是,在系统的核心链路和核心服务上,默认启用限流机制,比如,像网关这样的流量入口和账号这样的核心服务,不论是限流阈值的设定,还是脆弱性的判断,我们都可以通过减少限流引入的范围,来简化使用限流的复杂度;而对于其他的位置和服务,则默认不启用限流机制,在出现故障的时候,通过手动设置阈值再启用,把它作为处理系统故障的一个手段。
## 总结
到这里,我们一起讨论了需要限流机制的原因,限流机制的算法、实现原理以及关键问题,下面一起来总结一下这节课的主要内容。
通过了解有了熔断之后,还需要限流机制的原因,你在后续的工作中,如果碰到这样的限流场景,就可以引入限流机制了。
另外,在掌握了限流的算法、单节点限流和分布式限流的技术原理之后,你就可以为你现在的系统实现一个限流器了。
最后,我们从限流机制的关键问题:限流阈值的设置和引入的脆弱性中,得出**在核心链路和核心服务上,默认启用限流机制,在其他位置上,手动启用限流机制,把它作为处理系统故障的一个手段**。
## 思考题
在日常工作中,你在哪些场景会选择漏桶,哪些场景会选择令牌桶呢?欢迎你举例来分享。
欢迎你在留言区发表你的看法。如果这节课对你有帮助,也推荐你分享给更多的同事、朋友。