限流系统可以对并发访问进行限速,对超出阈值的请求可以拒绝或等待,防止瞬间高并发请求导致系统崩溃或性能下降,保障系统的可用性和稳定性。
单机限流算法
本文首先从限流算法的提供的接口开始,因为限流算法会根据接口的不同分成不同的情况。
限流算法提供的接口通常分为两种 acquire()
和 tryAcquire()
,两者的目的都是尝试获取执行一项操作的令牌或资格,但对于资格已被耗尽的情况,调用 acquire()
时会阻塞直到获得允许,而 tryAcquire()
会立刻返回结果拒绝请求。前者通常用于网络包处理的场景,这些场景可以接受阻塞性操作,而后者通常用于需要快速响应,避免线程阻塞的互联网应用中。
两者的实现方式有区别,acquire()
方式在内部通常比tryAcquire()
多一个阻塞队列,当有一个新线程请求令牌时,这个线程就会被加入等待队列中并挂起,直到有新的令牌可用时被唤醒。
漏斗算法
漏斗算法可以形象地理解为,有一个漏斗,顶部接收流入的请求,底部以恒定的速率流出(处理)请求。算法的特点是请求可以以任意速率进入漏斗,但以恒定的速率流出,如果流入的太多超过了漏斗的容量则丢弃。
若要实现一个acquire()
接口的漏斗算法,需要用一个队列来模拟漏斗,每次有新请求到达时,根据队列容量判断拒绝请求还是等待执行。同时还需要有另一个线程从队列中以固定速率取出请求并处理。
对于 tryAcquire()
的情况,实现就比较简单了:只在 tryAcquire()
调用时判断与上一次请求的间隔是否满足达到了一个时间区间。非阻塞的算法通过确保两个请求之间的间隔至少为interval,违背了匀速通过的本意,所以漏斗算法不适合 tryAcquire()
接口。
漏桶算法简单易懂,易于实现,但它也有一些局限性,比如无法处理突发流量的峰值,以及 acquire() 接口可能导致数据延迟等问题。
令牌桶算法
有一个存放令牌的桶,令牌以恒定的速率被放入,桶满了,新令牌会被丢弃。另有一个保存请求的队列,每个请求被处理都会消耗一个令牌,如果令牌桶空了,请求需要等待。令牌放入速率代表系统的处理速率,令牌桶的大小代表了突发流量的处理能力。
tryAcquire()
接口的令牌桶实现比较简单:
- 首先根据当前时间与上一个请求的时间之差,计算应该放入多少令牌;
- 如果令牌数大于1,则令牌数减去1,然后返回 true 允许通过,否则返回 false
acquire()
接口的令牌桶算法实现比较复杂:
- 与漏斗算法类似,需要一个队列暂存请求;
- 创建一个 Processor 线程,不断地从队列中取出请求,当令牌数为0时线程睡眠,直到有新令牌。令牌的增加可以写在 Processor 的处理逻辑中。
令牌桶算法相对于漏斗算法增加了突发流量的处理能力。
窗口限流
固定窗口限流
该算法把连续的时间划分成等长的时间窗口,然后限制时间窗口内的请求数。假如我们希望限制每分钟通过5个请求,那么可以把时间窗口长度设置为1分钟,限制时间窗口内通过5个请求。
但这个算法存在限流不准确的问题,比如在某个时间窗口的后半部分和下一个时间窗口的前半部分通过了很多请求,那么在这两个部分组成的新时间窗口内,通过的请求数超过了限制。
tryAcquire()
的实现方式比较简单,只需要一个变量存储当前时间窗口内已通过的请求数。当遇到新请求时,如果已进入下一个窗口,则该变量归零,然后根据变量是否超限拒绝和同意请求。
acquire()
的需求不常见,但实现方式与上面的算法类似,都可以通过一个队列和一个Processor线程实现。
滑动窗口限流
为了改善限流不准确的问题,出现了滑动窗口限流算法。
假如仍然需要限制每分钟5个请求,并且把时间片设置为10秒,这样1分钟有6个时间片,所以连续的6个时间片请求数不超过5个。随着时间的推移,时间窗口也要向右移动,假如时间过去10秒,窗口范围就要向右平移1格。这就是滑动窗口限流的原理。
使用更小的时间片可以更大程度的缓解限流不准确的问题,以上图中的极端情况为例,虽然每个长度为6的时间窗口请求数都没超过5,但是却出现了50秒内10个请求的情况, <math xmlns="http://www.w3.org/1998/Math/MathML"> QPS = 2 × 5 60 − 60 / 6 \text{QPS}={2\times 5\over{60-{60 / 6}}} </math>QPS=60−60/62×5,当6变大时,QPS会降低,限流更准确。
tryAcquire()
的实现:在环形数组中每个时间片的请求数,便于实现窗口的滑动。有新的请求时先按需进行窗口滑动,重新计算当前窗口内的请求总数,然后据此判断是否允许新请求。
acquire()
的实现略去。
动态限流
以上限流算法都需要手动指定限流的参数,但很多时候很难给出一个合适的值,原因包括:不同种类请求的数据访问模式有差异、数据库的性能变化以及不同时间的请求量不同等等。
所以下一个问题是如何动态调整限流的参数。联想到TCP的拥塞控制算法根据网络延迟和性能自动调整滑动窗口的大小,动态限流可以模仿这种方式。
"根据网络延迟"可以改为根据最近一段时间内(比如10秒)响应时间的P90或P99进行判断,当P90超过阈值时,应该降低限流QPS。
其次是作何调整。一种方法是类似拥塞控制算法收到重复应答一样,将QPS设置为一半,然后慢启动,直到响应时间又超过限制。将QPS降低1/4,再慢启动·,将QPS降低1/8,最后限制QPS会在一个值上小幅振动,使QPS能适应后端的最大性能。
分布式限流
概述
限流服务通常部署在网络请求的入口,所以需要满足高并发、高性能、高可用和可扩展的特点,尽可能的减少对用户程序的影响。在限流的准确性上,要根据场景选择精确限流或者大致准确。在阻塞性上,分布式限流通常采用非阻塞的 tryAcquire()
接口,因为非阻塞不会导致系统的响应延迟增加和线程堆积问题。
限流系统可以分为集中状态限流无状态限流。集中状态限流对Redis等分布式存储系统的要求比较高,但是通过记录全局状态可以实现精确限流。无状态限流由各节点根据本地状态进行限流,一般无法实现准确限流,但能在总体上限制流量,保障系统的可用性,集成和部署简单,投入成本低。
算法
上面的那些算法可以如果要实现集中状态限流,需要进行哪些改造?
对于令牌桶算法,只需要全局保存时间戳和令牌数。在算法执行时会涉及到时间戳和令牌数的读写,要注意加锁,如果用 Redis,可以通过 lua 脚本实现原子操作,但是这样做会引入一定的复杂性。
固定窗口限流的实现比较简单,可以将时间戳作为 key,请求数量作为 value,如果 value 超过限制就进行限流。可以用 Redis 的INCR
命令进行原子性的递增。
单机的滑动窗口限流可以用环形数组,如果在 Redis 中仍然用这个方法,需要些比较复杂的 lua 脚本。另一种实现方式是使用 Redis 的有序集合 ZSET 将所有请求的时间戳记录下来,然后用ZREMRANGEBYSCORE
移除时间窗口之外的请求,用 ZCARD
获取集合中的元素数量作为当前窗口的请求总数,据此判断是否拒绝新请求。
漏斗算法的目的是平滑输出流量,适合实现acquire()
接口,可以用 LIST 结构保存所有请求,当有新请求时先判断列表的长度是否超出阈值再插入,然后在代码中定时从 Redis 中取出请求进行处理。