Language:Chinese VersionEnglish Version

你暴露给互联网的每个 API 最终都会被滥用。自动化爬虫、凭证填充机器人、行为不当的集成,有时只是一个善意但循环过快的客户端。如果没有速率限制,单个恶意行为者或有问题的客户端可能会消耗掉你所有的服务器资源,降低其他所有用户的体验,甚至可能导致整个服务瘫痪。

速率限制是那些表面上看起来简单但当你开始实施时会展现出惊人深度的机制之一。算法的选择影响公平性和突发容忍度。在架构中的位置影响你能保护什么和不能保护什么。而你选择的限制既是产品决策也是技术决策。

本文涵盖了核心算法、实用的实施模式,以及区分设计良好的速率限制系统和事后添加的系统的战略思维。

速率限制实际保护什么

在深入探讨算法之前,明确速率限制保护什么以及从谁那里保护是值得的。不同的威胁需要不同的方法。

资源保护

速率限制最基本的功能是防止任何单个客户端不成比例地消耗你服务器资源:CPU、内存、数据库连接、带宽。没有限制,每秒发出数千个请求的客户端可能会耗尽所有其他客户端的资源,即使没有恶意意图,实际上也造成了拒绝服务。

成本控制

如果你的 API 调用下游服务且这些服务按请求收费,例如 AI 推理 API、短信提供商或支付处理器,那么速率限制直接关系到你的运营成本。一个不受限制的客户端可以在几分钟内产生大量费用。速率限制充当财务断路器。

滥用预防

凭证填充、内容抓取、垃圾邮件提交和枚举攻击都依赖于大量请求。速率限制不能消除这些威胁,但它会显著提高攻击者的成本。限制为每分钟每 IP 十次登录尝试的凭证填充攻击,比每秒运行数千次的攻击效果差几个数量级。

公平访问

在多租户系统中,速率限制确保一个租户的使用不会降低另一个租户的体验。这既是技术问题也是业务问题。除非你的定价模型明确允许,否则你的最大客户不应该能够牺牲其他所有人的利益来垄断你的基础设施。

核心算法

实际上有四种重要的速率限制算法。每种在公平性、突发容忍度和实施复杂性方面都有不同的特点。

固定窗口

固定窗口算法是最简单的方法。您将时间划分为固定间隔,比如一分钟的时间窗口,并统计每个客户端在当前窗口内的请求数量。如果计数超过限制,后续请求将被拒绝,直到下一个窗口开始。

实现起来非常直接。您需要为每个客户端设置一个计数器,可以使用他们的 API 密钥或 IP 地址作为键,并设置与窗口持续时间相等的过期时间。在 Redis 中,这只是一个带有 EXPIREINCR 命令。计数器随每个请求递增,当超过阈值时,您返回 429 状态码。

固定窗口的显著弱点是边界问题。客户端可以在一个窗口结束时发送最大数量的请求,在下一个窗口开始时再次发送最大数量的请求, effectively 在窗口边界附近短暂地使他们的请求量翻倍。如果您的限制是每分钟 100 个请求,客户端可以在跨越窗口边界的两秒内发送 200 个请求。

尽管存在这个弱点,固定窗口仍被广泛使用,因为它们易于实现、易于理解,并且在存储方面效率高。对于许多应用来说,边界突发流量是可以接受的。

滑动窗口日志

滑动窗口日志算法通过跟踪速率限制窗口内每个请求的时间戳来消除边界问题。对于每个传入请求,您删除早于窗口持续时间的时间戳,然后计算剩余条目的数量。如果计数超过限制,该请求将被拒绝。

这提供了完全没有边界异常的完美平滑速率限制。然而,它带来了显著的内存成本:您必须在窗口内为每个客户端的每个请求存储一个时间戳。如果您的限制是每分钟 1000 个请求,有 10,000 个客户端,您最多需要存储 1000 万个时间戳。对于高流量 API,这变得不切实际。

滑动窗口日志最适合低流量、高价值端点,这些端点需要精确的速率限制且每个窗口的请求数量较少。登录端点、密码重置流程和高计算成本端点是很好的候选。

滑动窗口计数器

滑动窗口计数器是一种巧妙的混合算法,它以滑动窗口日志的平滑性近似固定窗口的内存效率。它通过维护当前和先前固定窗口的计数器工作,然后根据您进入当前窗口的程度计算加权计数。

例如,如果您进入当前一分钟窗口的 30%,有效计数为:(上一个窗口计数乘以 0.7)加上(当前窗口计数)。这种加权平均平滑了边界问题,而无需存储单独的时间戳。

这种近似并不完美。在某些请求模式下,估计的计数可能与真实的滑动窗口计数有所不同。但在实践中,对于几乎所有用例来说,这种近似已经足够接近,而且无论请求量如何,每个客户端只需要两个计数器即可实现。

这是我推荐的大多数应用的默认算法。它在准确性、内存效率和实现简单性之间提供了良好的平衡。

令牌桶

令牌桶算法将速率限制建模为一个以稳定速率填充令牌的桶。每个请求消耗一个令牌。如果桶为空,则请求被拒绝。桶有最大容量,这决定了最大突发大小。

两个参数定义了行为:填充速率(每秒令牌数)和桶容量(最大令牌数)。一个填充速率为每秒10个令牌、容量为50的桶,允许每秒10个请求的稳定速率,以及最多50个请求的突发。

令牌桶之所以优雅,是因为它自然地建模了速率限制的两个不同方面:稳定速率和突发容忍度。您可以独立调整这些参数。一个较大的突发容量适中的稳定速率可以合法的流量峰值,同时防止持续的过载。

实现需要为每个客户端存储两个值:当前令牌计数和上次填充的时间戳。对于每个请求,您计算自上次填充以来积累了多少令牌,将它们添加到桶中(不超过最大容量),然后为当前请求扣除一个令牌。如果令牌计数将变为负数,则请求被拒绝。

令牌桶特别适合于那些您希望允许偶尔突发但强制执行长期平均速率的API。这是大多数云提供商用于其API速率限制的算法,并且它自然地映射到分层定价模型,其中不同计划获得不同的桶大小和填充速率。

实现模式

选择算法只是实现的一部分。您在哪里实现速率限制、如何识别客户端以及如何传达限制,都会影响您系统的实际行为。

在哪里进行速率限制

速率限制可以在多个层实现,而最好的系统通常同时使用多个层。

在边缘或负载均衡器处。 Nginx、HAProxy、Cloudflare和AWS API Gateway都支持速率限制。边缘级别的限制可以保护您的应用服务器,使其不会接收到过多的流量。这是您防止滥用流量和DDoS式流量模式的第一道防线。限制在于边缘级别的限制通常基于简单的标识符(如IP地址)运行,并且无法整合应用程序级别的上下文,如用户身份或订阅层级。

在 API 网关或中间件层。 应用级速率限制具有完整的请求上下文。您可以根据已认证用户、API 密钥、端点、订阅层级或这些的任意组合来实施速率限制。这是您实施业务级速率限制的地方。

在单个服务层。 在微服务架构中,单个服务可以实施自己的速率限制以防止过多的内部流量。这可以防止行为异常的上游服务压垮下游依赖项,即使外部速率限制未被突破。

分层方法很重要,因为每一层都针对不同的故障模式进行防护。边缘限制阻止批量滥用。应用限制执行业务策略。服务限制防止级联故障。

识别客户端

您用于速率限制的标识符决定了谁会受到限制以及限制的有效性。

IP 地址 是最简单的标识符,对于未认证的端点很有效。然而,基于 IP 的限制越来越不可靠。使用 NAT 或企业代理的用户共享 IP 地址,这意味着您的限制会影响该代理背后的所有用户。相反,拥有僵尸网络或轮换代理服务权限的攻击者可以将他们的请求分散到数千个 IP 地址,使得每个 IP 的限制无效。

API 密钥或认证令牌 是认证 API 的首选标识符。它将速率限制与实际消费者关联,无论其网络拓扑如何。这既公平又准确,并且自然地与基于订阅的定价层级保持一致。

复合标识符 结合多个维度。例如,您可以按每个端点的用户 ID 进行速率限制,允许用户每分钟对搜索端点发出 100 个请求,对导出端点发出 10 个请求。这提供了细粒度控制,防止对昂贵端点的滥用,同时不限制对便宜端点的访问。

向客户端传达限制

良好的速率限制是透明的。客户端应该知道限制是什么,距离达到限制有多近,以及在受到速率限制时该怎么做。

标准做法是在响应头中包含速率限制信息。RateLimit-Limit 头传达允许的最大请求数。RateLimit-Remaining 显示当前窗口内剩余的请求数。RateLimit-Reset 指示限制何时重置,通常作为 Unix 时间戳或秒数表示。

当客户端超出限制时,应返回 429 Too Many Requests 状态码,并附带一个 Retry-After 头,指示客户端应该等待多长时间才能重试。响应体中包含清晰的错误信息,说明发生了什么以及客户端应该怎么做,这对开发者体验也至关重要。

这些头部信息不仅仅是礼貌性的。它们使客户端能够实现智能退避策略,控制请求节奏以保持在限制范围内,并构建显示其 API 使用情况的仪表板。不透明的速率限制(客户端突然收到没有上下文的 429 响应)会导致沮丧和支持工单。

分布式速率限制

当您的应用程序在多台服务器上运行时,速率限制状态必须在各个实例之间共享。一个被限制为每分钟 100 个请求的客户端应该在全局范围内执行该限制,而不是每台服务器单独限制。

使用 Redis 进行集中状态管理

Redis 是分布式速率限制最常用的后端存储。它的原子操作(INCR、EXPIRE、Lua 脚本)使其非常适合实现上述任何算法。在任何语言栈中,大多数速率限制库和中间件都支持 Redis 作为后端。

权衡之处在于 Redis 成为关键依赖项。如果 Redis 不可用,您的速率限制器将无法工作。您需要决定一个故障策略:当速率限制器不可用时,是允许所有请求(fail open)还是拒绝所有请求(fail closed)?对于大多数应用程序来说,允许所有请求是更安全的选择。短暂的无速率限制时期比完全的服务中断更可取。

本地近似法

对于精确的全局计数不关键的系统,另一种方法是使用近似全局协调的本地速率限制。每台服务器维护自己的计数器,并定期与对等方或中央存储同步。这以降低精度为代价,减少了延迟,并消除对中央存储的硬性依赖。

如果您在负载均衡器后有 N 台服务器,且流量分布大致均匀,您可以将每台服务器的限制设置为全局限制除以 N。这种方法不精确,特别是在流量分布不均的情况下,但它提供了合理的保护,而不需要每个请求都访问中央存储。

用于有状态路由的一致性哈希

另一种方法是在负载均衡器级别使用一致性哈希,将来自特定客户端的所有路由到同一台服务器。这样,每台服务器的速率限制实际上就是每客户端的速率限制,而无需共享状态存储。权衡之处在于,您失去了在不重新哈希的情况下添加或删除服务器的能力,而热门客户端可能导致负载分布不均。

作为产品决策的速率限制

大多数关于速率限制的技术文章到这里就结束了,但最重要的思考才刚刚开始。速率限制不仅仅是技术护栏,它们是产品决策,传达了您重视什么以及您希望如何使用您的平台。

限制定义您的产品层级

对于 API 产品,速率限制是区分定价层级的最常见机制。免费层级可能允许每天 100 次请求。入门计划可能允许每分钟 1000 次请求。企业计划可能提供根据合同协商的自定义限制。

您在每个层级选择的限制传达了您产品的定位。慷慨的免费层级限制表明您希望获得广泛采用和开发者实验。带有积极升级策略的限制性免费层级限制表明您正在优化向付费计划的转化。两者本身没有对错之分,但这个决定应该是经过深思熟虑的。

限制塑造用户行为

速率限制影响开发者在您的平台上构建的方式。如果您的限制是每分钟的,开发者会将请求批量处理成突发请求。如果您的限制是每秒的,他们会均匀分布请求。如果您按端点限制,他们会优化调用的端点。如果您全局限制,他们会最小化总 API 调用次数。

仔细思考您希望鼓励的行为。按端点限制鼓励开发者为他们的用例使用最高效的端点。全局限制鼓励开发者最小化总调用次数,这可能导致他们在单个请求中过度获取数据,增加您每次请求的服务器负载。

限制的心理影响

限制给用户的感觉与限制本身同样重要。每分钟 60 次请求的限制显得很慷慨。每秒 1 次请求的限制在数学上是相同的,但感觉上很严格。框架会影响开发者的感知和满意度,即使实际容量是相同的。

同样,达到速率限制的体验也很重要。带有重试时间和升级文档链接的清晰错误消息是一种产品体验。没有解释的神秘 429 响应是沮丧的来源。速率限制的体验是您产品的一部分,它应该与您的主流程一样得到相同的设计关注。

优雅降级而非硬性截止

考虑硬性速率限制是否是唯一的选择。一些系统实现渐进式响应:达到某个阈值后,不是完全拒绝请求,而是降低它们的优先级、提供缓存响应或减少响应细节。这为超出限制的客户端保持了一定水平的服务,同时仍然保护您的基础设施。

例如,搜索 API 可能为在速率限制内的客户端返回完整结果,为略微超出限制的客户端返回 30 秒前的缓存结果,为大幅超出限制的客户端返回 429 状态码。这种分级方法实现起来更复杂,但能提供更好的用户体验。

常见错误及避免方法

在许多项目中实施和审查了速率限制系统后,以下是我最常看到的错误。

在请求生命周期中实施速率限制过晚。 如果您的速率限制检查发生在身份验证、输入验证和数据库查询之后,被限制的请求在被拒绝前仍然消耗了大量服务器资源。尽可能早地在请求管道中检查速率限制,最好在任何昂贵操作之前。

不对内部服务实施速率限制。 团队通常在其公共 API 上实施速率限制,但保留服务间内部通信不受限制。内部服务中的错误可能导致重试风暴,与外部攻击一样有效地使下游依赖瘫痪。内部速率限制或断路器对弹性至关重要。

仅基于 IP 实施限制。 基于 IP 的限制很容易通过轮换代理绕过,并且不公平地惩罚共享网络基础设施后的用户。为主要速率限制使用经过身份验证的标识符,仅将基于 IP 的限制作为未验证端点的补充防御层。

没有数据就设置限制。 基于直觉而非实际使用数据选择速率限制,会导致限制要么过于严格,让合法用户感到沮丧,要么过于宽松,无法保护您的基础设施。在设置限制之前,为您的 API 添加监控以了解实际使用模式。从宽松开始,然后根据观察到的行为收紧限制。

不监控速率限制命中情况。 您的速率限制系统会产生有价值的数据。频繁达到限制的客户端可能表明您的限制过低、客户端存在错误,或者正在进行攻击。监控速率限制事件,并为异常模式设置警报。

入门实施清单

如果您要为现有 API 添加速率限制,这里是一个实用的步骤序列。

  • 先进行监控。 在设置任何限制之前,添加日志记录以了解您当前的流量模式。每个客户端的请求分布如何?峰值速率是多少?哪些端点使用最频繁?
  • 从边缘开始。 在您的负载均衡器或 CDN 上实现基于 IP 的基本速率限制。这可以在最小化应用程序更改的情况下,立即提供针对容量滥用的保护。
  • 添加应用级别的限制。 使用滑动窗口计数器或令牌桶算法实现每用户或每 API 密钥的速率限制。使用 Redis 进行分布式状态管理。
  • 清晰传达限制。 在所有 API 响应中添加 RateLimit 头部。在 429 响应中返回包含 Retry-After 值的清晰错误消息。
  • 监控和迭代。 跟踪每个客户端和每个端点的速率限制命中率。根据观察到的数据调整限制。为异常模式设置警报。
  • 记录您的限制。 在您的 API 文档中发布您的速率限制。包括关于客户端应如何处理 429 响应以及如何请求限制增加的指导。

结论

速率限制是每个生产 API 所需的基础设施。算法已被充分理解,实现模式已成熟,工具生态系统也很丰富。没有理由在不实现速率限制的情况下发布 API。

但技术实现只是故事的一半。您选择的限制、如何传达这些限制,以及速率限制体验对用户的感觉,这些都是值得深思熟虑的产品决策。设计良好的速率限制系统可以保护您的基础设施,实现公平访问,并引导开发者采用高效的 usage 模式。设计不良的系统会让您的用户感到沮丧,并造成支持负担,同时无法提供有意义的保护。

将速率限制视为一项功能,而不是约束。您的 API、您的基础设施和您的用户都将因此受益。

By

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed