9. 设计API限流器

1.什么是限流器?

假设我们有一个服务,它接收大量的请求,但它每秒只能服务有限的部分请求。要处理这个问题,我们需要某种节流或速率 限制机制,只允许一定数量的请求,以便我们的服务能够响应这些请求。高阶的速率限制器限制实体(用户、设备、IP等)的 在特定的时间窗口中触发事件。例如:

  • 用户每秒只能发送一条消息。

  • 一个用户每天只能进行三次失败的信用卡交易。

  • 一个IP每天只能创建20个帐户。

    通常,速率限制器限制发送者在特定时间窗口内可以发出的请求数。那么一旦达到上限,就会阻止请求。

2.为什么我们需要接口限流器?

速率限制有助于保护服务免受针对性的应用层的滥用行为,如拒绝服务(DOS)攻击、暴力密码尝试、暴力信用卡交易,这些攻击通常是一连串HTTP/S请求,看起来像是来自真实用户,但通常由机器(或机器人)生成。因此,这些攻击往往更加难于检测到,并可以更轻松地搞垮服务、应用程序或API。

速率限制还用于防止收入损失、降低基础设施成本、阻止垃圾邮件以及停止网上骚扰。下面是引入速率限制器让一个服务或者接口变得更加可靠的场景:

  • 不良的客户端/脚本:无论是有意还是无意,某些实体都可以通过发送大量请求来压垮服务。另一种情况可能是用户正在发送大量低优先级请求,我们希望确保它不会影响高优先级流量。例如,用户发送大量分析数据请求,不应允许妨碍其他用户的关键事务。

  • 安全性:通过限制用户的第二因素尝试次数(2因素身份验证)被允许执行,例如,他们被允许尝试错误代码的次数密码。

  • 防止滥用行为和不良设计实践:在没有API限制的情况下客户端应用程序将使用草率的开发策略,例如,一遍又一遍请求相同的 信息。

  • 控制成本和资源使用:服务通常设计为正常使用输入行为,例如,用户在一分钟内写一篇文章。计算机可以很容易地通过API每秒推送数千次。速率限制器启用对API服务的控制。

  • 收入:某些服务可能希望根据客户的业务级别限制运营服务,从而创建基于费率限制的收入模型。可能会有默认的限制服务提供的所有API。为了逾越这一点,用户必须购买更高的限额。

  • 为了消除访问堵塞:确保为其他人提供服务。

3.系统的要求和目标

我们的费率限制器应满足以下要求:

功能性需求

1.限制实体在一个时间窗口内可以向API发送的请求数量,例如15每秒请求数。 2.API可通过集群访问,因此应在不同的应用程序之间考虑速率限制服务器。每当在范围内超过定义的阈值时,用户应收到错误消息 ,无论在单个服务器或跨多个服务器。

非功能性需求

1.系统应具有高可用性。利率限制器应该始终工作,因为它保护我们的服务免于来自外部攻击。 2.我们的速率限制器不应引入影响用户体验的大量延迟。

4.如何进行速率限制?

速率限制是被再用户访问API接口时,规定了速率和速度。

节流是在给定时间内控制客户端使用API的进程。节流可以在应用程序级别和/或API级别定义。当节流的临界点到达时,服务器返回HTTP状态“429-请求过多”。

5.什么是不同类型的节流?

以下是不同服务使用的三种常见的节流类型:

  • 硬节流:API请求的数量不能超过限制的数量。

  • 软节流:在这种类型中,我们可以将API请求限制设置为超过某个百分比。例如,如果我们每分钟有100条消息的速率限制,并且有10%的消息超出了限制,那么我们的速率限制器将每分钟最多允许110条消息。

  • 弹性节流或动态节流:在弹性节流下,请求数可以超过阈值(如果系统有一些可用资源)。例如,如果一个用户只允许发送100条消息一分钟,我们可以让用户发送超过100条消息时,有免费的一分钟系统中可用的资源。

6.用于速率限制的算法有哪些不同类型?

以下是用于速率限制的两种算法: 固定窗口算法

在该算法中,时间窗口从开始时间到结束时间。例如,一段时间将被视为0-60秒每分钟,不考虑API请求发出的时间消耗。在下图中,有两条消息在0-1秒之间,三条消息在1-2秒之间。如果我们有速率限制在每秒两条消息中,此算法将仅限制“m5”。

滚动窗口算法

在该算法中,时间窗口是从发出请求的时间加上时间窗口长度。例如,如果有两条消息以1秒的第300毫秒和400毫秒的速度发送,我们将它们计为两条消息从那一秒的第300毫秒到下一秒的第300毫秒。在上面图中,每秒保留两条消息,我们将限制 “m3” 和 “m4”。

7.速率限制器的高阶设计

速率限制器将负责决定哪些请求被服务器接收,哪些请求将被拒绝。一旦新请求到达,Web服务器首先询问速率限制器,决定是接收还是节流。如果请求没有被限制,那么它将被发送给API服务器。

8.基础系统设计和算法

让我们以限制每个用户的请求数为例。在这种情况下对于每个唯一的用户,我们将保留一个计数器,表示该用户发出了多少请求,以及我们开始计算请求时的时间戳。我们可以将它保存在一个哈希表中,其中的KeyUserIDvalue一个组合,其中包含Count的整数和时间的整数。

让我们假设我们的速率限制器允许每个用户每分钟3个请求,所以每当有新请求时进入后,我们的速率限制器将执行以下步骤: 1.如果哈希表中不存在UserID,插入它,将Count设置为1,将StartTime设置为当前时间(标准化为一分钟),并允许请求。 2.否则,查找“UserID”的记录,如果CurrentTime–StartTime>=1min,将“开始时间”设置为当前时间,Count设置为1,并允许请求。 3.如果CurrentTime-StartTime<=1分钟

  • 如果Count<3,则增加计数并允许请求。

  • 如果Count>=3,则拒绝请求。

我们的算法有哪些问题? 1.这是一个固定的窗口算法,因为我们在每分钟结束的时候重置StartTime,这意味着它可能允许每分钟两倍的请求数。想象如果Kristie在一分钟的最后一秒发送了3个请求,那么她可以立即发送在下一分钟的第一秒,又发送了3个请求,结果两秒钟内有6个请求。解决这个问题的方法是使用滑动窗口算法我们稍后再讨论。

2.原子性:在分布式环境中,“先读后写”行为会造成竞争,想象一下,如Kristie当前的Count为2,并且她又发出了两个请求。如果两个独立的进程为每个请求提供服务,并同时读取之前的计数他们中的任何一个都更新了它,每个进程都会认为Kristie可能还有一个请求而且她还没有达到速率上限。

如果使用Redis存储键值,解决原子性问题的一个解决方案是使用读取更新操作期间的Redis锁。然而,这将以减缓来自同一用户的并发请求并引入另一层复杂性为代价。我们可以使用Memcached,但会有类似的复杂性。 如果我们使用一个简单的哈希表,我们可以有一个自定义的实现来lock每个记录以解决原子性问题。

**我们需要多少内存来存储所有用户数据?**让我们假设一个简单的解决方案,我们将所有数据保存在一个哈希表中。 假设UserID需要8个字节。我们还假设一个2字节的Count(能到65k),是足以满足我们的用例。虽然纪元时间需要4个字节,但我们可以选择只存储分钟和第二部分,可容纳2个字节。因此,我们总共需要12个字节来存储用户的数据:

8+2+2=12 bytes

让我们假设哈希表的每个记录有20字节的开销。如果我们需要在任何时候追踪100w用户,我们需要的总内存为32MB:

(12 + 20) bytes * 1 million => 32MB

如果我们假设需要一个4字节的数字来锁定每个用户的记录,以解决原子性问题,将需要总共36MB的内存。 这可以很容易地安装在单个服务器上,但是,我们不希望所有的流量都通过单个节点。此外,如果我们假设每秒10个请求的速率限制,这将转化为10个请求,这将是百万QPS,对于速率限制器来说,这对于单个服务器来说流量太大了。实际上,我们可以假设我们将在分布式场景中使用Redis或Memcached之类的解决方案。我们将储存所有的数据在远程Redis服务器,所有速率限制服务器将读取(和更新)这些服务器,在接收或节流任何请求之前。

9.滑动窗口算法

如果我们能够跟踪每个用户的每个请求,我们可以维护一个滑动窗口。我们可以把时间戳当做value存储在Redis的Sorted Set结构中

让我们假设我们的速率限制器允许每个用户每分钟三个请求,因此,每当有新请求时输入时,速率限制器将执行以下步骤: 1.从排序集中删除所有早于CurrentTime-1 minute的时间戳。 2.计算已排序集合中的元素总数。如果此计数更大,超过节流限值3,则拒绝请求。 3.在排序集中插入当前时间并接受请求。

我们需要多少内存来存储滑动窗口的所有用户数据?

假设UserID需要8个字节。每个纪元时间将需要4个字节。每小时500个请求。假设哈希表的开销为20字节,而哈希表的开销为20字节 Sorted Set。最多需要12KB来存储一个用户的数据:

 8 + (4 + 20 (sorted set overhead)) * 500 + 20 (hash-table overhead) = 12KB

这里我们为每个元素预留了20字节的开销。在Sorted Set中,可以假设至少需要两个指针来维护元素之间的顺序:一个指针指向上一个元素,另一个指针指向下一个元素。在64位机器上,每个指针将花费8字节。因此,我们将需要16个字节指针。我们添加了一个额外的字(4字节)来存储其他开销。

如果要在任何时候追踪到100w用户的记录,需要12GB:

12KB * 1 million ~= 12GB																

与固定窗口相比,滑动窗口算法占用大量内存;这将是一个可伸缩性问题。如果我们能结合上述两种算法来优化我们的内存使用情况呢?

10.带计数器的滑动窗口

如果使用多个固定时间窗口(例如1/60的速率限制时间窗口)跟踪每个用户的请求计数,会怎么样?例如,如果我们有小时速率限制,我们可以保持当我们收到新的计数器时,计算每分钟的计数,并计算过去一小时内所有计数器的总和。这将减少内存占用。让我们举个例子 ,限制为每小时500个请求,额外限制为每分钟10个请求。这表示过去一小时内带有时间戳的计数器的总和超过请求时阈值(500),Kristie已超过速率限制。除此之外,她最多只能每分钟请求10次。这将是一个合理和实际的考虑,因为没有真正的用户会经常发出请求。即使他们这样做了,他们也会看到重试成功,因为他们的限制被每分钟重置一次。

我们可以将计数器存储在Redis 的哈希中,因为它提供了难以置信的高效存储,不到100个key。当每个请求增加散列中的一个计数器时,它还将散列设置为一小时后过期。我们将每个time标准化为一分钟。

我们需要多少内存来存储带计数器的滑动窗口的所有用户数据? 假设UserID需要8个字节。每个纪元时间需要4个字节,计数器需要2个字节。假设我们需要每小时500个请求的速率限制。假设20字节的开销哈希表和Redis哈希的20字节。因为我们会记录每一分钟的数量,所以我们会每个用户需要60个实体。我们总共需要1.6KB来存储一个用户的数据:

 8 + (4 + 2 + 20 (Redis hash overhead)) * 60 + 20 (hash-table overhead) = 1.6KB

如果需要追踪100w用户,存储需要1.6GB:

1.6KB * 1 million ~= 1.6GB

因此,比起朴素版的滑动窗口,带计数器的滑动窗口可以节省86%的存储空间。

11.数据分片和缓存

我们可以基于UserID进行切分。对于容错和复制,我们应该使用一致性哈希。如果我们想对不同的API有不同的节流限制,我们可以选择按API按每个用户分片。以「短链」为例;对于每个用户或IP,有不同的速率限制器,createURL()deleteURL()这两个API。

如果我们的API是分区的,一个实际的考虑可能是使用一个单独的(稍微小一些的)API,每个API碎片的速率限制器。以「短链」为例,限制每个用户每小时创建的短链URL不超过100个。假设我们使用的是基于哈希的分区,对于createURL()的API,我们可以对每个分区进行速率限制,以允许用户创建除了每小时100个短链URL之外,每分钟不超过3个的短链URL。

我们的系统可以从缓存最近的活跃用户中获得巨大的好处。应用服务器可以快速在命中后端服务器之前,检查缓存是否具有所需的记录。我们的速率限制器可以通过更新缓存中的所有计数器和时间戳,可显著受益于回写缓存,只有对永久存储器的写入可以在固定的时间间隔内完成。这样我们可以确保速率限制器向用户请求的最小延迟。读取总是可以首先命中缓存,一旦用户达到其最大限制,并且速率限制器将只能读取数据而不进行任何更新。

对于我们的系统来说,使用LRU的缓存策略是合理的。

12.我们应该按IP还是按用户进行速率限制?

让我们讨论一下使用每种方案的利弊:

IP

在这个方案中,我们限制每个IP的请求;在“好”和“坏”之间,尽管它在区分方面不是最优的,总比完全没有利率限制要好。最大的问题基于IP的节流是指多个用户共享一个公共IP,如在网吧或使用同一网关的智能手机用户。一个坏的用户可能导致其他用户被节流。缓存基于IP的限制时可能会出现另一个问题,因为存在大量IPv6地址,黑客甚至可以从一台计算机上使用,使服务器内存不足是很简单的。

用户

在用户身份验证后,可以对API进行速率限制。一旦通过身份验证,用户将拿到一个令牌,用户将在每次请求时传递该令牌。这将确保针对具有有效身份验证令牌的特定API的限制。但如果必须对登录API进行速率限制呢?这种速率限制的缺点是黑客可以执行拒绝访问,通过输入错误的凭据对用户进行服务攻击,达到最大限度,之后,有效的用户将无法登录。 如果我们把以上两个方案结合起来,怎么样? 结合:一个正确的方法可能是同时进行每IP和每用户速率限制,就像它们单独实现的那样,然后,这将导致更多的缓存条目具有更多细节 实体,因此需要更多的内存和存储空间。

Last updated