Skip to content
This page has been auto-translated and may contain errors.View in English

速率限制基础

一个客户端每秒能发起一千个请求。HTTP 本身没有任何机制能阻止这种事,而你的服务器会试图应答每一个请求。

速率限制(Rate limiting)控制着一个服务能接受多大的流量。它决定了一个接口是能扛过糟糕的一下午,还是会因为一个写得太"起劲"的脚本而彻底崩掉。

它解决的三个问题

公平使用。 如果一个客户端占用了大量资源,其他人就都得等着。设置限制能防止单个调用方消耗掉整个用户群所需要的资源,这样在拦下某个失控循环的同时,正常流量依然能被正常处理。

安全性。 一个没有限制的登录接口,等于是邀请别人一次猜上成千上万个密码。限制能让暴力破解慢到毫无意义,也能削弱那些更简单的拒绝服务攻击。

资源。 过载会先让服务变慢,然后才彻底不可用,而"变慢"正是用户开始流失的时刻。限制吞吐量能让所有仍在限额内的用户获得可预期的响应时间。

单靠限流并不能防御 DDoS

分布式拒绝服务攻击来自成千上万台不同的机器,每一台看起来都像是普通客户端。按地址设置的限制对此几乎不起作用。

要应对它,需要更上游的手段:地理位置过滤、行为分析,或者由一个专门的服务商挡在你前面。速率限制只是其中一层防护,而不是全部答案。

Juno它解决的三个问题 这三个问题其实是相互重叠的,这也是为什么一种控制手段能同时覆盖它们。公平使用、安全性和资源保护,本质上是同一个机制,只是从不同角度看而已。

限制每个人能请求的频率,正是让一个应用既难以被滥用,又能让所有其他用户用得舒心的关键所在。

Juno它解决的三个问题 最值得优先设置的限制,通常不是那些全局性的限制。登录接口、密码重置、注册、会给数据库带来很大压力的搜索功能:这些都需要各自专属的、更严格的限制,远早于给所有请求设一个统一上限。

对每个接口,值得思考的问题是:一个正常用户在一分钟内合理能做多少次操作,然后在此基础上留出一些余量。

Juno它解决的三个问题 登录接口的速率限制有一个值得了解的微妙之处:按地址限制会误伤同一地址背后的整个办公室,而按用户名限制则会让攻击者可以故意让某个账号的登录连续失败,从而随意把它锁死。

比较稳妥的做法是两者都限制,各自设定不同的额度,并且绝不仅凭失败次数就彻底锁死一个账号。在这种场景下,放慢响应比直接拦截更管用,因为这会让攻击者付出时间成本,而不会让你的正常用户反过来遭遇拒绝服务。

拒绝还是放慢

有两种经常被混用、但实际行为完全不同的控制手段。

速率限制限流(Throttling)
它做什么拒绝超出上限的请求在接近限制时放慢请求速度
客户端看到什么一个错误,通常是 429 Too Many Requests一个响应,只是比平时来得晚
最适合需要严格执行的硬性上限在不打断客户端的前提下平滑突发流量

速率限制在超过上限后予以拒绝,限流则是放慢速度。

两者各有用武之地,也可以结合使用:随着流量上升先放慢速度,超过硬性上限后再彻底拒绝。限流一文详细讲了后半部分。

Juno拒绝还是放慢 一个记住两者区别的办法是:拒绝说的是"不行",放慢说的是"别这么快"。

一种会直接打断客户端的请求,另一种只是让它等一等。哪种更"友善",完全取决于是谁在请求、又是为了什么。

Juno拒绝还是放慢 对一个短暂请求过多、但行为正常的客户端来说,限流更友好一些,因为它的请求最终还是会成功。但面对滥用行为,限流就没那么好用了,因为让请求悬而不决会占用你的连接和内存。

所以常见的做法是:中间地带用限流,顶部用拒绝——对轻微超量的客户端温和一些,对远远超出的则要果断。

Juno拒绝还是放慢 一个 429 只有在客户端能据此采取行动时才有意义,这意味着需要带上 Retry-After,以及告诉客户端限额、剩余次数和何时重置的 RateLimit 系列响应头。没有这些信息,客户端唯一能做的就是立刻重试,而这恰恰是你本想阻止的行为。

要重点防范的失效模式是"重试风暴":所有人在同一时刻被拒绝,然后所有人在同一时刻一起重试。客户端退避时加入抖动(jitter),以及把重置时间错开,正是打破这种局面的办法,而这两者都不会自己发生。

限制是针对谁计数的

限制需要一个计数的对象。有四个候选项,各有不同的权衡:

  • IP 地址。 每个请求(包括匿名请求)都能拿到,但同一个办公网络或移动运营商背后的所有人会共享同一个地址。
  • 已认证的用户 ID。 精确且公平,但只有登录之后才存在。
  • API 密钥。 对机器客户端来说很理想,因为一个密钥能精确标识一个调用方。
  • JWT 中的某个声明(claim)。 当身份信息本来就携带在令牌里时很有用。

识别客户端一文讲的就是如何在这几者之间做选择。简单来说:匿名流量只能靠地址,其他方式只要能用,效果都会更好。

限制还需要一个时间窗口。按秒、按分钟、按小时——这个选择对行为的影响,不亚于数字本身。

Juno限制是针对谁计数的 "一百次请求"这句话本身没什么意义,除非再加上两个信息:一百次是"每多长时间",一百次是"来自谁"。

每一条速率限制规则其实都是这三个决定的组合,改变其中任何一个,限制实际发挥的作用就会完全不同。

Juno限制是针对谁计数的 窗口长度的影响比人们想象的要大得多。"每小时一百次"和"每分钟两次"平均下来数字一样,但表现完全不同:前者允许在一秒钟内打出一百个请求、然后彻底沉寂,后者则完全不允许任何突发流量。

短窗口能平滑流量,长窗口能容忍突发。具体选哪个,要看你真正想放行的是哪种流量。

Juno限制是针对谁计数的 不管你用什么作为计数对象,攻击者都能想办法替换掉它。住宅代理池能廉价地批量提供大量地址;免费账号也很廉价,除非注册环节本身受到保护;API 密钥是个例外,因为签发一个密钥是需要主动操作的。

这也是这种控制手段的真实局限:它让滥用的代价变高,而不是让滥用变得不可能,具体代价的高低,取决于你能用的最廉价的标识符有多容易获取。

同样值得提前想清楚的是,限流器自身的状态存在哪里。进程内计数器很简单,但只要你运行了两个实例它就会出问题,因为每个实例各自维护自己的计数,实际生效的限额就翻倍了。

动手试试

某个 API 只有一条限制:每个 IP 地址每小时 1000 次请求。想一想它防不住什么。

  1. 一个大学网络里的用户,在学期中无法正常使用这个应用。为什么?
  2. 攻击者想每小时发起 10000 次请求。这会让他付出什么代价?
  3. 一个脚本在每小时开始的头两秒钟内,就把 1000 次请求全部发完。这算不算在限制范围内?
核对你的答案

1. 这条限制计数错了对象。 那个网络上的所有人共享同一个出口地址,也就是说成千上万人在共用一份"一千次"的额度。修复方法是:只要用户已认证,就按用户计数;地址限制只用于匿名流量。

2. 十个地址。 住宅代理池以极低的价格成批出售这类地址。按地址限制只是让滥用付出一点代价,并没有真正让滥用变得困难。这也是为什么按 IP 限制对分布式攻击几乎不起作用。

3. 完全在限制范围内。 规则并没有规定这一千次请求应该如何分布,所以它们完全可以一次性同时到达,而这个接口从一开始就没有针对这种突发流量做过防护。这正是时间窗口长度决定的问题:选一个小时作为窗口,就会放行一分钟窗口绝不会允许的流量尖峰。

三个问题,三种不同的薄弱环节,只有第三个才真正跟算法有关。前两个都关乎你计数的对象是什么,以及它被替换的成本有多低。

接下来看什么

一条限制需要三个决定:多少次、在多长的窗口内、针对谁计数。算法则决定了你如何追踪这些数字。

固定窗口计数器是这些算法里最简单的一种,而它放行的那种突发流量,正是后续每一种算法要解决的问题。