声明:本文仅做学习使用。
在网络系统中,限流器用于控制客户端或服务发送的流量速率。在HTTP世界中,限流器限制了在指定时间段内允许发送的客户端请求的数量。如果API请求计数超过限速器定义的阈值,则所有超出的调用都将被阻塞。下面是一些例子:
- 个用户每秒最多可以写2篇文章。
- 每天最多可以从同一个IP地址创建10个帐户。
- 每周从同一设备领取奖励不超过5次。
在本章中,要求你设计一个限流器。在开始设计之前,我们首先看看使用API限流器的好处:
- 防止Denial of Service(DoS)攻击导致的资源饥饿。几乎所有大型科技公司发布的api都执行了某种形式的限流。例如,Twitter将tweet的数量限制为每3小时300条。Google docs api有以下默认限制:每个用户每60秒300个读请求。限流器通过阻止多余的调用来防止DoS攻击,无论是有意的还是无意的。
- 降低成本。限制多余请求意味着减少服务器数量,并为高优先级api分配更多资源。限流对于使用付费第三方api的公司来说非常重要。例如,对以下外部api按每次调用收费:检查信用额度、付款、检索健康记录等。限制通话次数对降低成本至关重要。
- 防止服务器过载。为了减少服务器负载,使用限流器过滤掉由机器人或用户不当行为引起的多余请求。
第一步 - 理解问题并确定设计的范围
限流可以使用不同的算法来实现,每种算法都有其优点和缺点。 面试官和候选人之间的互动有助于明确我们试图建立的限流的类型。 候选人:我们要设计什么样的限速器?它是客户端限流器还是服务器端API限流器? 面试官:问得好。我们关注的是服务器端API限流器。
候选:限流器是否基于IP、用户ID或其他属性限制API请求? 面试官:限流器应该足够灵活,以支持不同的节流规则。
候选人:系统的规模是多少?它是为初创公司还是为拥有庞大用户基础的大公司打造的? 面试官:系统必须能够处理大量的请求。
候选人:系统能在分布式环境中工作吗? 面试官:是的。
候选人:限流器是单独的服务还是应该在应用程序代码中实现? 面试官:这是你的设计决定。
候选人:我们需要通知被限流的用户吗? 面试官:是的。
需求
这里应该是限流服务的需求总结。
- 低延迟。限流服务不应该使得HTTP响应时间变慢。
- 使用的内存尽可能少。
- 分布式的限流服务。限流服务应该可以在多个服务系统或者进程之间共享。
- 异常处理。当用户的请求被限制的时候,可以向用户进行清晰的提示。
- 高容错性。如果限流服务有任何的问题(比如,缓存服务器掉线),它也不会影响整个系统的运行。
提出顶层设计并获得面试官的认可
让我们从简单开始,首先使用基本的客户端和服务器模型进行通信。
限流服务应该放在哪里?
直观地说,您可以在客户端或服务器端实现限流。
- 客户端实现。一般来说,客户机是一个不可靠的执行限流的地方,因为客户机请求很容易被恶意行为者伪造。此外,我们可能无法控制客户端实现。
- 服务器端实现。服务器端限速器如图4-1所示
- 除了客户机和服务器端实现之外,还有一种替代方法。我们没有在API服务器上设置限流服务,而是创建了一个限流中间件,它会限制对API的请求,如图4-2所示。
- 让我们用图4-3中的一个例子来说明在这种设计中限流服务是如何工作的。
假设我们的API允许每秒2个请求,并且客户端在一秒钟内向服务器发送3个请求。前两个请求被路由到API服务器。但是,限流中间件限制第三个请求并返回HTTP状态码429。HTTP 429响应状态码表示用户发送了过多的请求。
微服务已经广泛流行,限流功能通常在一个称为API网关的组件中实现。API网关是一个完全托管的服务,支持限流、SSL中断、身份验证、IP白名单、静态内容服务等。现在,我们只需要知道API网关是一个支持限流的中间件。
在设计限流功能时,要问自己的一个重要问题是:限流功能应该在哪里实现,在服务器端还是在网关中?没有绝对的答案。
这取决于你公司当前的技术栈、项目资源、优先级、目标等。以下是一些一般性的指导原则:
- 现有的技术栈,如编程语言、缓存服务等。确保当前语言能够有效地在服务器实现限流。
- 确定适合业务的限流算法。自己实现的限流服务可以完全控制限流算法。但是使用第三方网关的话,选择可能会受限。
- 如果使用微服务架构的,并且在设计中包含API网关来执行身份验证、IP白名单等功能的话可以直接在API网关中添加限流功能。
- 建立自己的限流服务是需要时间的。如果没有足够的时间和资源来实现限流功能,那么商业的API也是很好的选择。
第二步 限流算法
- 令牌桶
- 漏桶
- 固定窗口计数器
- 滑动窗口日志
- 滑动窗口计数
令牌桶算法
令牌桶算法被广泛用于限流。它简单易懂,被互联网公司普遍使用。Amazon和Stripe都使用这种算法来限制它们的API请求。
令牌桶算法的工作原理如下:
- 令牌桶是具有预定义容量的容器。令牌以预设的速率周期性地放入桶中。一旦桶填满,就不会再添加令牌。如图4-4所示,令牌桶容量为4。填充器每秒将2个令牌放入桶中。一旦桶满了,额外的令牌就会溢出,保持桶里面的最大令牌数。
- 每个请求消耗一个令牌。当请求到达时,我们检查桶中是否有足够的令牌。原理如图4-5所示。
- 如果有足够的令牌,我们为每个请求取出一个令牌,然后请求通过。
- 如果没有足够的令牌,请求将被丢弃。
图4-6说明了令牌消耗、填充和限流逻辑的工作原理。在本例中,令牌桶大小为4,填充速率为每1分钟4个。
令牌桶算法接受两个参数:
-
桶大小:桶中允许的最大令牌数量
-
重新填充速率:每秒放入桶中的令牌数量 我们需要多少个桶?这取决于限流规则。这里有几个例子。 对于不同的API端点,通常需要有不同的桶。例如,如果允许用户每秒发布1个帖子,每天添加150个好友,每秒发布5个帖子,那么每个用户就需要3个bucket。
-
如果我们需要基于IP地址节流请求,每个IP地址需要一个桶。
-
如果系统允许每秒最多10,000个请求,那么所有请求共享一个全局桶是有意义的。
优点:
-
该算法易于实现。
-
内存效率高。
-
令牌桶允许短时间的流量爆发。只要有令牌剩余,请求就可以通过。
缺点:
- 算法中有两个参数:桶大小和令牌填充率。然而,正确地调整它们可能具有挑战性。
漏桶算法
泄漏桶算法类似于令牌桶,不同之处在于请求是以固定速率处理的。它通常使用先进先出(FIFO)队列来实现。算法的工作原理如下:
-
当请求到达时,系统检查队列是否已满。如果未满,则将请求添加到队列中。
-
否则,请求将被丢弃。
-
请求从队列中取出并按照相同的间隔处理。
实现原理如图4-7所示。
泄漏桶算法接受以下两个参数:
-
桶大小:与队列大小相等。队列以固定的速率保存要处理的请求。
-
流出率:它定义了在固定速率下可以处理多少请求,通常以秒为单位。
电子商务公司Shopify使用漏桶来限制费率。
优点:
-
考虑到有限的队列大小,内存效率高。
-
请求以固定速率处理,因此适合需要稳定流出速率的用例。
缺点:
-
突发的流量会使队列中充满旧的请求,如果它们没有得到及时处理,那么最近的请求将受到限流。
-
算法中有两个参数。正确地调整它们可能并不容易。
固定窗口算法
固定窗口计数器算法的工作原理如下:
-
该算法将时间轴划分为固定大小的时间窗口,并为每个窗口分配一个计数器。
-
每个请求将计数器加1。
-
一旦计数器达到预定义的阈值,新的请求将被丢弃,直到一个新的时间窗口开始。
让我们用一个具体的例子来看看它是如何工作的。在图4-8中,时间单位为1秒,系统每秒最多允许3个请求。如图4-8所示,在每个second窗口中,如果收到超过3个请求,则会丢弃多余的请求。
该算法的一个主要问题是,时间窗口边缘的流量突发可能导致请求数量超过允许的配额。考虑以下情况:
在图4-9中,系统每分钟最多允许5个请求,可用配额会在“人性化轮分”时重置。可以看到,在2:00:00到2:01:00之间有5个请求,在2:01:00到2:02:00之间有5个请求。在2:00:30到2:01:30之间的一分钟窗口中,有10个请求通过。这是允许请求数量的两倍。
优点:
- 内存效率高。
- 易于理解。
- 在单位时间窗口结束时重置可用配额适合某些用例。
缺点:
- 窗口边缘的流量峰值可能导致更多的请求超过允许的限制。
滑动窗口测井算法
如上所述,固定窗口计数器算法有一个主要问题:它允许更多的请求通过窗口的边缘。滑动窗口日志算法解决了这个问题。它的工作原理如下:
- 该算法跟踪请求时间戳。时间戳数据通常保存在缓存中,例如Redis的sortedSet(如何使用redis中的zset实现滑动窗口限流_Redis_脚本之家 (jb51.net)。
- 当一个新请求进来时,删除所有过时的时间戳,[ZCARD的时间复杂度](Redis中ZSET的ZCARD操作的时间复杂度_redis zset zcard-CSDN博客)过期时间戳定义为比当前时间窗口的开始时间更早的时间戳。
- 在日志中添加新请求的时间戳。
- 如果日志大小等于或低于允许的数量,则接受请求。否则,它将被拒绝.
我们用图4-10所示的示例来解释该算法。
在本例中,限流器每分钟允许2个请求。通常,Linux时间戳存储在日志中。然而,为了更好的可读性,我们的示例中将时间戳转化为可读的时间表示。
当新请求在1:00:01到达时,日志为空。因此,请求是允许的。
- 一个新的请求在1:00:30到达,时间戳1:00:30被插入到日志中。插入后,日志大小为2,不大于允许的计数。因此,请求是允许的。
- 一个新的请求在1:00:50到达,时间戳插入到日志中。插入后,日志大小为3,大于允许的大小2。因此,即使时间戳保留在日志中,该请求也会被拒绝。
- 一个新的请求在1:01:40到达。在[1:00:40,1:01:40]范围内的请求在最近的时间范围内,但是在1:00:40之前发送的请求是过期的。两个过时的时间戳,1:00:01和1:00:30,从日志中删除。删除操作后,日志大小变为2;因此,请求被接受。
优点:
- 该算法实现的限流非常准确。在任何滚动窗口中,请求都不会超过限流。
缺点:
- 该算法消耗大量内存,因为即使请求被拒绝,其时间戳仍可能存储在内存中。
滑动窗口计数算法
滑动窗口计数器算法是固定窗口计数器和滑动窗口日志相结合的一种混合方法。该算法可以通过两种不同的方法实现。我们将在本节中解释其中一个实现,并在本节末尾为另一个实现提供参考。实现原理如图4-11所示。
假设限流器每分钟最多允许7个请求,前一分钟有5个请求,当前一分钟有3个请求。对于在当前分钟内到达30%位置的新请求,滑动窗口中的请求数使用以下公式计算:
- 当前窗口的请求数+前一个窗口的请求数*滑动窗口与前一个窗口的重叠百分比
- 使用这个公式,我们得到3 + 5 * 0.7% = 6.5个请求数。根据用例的不同,这个数字可以四舍五入也可以四舍五入。在我们的示例中,它被四舍五入到6。 由于限流器每分钟最多允许7个请求,因此当前请求可以通过。但是,在再收到一个请求后,将达到该限制。
由于篇幅限制,我们将不在这里讨论另一个实现。有兴趣的读者可参考参考资料1这个算法并不完美。它有利有弊。 优点
- 它平滑了流量峰值,因为速率是基于前一个窗口的平均速率。
- 内存效率高。
缺点
- 它只适用于不太严格的后视窗。它是实际速率的近似值,因为它假设前一个窗口中的请求是均匀分布的。然而,这个问题可能并不像看起来那么糟糕。根据Cloudflare的实验,在4亿个请求中,只有0.003%的请求被错误允许或限流。
第二种滑动窗口计数方法
- 这是固定窗口计数器和滑动窗口日志的混合体
- 整个窗口时间被分解为更小的存储桶。每个存储桶的大小取决于限流器允许的弹性
- 每个存储桶存储与存储桶范围对应的请求计数。
例如,为了构建 100 req/hr 的限流器,假设选择了 20 分钟的存储桶大小,那么1hour 内有 3 个存储桶
对于凌晨 2 点到 3 点的窗口时间,存储桶是
{
"2AM-2:20AM": 10,
"2:20AM-2:40AM": 20,
"2:40AM-3:00AM": 30
}
如果在凌晨 2:50 收到请求,我们会找出包括当前请求在内的最近 3 个存储桶中的总请求数并将它们相加,在本例中,它们的总和为 60 (<100),因此将一个新请求添加到凌晨 2:40–3:00 的存储桶中,给予...
{
"2AM-2:20AM": 10,
"2:20AM-2:40AM": 20,
"2:40AM-3:00AM": 31
}
注意: 这不是完全正确的,例如:在 2:50 时,应考虑从 1:50 到 2:50 的时间间隔,但在上面的示例中,不考虑前 10 分钟,并且可能会发生这种情况,在错过的 10 分钟内,可能会出现流量高峰,请求计数可能为 100,因此请求将被拒绝。但是通过调整存储桶大小,我们可以达到理想限流器的近似值。
空间复杂度:O(桶数)
时间复杂度: O(1) — 获取最近的存储桶,递增并检查存储桶的总和(可以存储在 totalCount 变量中)。
优点
- 由于仅存储计数,因此不会占用大量内存
缺点
- 仅适用于不太严格的回溯窗口时间,尤其是较小的单位时间
顶层结构设计
限流算法的基本思想很简单。在高层,我们需要一个计数器来跟踪从同一用户、IP地址等发送的请求的数量。如果计数器大于限制,则不允许请求。
我们把计数器放在哪里?由于磁盘访问速度很慢,使用数据库不是一个好主意。之所以选择内存缓存,是因为它速度快,并且支持基于时间的过期策略。例如,Redis是实现限流的常用选项。它是一个内存存储,提供两个命令:INCR和EXPIRE。
- INCR:使存储的计数器增加1。
- EXPIRE:设置计数器的超时时间。超时后,计数器将被自动删除。
图4-12显示了限流中间件的高级架构,其工作原理如下:
客户端向限流中间件发送请求。
- 限流中间件从Redis中相应的bucket中获取计数器,并检查是否达到限制。
- 如果达到限制,请求将被拒绝。
- 如果没有达到限制,请求被发送到API服务器。同时,系统增加计数器并将其保存回Redis
第三步 设计底层实现
图4-12中的高级设计没有回答以下问题:
- 限流规则是如何创建的?规则存储在哪里?
- 如何处理限流的请求?在本节中,我们将首先回答有关限流规则的问题,然后讨论处理限流请求的策略。最后,我们将讨论分布式环境下的限流、详细设计、性能优化和监控。
限流规则
Lyft开源了他们的限速组件。我们将看到组件内部的一些限流规则的例子。
domain:messaging
descriptors:
—key: message_type
Value: marketing
rate_limit:
unit: day
requests_per_unit: 5
在上面的示例中,系统配置了每天最多允许发送5条消息。例如:
domain: auth
descriptors:
—key: auth_type
Value: login
rate_limit:
unit: minute
requests_per_unit: 5
该规则表示客户端在1分钟内登录次数不能超过5次。规则通常写在配置文件中并保存在磁盘上。
超限流限制提示
如果请求受到速率限制,api将向客户端返回HTTP响应码429(请求过多)。根据用例的不同,我们可能会将速率受限的请求排队,以便稍后处理。例如,如果某些订单由于系统过载而受到速率限制,我们可能会将这些订单保留到以后处理。
限流的headers
客户端如何知道它是否正在被节流?客户机如何知道在被限制之前允许的剩余请求数?答案在HTTP响应头中。速率限制器向客户端返回以下HTTP头:
x - ratlimit - remaining:窗口内允许的剩余请求数。
x - ratlimit - limit:表示客户端每个时间窗口可以调用多少次。
x - ratlimit - retry - after:等待的秒数,直到您可以再次发出请求而不被限制。
当用户发送过多请求时,向客户端返回429 too many requests错误和X-RatelimitRetry-After报头。
详细设计
系统详细设计如图4-13所示。
- 规则存储在磁盘上。工作器经常从磁盘中提取规则并将其存储在缓存中。
- 当客户端向服务器发送请求时,请求首先被发送到限流中间件。
- 限流中间件从缓存加载规则。它从Redis缓存中获取计数器和最后一次请求的时间戳。基于响应信息,限流中间件决定:
- 如果请求没有被限流,则将其转发到API服务器。
- 如果请求被限流了,限流中间件返回429请求过多错误给客户端。同时,请求要么被丢弃,要么被转发到消息队列。
分布式环境的限流服务
构建一个在单个服务器环境中工作的速率限制器并不困难。但是,扩展系统以支持多个服务器和并发线程是另一回事。
有两个挑战:
- 竞争条件
- 同步问题
竞态条件
如上所述,速率限制器在高级别的工作如下:
-
从Redis读取计数器值。
-
检查(counter + 1)是否超过阈值。
-
如果不是,在Redis中将计数器值增加1。
如图4-14所示,在高度并发的环境中可能会出现竞态条件。
假设Redis中的计数器值为3。如果两个请求并发地读取计数器值,然后其中一个将其写回,则每个请求将计数器增加1并将其写回,而不检查另一个线程。两个请求(线程)都认为它们有正确的计数器值4。但是,正确的计数器值应该是5。
锁是解决竞争条件最明显的解决方案。但是,锁会显著降低系统的速度。通常采用两种策略来解决这个问题:Lua脚本和Redis中的sorteSet数据结构。对这些策略感兴趣的读者可以参考相应的参考资料。
同步的问题
同步是分布式环境中需要考虑的另一个重要因素。为了支持数百万用户,一个速率限制器服务器可能不足以处理流量。
当使用多个速率限制器服务器时,需要同步。例如,在图4-15左侧,客户端1向限速器1发送请求,客户端2向限速器2发送请求。由于web层是无状态的,客户端可以向不同的限速器发送请求,如图4-15右侧所示。如果没有同步,速率限制器1不包含客户端2的任何数据。因此,速率限制器不能正常工作。
一种可能的解决方案是使用粘性会话,允许客户端将流量发送到相同的速率限制器。这种解决方案是不可取的,因为它既不具有可伸缩性也不灵活。更好的方法是使用像Redis这样的集中式数据存储。外观设计如图4-16所示。
性能优化
性能优化是系统设计面试中的一个常见话题。我们将从两个方面进行改进。
首先,多数据中心设置对于速率限制器至关重要,因为对于远离数据中心的用户来说,延迟很高。大多数云服务提供商在世界各地建立了许多边缘服务器位置。例如,截至2020年5月,Cloudflare拥有194个地理分布的边缘服务器。流量自动路由到最近的边缘服务器,以减少延迟。
其次,用最终的一致性模型同步数据。如果您不清楚最终的一致性模型,请参阅“第6章:设计键值存储”中的“一致性”部分。
监控
在速率限制器放置到位后,收集分析数据以检查速率限制器是否有效是很重要的。首先,我们要确保:
-
限流算法是有效的。
-
限速规则是有效的。
例如,如果限速规则过于严格,许多有效请求将被丢弃。在这种情况下,我们想要稍微放松一下规则。在另一个例子中,我们注意到当流量突然增加(如限时销售)时,我们的速率限制器变得无效。在这种情况下,我们可以替换算法来支持突发流量。令牌桶在这里很合适。
第四步 打包
在本章中,我们讨论了不同的速率限制算法及其优缺点。
讨论的算法包括:
-
令牌桶
-
泄漏桶
-
固定窗口
-
滑动窗口日志
-
滑动窗口计数器 然后,我们讨论了系统架构、分布式环境下的速率限制器、性能优化和监控。与任何系统设计面试问题类似,如果时间允许,你也可以提到一些额外的话题: 硬/软速率限制。
-
硬:请求数不能超过阈值。
-
软:短时间内可以超过阈值。
不同级别的速率限制。在本章中,我们只讨论了应用层的速率限制(HTTP:第7层)。在其他层也可以应用速率限制。例如,您可以使用Iptables (IP: layer 3)应用IP地址限制速率。
注:开放系统互连模型(OSI模型)有7层:第1层:物理层,第2层:数据链路层,第3层:网络层,第4层:传输层,第5层:会话层,第6层:表示层,第7层:应用层
为了避免受到速率限制。
-
使用客户端缓存来避免频繁调用API。
-
了解限制,不要在短时间内发送太多请求。
-
包含捕获异常或错误的代码,以便您的客户端可以从异常中优雅地恢复。
-
为重试逻辑添加足够的后退时间。
-
恭喜你走了这么远!现在给自己点鼓励吧。Good job!