0. 背景

在开发分布式高并发系统时,有三种常用的保护系统的手段:缓存、降级、限流

  • 缓存:在访问数据库之前引入缓存,对一部分热度高的请求直接从缓存中获取目标数据,从而减少计算量,提升吞吐。
  • 降级:当服务器压力剧增的情况下,通过根据当前业务情况和流量对一些服务和页面有策略的降级来保证核心任务的正常运行。
  • 限流:可以认为是降级的一种,通过限制系统的输入和输出流量来保护系统,被限制的流量可以采取不同的措施,例如延迟处理、拒绝处理或者拒绝部分处理。

本文讲的主要是关于需要限流的场景下使用的一些常见算法,主要分为:固定窗口、滑动窗口、漏桶算法、令牌桶

1. 什么是优秀的限流算法

在了解算法的具体实现之前,我们应该先搞清楚什么样的算法才是优秀的限流算法?
我认为限流的两大主要功能:

  1. 通过限制峰值流量来防止服务器负载过高(负载过高会导致处理延迟增大,队列堵塞,如果配置不合理的话甚至会触发OOM)
  2. 通过限制一段时间内的流量来防止短时间的流量上升,达到平滑流量的目的。

因此,在一个优秀的限流算法下,理想的流量曲线应该是这样的,如下图,实际流量以小幅的波动低于限流阈值(是小幅低于还是大幅要取决于具体的业务场景)。

这里说的理想曲线主要是针对于限流阈值用作稳定性兜底的场景,如果是离线的数据处理,要求的是极高的吞吐量的同时拥有对延迟的高容忍度,那理想的流量曲线可能是跟限流阈值重合在同一根线上。

java 分布式 限流 分布式限流方案_限流

2. 固定窗口

固定窗口时比较简单常见的限流算法,其中窗口指的是限流场景中的时间单元。

固定窗口的原理是将时间线划分为多个独立且固定大小的窗口,对于每个窗口有其固定的限流阈值,每次落在窗口内的请求都会让计数器加一,当计数器超过限流阈值之后,该时间窗口内的所有请求都会被拒绝,直到下一个时间窗口开始,计数器又会被重置为0.

java 分布式 限流 分布式限流方案_java 分布式 限流_02


固定窗口的原理和实现方式都很简单,那回过头来看,他能够满足我们的需求,形成我们的理想流量曲线吗?

结论是显然不行,当请求流量远远高于窗口限定的阈值时,流量曲线大概率会变成如下图所示。这种流量的输入会有两个缺点

  1. 固定的时间窗口在短时间内被打满,打满了之后系统开始空载,直到下个流量窗口的开启。这么做会导致系统波动极大,对系统的稳定性可能在造成很大的影响。
  2. 当一段流量在上一个时间窗口的末尾输入时,上一个时间窗口的后半部分和这一个时间窗口的前半部分组成的窗口,会达到最高2倍的流量阈值,也就是固定窗口算法下,并非任意一点开始的时间窗口都能保证这段时间的流量在限流阈值以下。

java 分布式 限流 分布式限流方案_限流_03

3. 滑动窗口

滑动窗口在固定窗口的基础上做了改进,目的是为了保证任意时间开始的时间窗口都不会超过阈值。
相比固定窗口来说,滑动窗口除了引入计数器意外,还需要记录时间窗口每个请求达到的时间点。当遇到请求时采用以下步骤

  1. 记录请求时间
  2. 根据请求时间往前推一个滑动窗口的时间范围,统计这个范围内的请求数,并且删除超过窗口的数据
  3. 统计到的请求数就是这个窗口的容量,判断是否小于阈值,小于就在窗口中记录统计时间,反之则直接拒绝

java 分布式 限流 分布式限流方案_限流_04


这么做能够解决刚才固定窗口的第二个问题,也就是临界值问题,在滑动窗口的限制下,能够保证任意一段时间内的流量都小于设定的阈值。

但是滑动窗口能达到实际流量曲线吗?答案也是否定的,滑动窗口会面临和固定窗口一样的问题,他们都无法应对短时间的流量上涨,在流量突然上升之后会出现一段流量在短时间内迅速占满窗口的情况。

  • 如果流量是增长之后维持不变,滑动窗口会立刻打满窗口,并在之后以每次开放的时间区间进行小流量的放开,情况要比固定窗口好一些。
  • 如果流量是一阵一阵的来的话,那滑动窗口就和固定窗口一样,都是每次被很高的流量打满窗口之后开始空载。

4. 漏桶算法

漏桶算法的原理与名字一样,可以认为是往水桶中漏水和注水的过程。
往一个漏桶中以任意速率注水,以固定的速率流出水,当水超过桶的容量的时候就会溢出(丢弃)。

  • 流入的水滴可以看做是访问系统的流量
  • 桶的容量指的是系统所能处理的请求数
  • 流出的水滴指服务按照固定速率处理请求

这么做能形成理想的限流曲线吗?其实是可以的,漏桶宽进严出的策略保证了输出的流量永远不会超过流量阈值,相对的,其实现复杂度和维护复杂度相比前两种也更高一些。

java 分布式 限流 分布式限流方案_缓存_05

5. 令牌桶

漏铜算法固已经很优秀了,但是在一些场景下也有其不足,其限制了服务的最大输入流量,这会有什么问题呢?

  • “最大输入流量”如果超过了服务的最大负载量时,则服务永远处于满载状态,流控功能几乎失效
  • “最大输入流量”如果小于服务的最大负载量,则服务永远无法满载,及时在流量突增情况下,也没法用上所有的资源去应对所有流量,只能受限于漏桶的速率。
  • “最大输入量”等于最大负载量呢?实际处理中,这几乎是不可能的,系统的处理能力会受到请求的内容、机器的资源使用情况等多种实时因素影响,实际配置中很难将两个配置恰好对齐。

既然流量阈值很难做到与最大输入流量对齐,我们不妨退而求其次,允许突发的流量短暂且可控的超过限流阈值,以此来用系统的多余负载量来消化突增的流量这就是令牌桶。

java 分布式 限流 分布式限流方案_滑动窗口_06


令牌桶会匀速向桶中放入令牌,请求来了之后先向桶中索取令牌,只有索取成功的请求能够被处理。

这么做乍一看和漏桶几乎一致,实际上区别在于令牌桶在输入流量小的时候会往桶中积攒一些存量的令牌,待到突发流量的时候会消耗这批存量的令牌,因为令牌有上限,因此突发流量的最大速率也可控,因此令牌桶的理想曲线如下。

java 分布式 限流 分布式限流方案_缓存_07

6. 总结

虽然桶类算法在“理想曲线”这一项目上比时间窗口算法好太多了,实际上时间窗口算法也有其优势

  1. 时间窗口算法的实现和维护更为简单
  2. 漏桶的请求会被暂存在桶种,因而他对被限流的数据必须做出“延迟处理”而无法直接拒绝,因此在部分需要做到低延时的业务中,漏桶并不是好的选择
  3. 相对的,令牌桶无请求缓存,拿不到令牌的请求会被直接丢弃,在刚上线的过程中如果没有预热,可能会对一些请求误杀。

总结来看,令牌桶和漏桶更适合阻塞式限流,而时间窗口则更适合需要快速处理,及时响应的场景。