如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)

在网络系统中,速率限制器被用来控制客户端或服务发送的流量的速率。在 HTTP 领域,速率限制器限制了在指定周期内允许发送的客户端请求的数量。如果 API 请求的数量超过了速率限制器定义的阈值,所有超出的调用都会被阻止。以下是一些示例:

  • 用户每秒钟最多只能发布2篇帖子。
  • 你可以每天最多从同一 IP 地址创建10个帐户。
  • 你可以每周最多从同一设备领取5次奖励。
AI不会取代你,使用AI的人会。欢迎关注我的公众号:更AI。以程序员的视角来看AI能带给我们什么~

在这一章中,你被要求设计一个速率限制器。在开始设计之前,我们首先看一下使用 API 速率限制器的好处:

  • 防止由拒绝服务(DoS)攻击[1]引起的资源耗尽。几乎所有大型科技公司发布的 API 都执行一些形式的速率限制。例如,Twitter 限制每3小时的推文数量为300条[2]。Google 文档的 API 默认限制为:对于读取请求,每个用户每60秒300个[3]。速率限制器通过阻止过量的调用来防止 DoS 攻击,无论是有意还是无意的。
  • 减少成本。限制过量的请求意味着更少的服务器和将更多的资源分配给高优先级的 API。对于使用付费第三方 API 的公司来说,速率限制极其重要。例如,你按次收费的外部 API 包括:查看信用,进行支付,获取健康记录等。限制调用次数是减少成本的关键。
  • 防止服务器过载。为了减少服务器负载,速率限制器被用来过滤由机器人或用户的不当行为引起的过量请求。

第一步 - 理解问题并确定设计范围

速率限制可以通过不同的算法来实现,每种算法都有其优点和缺点。面试官和面试者之间的交流有助于澄清我们试图构建的速率限制器的类型。

面试者:我们要设计的是哪种类型的速率限制器?是客户端速率限制器还是服务器端 API 速率限制器?
面试官:好问题。我们关注的是服务器端的 API 速率限制器。

面试者:速率限制器是基于 IP、用户 ID 还是其他属性来对 API 请求进行限流的?
面试官:速率限制器应该足够灵活,能支持不同的限流规则。

面试者:系统的规模如何?是为初创公司构建的,还是为拥有大量用户基础的大公司构建的?
面试官:系统必须能处理大量的请求。

面试者:系统会在分布式环境下工作吗?
面试官:是的。

面试者:速率限制器是一个单独的服务,还是应该在应用程序代码中实现?
面试官:这是由你来决定的设计决策。

面试者:我们需要通知被限流的用户吗?
面试官:是的。

需求

以下是系统的需求摘要:

  • 准确地限制过量的请求。
  • 低延迟。速率限制器不应降低 HTTP 响应时间。
  • 尽可能少地使用内存。
  • 分布式速率限制。速率限制器可以在多个服务器或进程之间共享。
  • 异常处理。当用户的请求被限流时,向用户显示清晰的异常。
  • 高容错性。如果速率限制器出现任何问题(例如,缓存服务器离线),它不会影响整个系统。

第二步 - 提出高层次设计并获得认可

让我们保持简单,使用基本的客户端和服务器模型进行通信。

应该在哪里放置速率限制器?

直观地说,你可以在客户端或服务器端实现速率限制器。

  • 客户端实现。一般来说,客户端是实施速率限制的不可靠地方,因为恶意参与者可以轻易地伪造客户端请求。此外,我们可能无法控制客户端的实现。
  • 服务器端实现。图 4-1 显示了放置在服务器端的速率限制器。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第1张图片

除了客户端和服务器端的实现,还有另一种方式。我们不是在 API 服务器上放置速率限制器,而是创建一个速率限制中间件,如图 4-2 所示,对你的 API 的请求进行限流。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第2张图片

让我们用图 4-3 中的一个例子来说明这种设计中的速率限制是如何工作的。假设我们的 API 每秒允许 2 个请求,客户端在一秒钟内向服务器发送 3 个请求。前两个请求被路由到 API 服务器。然而,速率限制中间件限流第三个请求并返回 HTTP 状态码 429。HTTP 429 响应状态码表示用户发送的请求过多。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第3张图片

云微服务[4]已经变得广受欢迎,速率限制通常在一个被称为 API 网关的组件内实现。API 网关是一个全面管理的服务,支持速率限制、SSL 终止、身份验证、IP 白名单、服务静态内容等。目前,我们只需要知道 API 网关是一个支持速率限制的中间件。

在设计速率限制器时,我们需要问自己一个重要的问题:速率限制器应该在服务器端实现还是在网关中实现?这个问题没有绝对的答案。它取决于你公司现有的技术栈、工程资源、优先事项、目标等。以下是一些一般性的指南:

  • 评估你当前的技术栈,如编程语言、缓存服务等。确保你当前的编程语言足够高效,能在服务器端实现速率限制。
  • 确定符合你业务需求的速率限制算法。当你在服务器端实现所有功能时,你可以完全控制算法。然而,如果你使用第三方网关,你的选择可能会受限。
  • 如果你已经使用了微服务架构,并在设计中包含了 API 网关来进行身份验证、IP 白名单等操作,你可以在 API 网关中添加速率限制器。
  • 自建你自己的速率限制服务需要时间。如果你没有足够的工程资源来实现速率限制器,商业 API 网关是一个更好的选择。

速率限制的算法

可以使用不同的算法实现速率限制,每种算法都有其独特的优点和缺点。虽然这一章并不专注于算法,但是高层次的理解有助于我们选择适合我们用例的正确算法或算法组合。下面是一些常见的算法:

  • 令牌桶
  • 漏桶
  • 固定窗口计数器
  • 滑动窗口日志
  • 滑动窗口计数器

令牌桶算法

令牌桶算法被广泛用于速率限制。它简单、易于理解,并且被互联网公司广泛使用。亚马逊 [5] 和 Stripe [6] 都使用这种算法来限制他们的 API 请求。

令牌桶算法的工作原理如下:

  • 令牌桶是一个具有预定义容量的容器。令牌按预设的速率定期放入桶中。一旦桶满,就不再添加令牌。如图4-4所示,令牌桶的容量是4。每秒填充器向桶中放入2个令牌。一旦桶满,额外的令牌将溢出。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第4张图片

  • 每个请求消耗一个令牌。当请求到达时,我们检查桶中是否有足够的令牌。图4-5解释了它是如何工作的。

    • 如果有足够的令牌,我们为每个请求取出一个令牌,请求通过。
    • 如果没有足够的令牌,请求将被丢弃。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第5张图片

图4-6说明了令牌消耗、填充和速率限制逻辑是如何工作的。在这个例子中,令牌桶的大小是4,填充速率是每1分钟4个。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第6张图片

令牌桶算法需要两个参数:

  • 桶大小:桶中允许的最大令牌数量
  • 填充速率:每秒钟放入桶中的令牌数量

我们需要多少个桶呢?这是不固定的,取决于限速规则。以下是一些例子。

  • 对于不同的API端点,通常需要有不同的桶。例如,如果用户每秒允许发表1篇帖子,每天添加150个好友,每秒点赞5篇帖子,每个用户需要3个桶。
  • 如果我们需要基于IP地址限制请求,那么每个IP地址需要一个桶。
  • 如果系统每秒钟允许最多10,000个请求,那么让所有请求共享一个全局桶是有意义的。

优点:

  • 算法易于实现。
  • 内存效率高。
  • 令牌桶允许在短时间内进行流量爆发。只要还有剩余的令牌,请求就可以通过。

缺点:

  • 算法中的两个参数是桶的大小和令牌填充速率。然而,正确地调整它们可能会很具挑战性。

漏桶算法

漏桶算法与令牌桶算法类似,只是请求以固定速率处理。它通常使用先进先出(FIFO)队列实现。该算法的工作原理如下:

  • 当请求到达时,系统检查队列是否已满。如果没有满,请求被添加到队列中。
  • 否则,请求将被丢弃。
  • 请求在规定的时间间隔内从队列中取出并处理。

图4-7解释了算法的工作原理。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第7张图片

漏桶算法需要以下两个参数:

  • 桶大小:等于队列大小。队列按固定速率保存待处理的请求。
  • 出流速率:它定义了可以按固定速率处理的请求的数量,通常以秒为单位。

Shopify,一家电子商务公司,使用漏桶算法进行限速 [7]。

优点:

  • 鉴于队列大小有限,内存效率高。
  • 请求按固定速率处理,因此适用于需要稳定出流速率的用例。

缺点:

  • 流量爆发将使队列充满旧的请求,如果它们没有及时处理,最近的请求将受到速率限制。
  • 算法中有两个参数。可能不容易正确地调整它们。

固定窗口计数器算法

固定窗口计数器算法的工作原理如下:

  • 该算法将时间线划分为固定大小的时间窗口,并为每个窗口分配一个计数器。
  • 每个请求将计数器增加一。
  • 一旦计数器达到预定义的阈值,新的请求将被丢弃,直到新的时间窗口开始。

让我们用一个具体的例子来看看它是如何工作的。在图4-8中,时间单位是1秒,系统每秒最多允许3个请求。在每个一秒的窗口中,如果接收到超过3个请求,额外的请求将被丢弃,如图4-8所示。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第8张图片

这种算法的一个主要问题是,在时间窗口的边缘出现的流量突增可能会导致通过的请求超过允许的配额。考虑以下情况:

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第9张图片

在图4-9中,系统每分钟最多允许5个请求,可用配额在整分钟时刻重置。如图所示,2:00:00至2:01:00之间有五个请求,2:01:00至2:02:00之间又有五个请求。对于2:00:30至2:01:30的一分钟窗口,通过了10个请求。这是允许请求的两倍。

优点:

  • 内存效率高。
  • 易于理解。
  • 在时间窗口结束时重置可用配额适合某些用例。

缺点:

  • 在窗口边缘的流量突增可能会导致通过的请求超过允许的配额。

滑动窗口日志算法

正如我们之前讨论过的,固定窗口计数器算法有一个主要问题:它在窗口的边缘允许通过更多的请求。滑动窗口日志算法解决了这个问题。它的工作原理如下:

  • 该算法记录请求的时间戳。时间戳数据通常保存在缓存中,例如Redis的排序集合 [8]。
  • 当一个新的请求进来时,删除所有过时的时间戳。过时的时间戳被定义为那些比当前时间窗口开始时间更早的时间戳。
  • 将新请求的时间戳添加到日志中。
  • 如果日志大小等于或小于允许的数量,请求被接受。否则,它会被拒绝。

我们用一个例子来解释这个算法,如图4-10所示。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第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说明了这种算法是如何工作的。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第11张图片

假设速率限制器每分钟允许最多7个请求,前一分钟有5个请求,当前分钟有3个请求。对于在当前分钟30%位置到达的新请求,滚动窗口内的请求数量是用以下公式计算的:

  • 当前窗口中的请求 + 上一个窗口中的请求 * 滚动窗口和上一个窗口的重叠百分比
  • 使用这个公式,我们得到3 + 5 * 0.7% = 6.5个请求。根据使用场景,这个数字可以四舍五入。在我们的例子中,它被舍入为6。

由于速率限制器每分钟允许最多7个请求,当前的请求可以通过。然而,再收到一个请求后,限制将被达到。

由于空间限制,我们不会在这里讨论另一种实现。感兴趣的读者应参考参考资料[9]。这个算法并不完美,它有优点和缺点。

优点:

  • 它能够平滑流量峰值,因为速率基于前一个窗口的平均速率。
  • 内存效率高。

缺点:

  • 它只适用于不那么严格的回溯窗口。它是实际速率的近似,因为它假设在前一个窗口中的请求是均匀分布的。然而,这个问题可能并不像看起来那么糟。根据Cloudflare [10]的实验,400 million请求中只有0.003%的请求被错误地允许或限速。

高级架构

速率限制算法的基本思想很简单。在高级别上,我们需要一个计数器来跟踪同一用户、IP地址等发送的请求数量。如果计数器的数值大于限制,那么请求就会被拒绝。

那我们应该在哪里存储计数器呢?由于磁盘访问速度慢,使用数据库不是一个好主意。因此选择了内存缓存,因为它快速并支持基于时间的过期策略。例如,Redis [11]是实现速率限制的一个流行选项。它是一个内存存储,提供了两个命令:INCR和EXPIRE。

  • INCR:它将存储的计数器增加1。
  • EXPIRE:它为计数器设置一个超时。如果超时到期,计数器会自动删除。

图4-12显示了速率限制的高级架构,其工作流程如下:

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第12张图片

  • 客户端向速率限制中间件发送请求。
  • 速率限制中间件从Redis中相应的存储桶获取计数器,并检查限制是否已经达到。
  • 如果达到了限制,请求将被拒绝。
  • 如果没有达到限制,请求将被发送到API服务器。同时,系统会增加计数器并将其保存回Redis。

第3步 - 深入设计

图4-12中的高级设计并没有回答以下问题:

  • 速率限制规则是如何创建的?规则存储在哪里?
  • 如何处理被限速的请求?

在本节中,我们将首先回答关于速率限制规则的问题,然后讨论处理被限速请求的策略。最后,我们将讨论分布式环境中的速率限制、详细设计、性能优化和监控。

速率限制规则

Lyft开源了他们的速率限制组件[12]。我们将深入这个组件并查看一些速率限制规则的例子:

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次。规则通常写在配置文件中并保存在磁盘上。

超过速率限制

如果一个请求受到了速率限制,APIs会向客户端返回HTTP响应码429(请求过多)。根据使用场景,我们可能会将受到速率限制的请求加入队列以便稍后处理。例如,如果一些订单由于系统过载而受到速率限制,我们可能会保留这些订单以便稍后处理。

速率限制器头

客户端如何知道它是否正在被限流?客户端如何知道在被限流之前允许的剩余请求数量?答案在HTTP响应头中。速率限制器向客户端返回以下HTTP头:

X-Ratelimit-Remaining:在窗口内允许的剩余请求数量。

X-Ratelimit-Limit:指示客户端每个时间窗口可以进行多少次调用。

X-Ratelimit-Retry-After:在不被限流的情况下,等待多少秒后可以再次发送请求。

当用户发送的请求过多时,会向客户端返回429过多请求错误和X-Ratelimit-Retry-After头。

详细设计

图4-13展示了系统的详细设计。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第13张图片

  • 规则存储在磁盘上。工作进程频繁地从磁盘中提取规则并将它们存储在缓存中。
  • 当客户端向服务器发送请求时,请求首先发送到速率限制中间件。
  • 速率限制中间件从缓存中加载规则。它从Redis缓存中获取计数器和上一个请求的时间戳。根据响应,速率限制器决定:

    • 如果请求没有被限速,它会被转发到API服务器。
    • 如果请求被限速,速率限制器返回429过多的请求错误给客户端。同时,请求被丢弃或转发到队列中。

分布式环境中的速率限制器

在单服务器环境中构建一个工作正常的速率限制器并不困难。然而,将系统扩展以支持多个服务器和并发线程是另一个挑战。有两个挑战:

  • 竞态条件
  • 同步问题

竞态条件

如前所述,速率限制器在高级别上按照以下方式工作:

  • 从Redis读取计数器的值。
  • 检查(计数器 + 1)是否超过阈值。
  • 如果没有,就在Redis中将计数器的值增加1。

在高度并发的环境中,可能会发生竞态条件,如图4-14所示。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第14张图片

假设Redis中的计数器值为3。如果在任何一个请求写回值之前,两个请求同时读取计数器值,每个请求都会将计数器增加1,然后在不检查其他线程的情况下写回。两个请求(线程)都认为他们拥有正确的计数器值4。然而,正确的计数器值应该是5。

锁是解决竞态条件的最明显的解决方案。然而,锁会显著降低系统的速度。通常用两种策略来解决这个问题:Lua脚本[13]和Redis中的有序集数据结构[8]。对这些策略感兴趣的读者,可以参考相应的参考资料8。

同步问题

在分布式环境中,同步是另一个需要考虑的重要因素。为了支持数百万用户,一个速率限制器服务器可能无法处理这样的流量。当使用多个速率限制器服务器时,需要进行同步。例如,在图4-15的左侧,客户端1向速率限制器1发送请求,客户端2向速率限制器2发送请求。

由于Web层是无状态的,客户端可以向不同的速率限制器发送请求,如图4-15的右侧所示。如果没有发生同步,速率限制器1就不包含关于客户端2的任何数据。因此,速率限制器无法正常工作。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第15张图片

一个可能的解决方案是使用粘性会话,允许客户端将流量发送到同一速率限制器。这个解决方案不建议使用,因为它既不可扩展也不灵活。更好的方法是使用像Redis这样的集中式数据存储。设计如图4-16所示。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第16张图片

性能优化

性能优化是系统设计面试中的常见话题。我们将探讨两个需要改进的领域。

首先,多数据中心的设置对于速率限制器非常关键,因为对于远离数据中心的用户来说,延迟很高。大多数云服务提供商在全球各地建立许多边缘服务器位置。例如,截至2020年5月20日,Cloudflare在地理上分布有194个边缘服务器[14]。流量会自动路由到最近的边缘服务器,以减少延迟。

如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口)_第17张图片

其次,用最终一致性模型同步数据。如果你对最终一致性模型不清楚,请参考“第6章:设计键值存储”中的“一致性”部分。

监控

在放置速率限制器后,收集分析数据以检查速率限制器是否有效是非常重要的。主要的是,我们想要确保:

  • 速率限制算法是有效的。
  • 速率限制规则是有效的。

例如,如果速率限制规则过于严格,许多有效的请求将被丢弃。在这种情况下,我们可能希望稍微放宽规则。在另一个例子中,我们注意到当流量突然增加,如闪购活动时,我们的速率限制器变得无效。在这种情况下,我们可能会更换算法以支持突发流量。在这里,令牌桶是一个很好的选择。

步骤4 - 总结

在这一章中,我们讨论了不同的速率限制算法及其优点/缺点。我们讨论的算法包括:

  • 令牌桶
  • 漏桶
  • 固定窗口
  • 滑动窗口日志
  • 滑动窗口计数器

然后,我们讨论了系统架构,分布式环境中的速率限制器,性能优化和监控。类似于任何系统设计面试问题,如果时间允许,你可以提到其他的讨论点:

  • 硬速率限制 vs 软速率限制。

    • 硬限制:请求次数不能超过阈值。
    • 软限制:请求在短时间内可以超过阈值。
  • 在不同层级的速率限制。在这一章中,我们只讨论了应用层级(HTTP:第7层)的速率限制。在其他层也可以应用速率限制。例如,你可以使用Iptables[15](IP:第3层)通过IP地址进行速率限制。注意:开放系统互联模型(OSI模型)有7层[16]:第1层:物理层,第2层:数据链路层,第3层:网络层,第4层:传输层,第5层:会话层,第6层:表示层,第7层:应用层。
  • 避免被限速。用最佳实践设计你的客户端:

    • 使用客户端缓存以避免频繁地调用API。
    • 理解限制,并且不在短时间内发送过多的请求。
    • 包含捕获异常或错误的代码,以便你的客户端可以从异常中优雅地恢复。
    • 在重试逻辑中添加足够的退避时间。

祝贺你走到这一步!现在给自己一个赞。做得好!

AI不会取代你,使用AI的人会。欢迎关注我的公众号:更AI。以程序员的视角来看AI能带给我们什么~

参考资料

[1] 速率限制策略和技术:https://cloud.google.com/solutions/rate-limiting-strategies-t...

[2] Twitter速率限制:https://developer.twitter.com/en/docs/basics/rate-limits

[3] Google文档使用限制:https://developers.google.com/docs/api/limits

[4] IBM微服务:https://www.ibm.com/cloud/learn/microservices

[5] 节流API请求以获得更好的吞吐量:

https://docs.aws.amazon.com/apigateway/latest/developerguide/...

[6] Stripe速率限制器:https://stripe.com/blog/rate-limiters

[7] Shopify REST Admin API速率限制:https://help.shopify.com/en/api/reference/rest-admin-api-rate...

[8] 使用Redis排序集合实现更好的速率限制:
https://engineering.classdojo.com/blog/2015/02/06/rolling-rat...

[9] 系统设计 — 速率限制器和数据建模:
https://medium.com/@saisandeepmopuri/system-design-rate-limit...
9304b0d18250

[10] 我们如何建立能扩展到数百万域名的速率限制器:
https://blog.cloudflare.com/counting-things-a-lot-of-differen...

[11] Redis网站:https://redis.io/

[12] Lyft速率限制:https://github.com/lyft/ratelimit

[13] 使用速率限制器扩展你的API:
https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#request-rate-limiter

[14] 什么是边缘计算:https://www.cloudflare.com/learning/serverless/glossary/what-
is-edge-computing/

[15] 使用Iptables限制请求速率:https://blog.programster.org/rate-limit-requests-with-
iptables

[16] OSI模型:https://en.wikipedia.org/wiki/OSI_model#Layer_architecture

你可能感兴趣的:(如何设计一个速率限制器(令牌桶/漏桶/固定窗口/滑动窗口))