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

限流延迟

想象水滑梯顶端的服务员。他不会把孩子们拒之门外,只是让每一个人都比前一个多等一会儿。

限流延迟(Throttling)是在请求数达到一定数量之后让它们慢下来,而不是在达到硬性上限时直接拒绝。客户端最终还是会得到回应,只是晚一点。

延迟而非拒绝

express-slow-down 是一个中间件,结构和限流器一样:它在一个时间窗口内统计请求数,超过阈值后开始施加延迟。

js
import { slowDown } from 'express-slow-down'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 2,
  delayMs: () => 5000,
})

app.get('/api/data', throttle, handler)

三个配置项:时间窗口、多少个请求可以不受影响地通过,以及超过之后统一延迟多久。

同时发出五个请求,前两个立刻返回。剩下三个各自等待五秒,然后成功。

请求到达时间延迟响应时间
10s0s
20s0s
30s5s5s
40s5s5s
50s5s5s

没有一个请求被拒绝。所有请求都成功了,只是其中三个比它们期望的多花了五秒。

Juno延迟而非拒绝 客户端根本不知道自己被限流了。没有错误,也没有状态码可以检查,唯一的表现就是响应变慢了。

这既是它的吸引力所在,也是它的问题所在。什么都没有出错,但也没有任何东西告诉客户端该收敛一下了。

Juno延迟而非拒绝 被延迟的请求其实是一个一直保持打开的连接,所以限流延迟消耗的是资源,而拒绝请求反而能释放资源。一次突发流量里每个请求延迟几秒没什么问题;但如果大范围地施加较长的延迟,就等于在耗尽你自己的连接池。

这就是为什么建议把它和硬性限制搭配使用,而不是单独使用,并且把延迟控制在几秒这个量级。

Juno延迟而非拒绝 在 express-slow-down 3.1.1 上验证过:delayMs 现在接收一个函数,而旧版本接受的是一个裸数字。现在传入数字会直接报配置错误,而不是静默降级处理,这种升级破坏性变化值得你锁定版本号。

更深层的原因是,对于行为规范的客户端,限流延迟能施加背压,而不需要客户端配合。429 只有在调用方实现了退避逻辑时才有用;而延迟无论对方有没有写那段代码,都会让它慢下来。

面对蓄意的攻击者,限流延迟就没那么有效了,因为攻击者可以开更多连接,也乐意等待。用限流延迟来规范正常流量,用拒绝来制止滥用行为。

延迟增长的速度

delayMshits(时间窗口内统计到的请求数)的函数,所以延迟可以随压力增大而增长。

js
delayMs: () => 5000                              // 固定值:始终 5 秒
delayMs: (hits) => (hits - 2) * 1000             // 递增:1秒、2秒、3秒
delayMs: (hits) => hits * hits * 1000            // 指数增长:9秒、16秒、25秒

delayAfter: 2 时,这三种方式到第五个请求时表现差别很大:

命中次数固定值递增指数增长
35s1s9s
45s2s16s
55s3s25s

递增方式会减去 delayAfter,所以第一个被延迟的请求只等一秒而不是三秒。指数增长方式则是对命中次数取平方,增长得非常陡峭,持续的压力很快就会变得难以承受。

Juno延迟增长的速度 看看这三列在第五个请求上的差距:三秒,还是二十五秒。同样的中间件,同样的阈值。

你选择的增长曲线比阈值本身更重要,而这一点只有把数字并排放在一起才看得清楚。

Juno延迟增长的速度 递增方式是比较稳妥的默认选择。对偶尔超标一点点的客户端比较温和,对持续超标的客户端又足够严厉,而且延迟始终保持在调用方超时时间能容忍的范围内。

指数增长需要设置上限。到第 20 次命中时,平方后的延迟会要求等待 400 秒,早就超过了任何客户端的超时时间,于是请求被放弃,而你的服务器却还在为它保持连接。这时就需要用 maxDelayMs 来设置上限。

Juno延迟增长的速度 在 3.1.1 上验证过,写测试之前值得知道这一点:并发请求并不会按到达顺序完成。同时发出五个请求,用递增延迟策略,结果请求 4 在一秒时返回,而请求 3 却在两秒时才返回,因为命中编号是按中间件统计的顺序分配的,而不是按你发送请求的顺序。

所以一个被延迟的请求最终排在第几位,取决于它分到的命中编号,任何基于到达顺序做断言的测试都会因此变得不稳定,而这和你的代码本身毫无关系。

也值得把限流延迟和旁边限流器给出的 RateLimit 响应头搭配使用。单靠延迟,客户端得不到任何可以据此采取行动的信号,写得再规范的调用方也无从收敛。

时间窗口统计的是什么

express-slow-down 使用的是滑动窗口。每个请求都会往回看 windowMs 这段时间,统计这段时间内的命中次数,如果超过这个时长都没有新请求到达,计数就会重新开始。

微妙之处在于:判断是否落在窗口内,看的是请求到达的时间,而不是它被处理的时间。

设置 windowMs: 10000delayAfter: 2,同时发出五个请求,30 秒后再发五个:

  • 前五个请求都在 0 秒到达。两个通过,三个被延迟,稍后才响应。
  • 第二批请求在 30 秒时到达。往回看十秒覆盖的是 20 秒到 30 秒这段区间,这段时间里没有任何请求到达,尽管此时第一批被延迟的响应还在陆续发出。
  • 所以计数重新开始,两个通过,三个再次被延迟。

从服务器发出的延迟响应并不会延长时间窗口,只有新到达的请求才会。

Juno时间窗口统计的是什么 水滑梯服务员回想的是过去十秒里*谁到场了*,而不是谁还站在那儿等着计时结束。

那些等待的孩子在到达的那一刻就已经被计数过了。再数一次,等于让他们为同一次入场重复付费。

Juno时间窗口统计的是什么 正是这一点让突发型客户端能够持续运转。用完一波,等窗口过去,再来一波。延迟不会在多次突发之间累积,因为窗口已经忘记了上一波流量。

如果这不是你想要的效果,那就需要把窗口设置得比两次突发之间的间隔更长,或者在限流延迟后面再放一个硬性限制。

Juno时间窗口统计的是什么 按到达时间计数是正确的选择,但它确实会和后面的限流器产生一种奇怪的互动。一个被延迟了十六秒的请求,在限流延迟这一环是按到达时刻计数的,而在限流器那一环则是按处理时刻计数的,也就是说这两个中间件是在不同的时间点衡量同一个请求。

这就导致了下一节里出现的顺序颠倒:一个较晚的请求反而超过了较早的请求,因为较早的那个还挂在计时器上没结束。

看日志的时候要记住这一点:限流延迟的计数和限流器的计数会对不上,但两边都没有错。

把限流延迟和限流器叠加使用

这两者可以组合使用,而顺序很重要,因为 Express 是从左到右依次执行中间件的:

js
app.get('/api/data', throttle, limiter, handler)

限流延迟放在前面,意味着请求会逐步变慢,只有超过硬性上限之后限流器才会拒绝它们。反过来,如果限流器在前,客户端在限流延迟还没来得及温柔对待它们之前就已经被切断了。

来看课程里的一个实战需求。系统在十秒内超过三个请求就会性能下降,所以第四个请求要延迟 1 秒,第五个延迟 4 秒,第六个延迟 9 秒。而且三十秒内总共不能超过七个请求。

js
import { slowDown } from 'express-slow-down'
import { rateLimit } from 'express-rate-limit'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 3,
  delayMs: (hits) => (hits - 3) * (hits - 3) * 1000,
})

const limiter = rateLimit({
  limit: 7,
  windowMs: 30000,
  message: 'Too many requests',
  standardHeaders: 'draft-6',
  legacyHeaders: false,
})

延迟公式是从需求要求的规律反推出来的。第 4 次命中要给 1 秒,第 5 次要给 4 秒,第 6 次要给 9 秒,也就是 1²、2²、3²。先减去 delayAfter 把命中次数转换成底数,再取平方就得到了这条曲线。

快速发出七个请求,会出现一个出人意料的情况。请求 7 被延迟了十六秒,等它到达限流器时,请求 8(分到的等待时间更短)已经先它一步到达了。结果请求 8 成了第七个成功的请求,而请求 7 反而被拒绝了。

被限流延迟的请求并不是排队等候,而是每个请求各自持有自己的计时器,到点了就自己到达。

Juno把限流延迟和限流器叠加使用 顺序颠倒看起来像是个 bug,其实不是。每个被延迟的请求在到达时都拿到了属于自己的计时器,所以计时短的请求会先于计时长的请求完成,跟它们原本的发送顺序无关。

如果是漏桶算法,它们会被保持在原来的队列顺序里。而限流延迟只是分发计时器,然后让它们各自运行。

Juno把限流延迟和限流器叠加使用 这意味着限流延迟加限流器的组合,可能会拒绝一个单独用限流器本会接受的请求。请求 7 到达时还在限制范围内,但等它的延迟结束时,已经超出限制了。

在你把这当成限流器的 bug 之前,值得先弄清楚这一点。限流器的计数没有错,是限流延迟改变了这个请求真正被计数的时刻。

Juno把限流延迟和限流器叠加使用 这里的两个中间件都没有识别客户端身份,所以所有流量共用一个额度。给两者都加上 keyGenerator,才能把它变成按客户端分别调控:一个流量大的用户被限速,其他人则照常立刻得到服务。

不这样做的话,单个客户端的一次突发流量就会拖累所有人,这就把一个本该起保护作用的控制手段变成了自己造成的拒绝服务。

在生产环境中站得住脚的架构分三层:识别客户端身份、用限流延迟调控流量、超过硬性上限就拒绝。这三层各自回答不同的问题,而它们组合起来的结果会变得相当复杂,所以在信任你的配置之前,值得先画一张到达时间、延迟和结果的对照表。

动手试试

某个接口配置了 windowMs: 10000delayAfter: 2delayMs: (hits) => hits * hits * 1000,且没有限流器。

  1. 四个请求同时到达,每个请求分别在什么时候响应?
  2. 一个客户端每 15 秒发送四个请求,一直持续下去。延迟会随着时间推移变得越来越糟吗?
  3. 如果第五个请求到达,而它的延迟时间超过了客户端的超时时间,会发生什么?
  4. 你要怎样修改配置,才能防止单个客户端拖慢其他所有人?
对照答案
#答案
1请求 1 和请求 2 立刻响应。请求 3 是第 3 次命中,所以延迟 3² = 9 秒。请求 4 是第 4 次命中,所以延迟 4² = 16 秒
2不会。时间窗口是 10 秒,而间隔是 15 秒,所以每一波请求回看窗口时都是空的,计数会从头开始。同样的"前两个免费,然后 9 秒,然后 16 秒"的模式会一直重复下去
3客户端放弃了,但你的服务器仍然会一直保持连接,直到延迟结束。工作做完了,却没人接收结果,这就是为什么指数增长的延迟需要配上 maxDelayMs
4添加一个 keyGenerator,让限流延迟按客户端分别计数,而不是全局统计。不这样做的话,所有请求共用一个额度,一个流量大的客户端就会拖慢所有人

问题 2 是最让人意外的一个。指数增长的延迟听起来应该能惩罚持续的滥用行为,但如果客户端把突发流量的节奏控制在窗口之外,延迟就完全感受不到任何攀升。这条曲线只在单个时间窗口内部才会起作用。

接下来去哪里

五种算法,两种控制手段,最终都在回答同一个问题:这个客户端能被允许请求多少,超过之后又会怎样。

这套思路不只适用于你自己的服务器。你调用的每一个外部 API 都有自己的限制,所以理解这些内容也能帮你写出更好的客户端代码:怎么退避,以及遇到 429 时该怎么处理,而不是一个劲儿地去撞击一个本就吃紧的服务。

到这里,这本手册就讲完了。从像攻击者一样思考,到输入安全、身份识别与滥用防范,一共五个部分,每一部分都配有一个你可以亲手破坏再修复的可运行示例。