限流延迟
想象水滑梯顶端的服务员。他不会把孩子们拒之门外,只是让每一个人都比前一个多等一会儿。
限流延迟(Throttling)是在请求数达到一定数量之后让它们慢下来,而不是在达到硬性上限时直接拒绝。客户端最终还是会得到回应,只是晚一点。
延迟而非拒绝
express-slow-down 是一个中间件,结构和限流器一样:它在一个时间窗口内统计请求数,超过阈值后开始施加延迟。
import { slowDown } from 'express-slow-down'
const throttle = slowDown({
windowMs: 10000,
delayAfter: 2,
delayMs: () => 5000,
})
app.get('/api/data', throttle, handler)三个配置项:时间窗口、多少个请求可以不受影响地通过,以及超过之后统一延迟多久。
同时发出五个请求,前两个立刻返回。剩下三个各自等待五秒,然后成功。
| 请求 | 到达时间 | 延迟 | 响应时间 |
|---|---|---|---|
| 1 | 0s | 无 | 0s |
| 2 | 0s | 无 | 0s |
| 3 | 0s | 5s | 5s |
| 4 | 0s | 5s | 5s |
| 5 | 0s | 5s | 5s |
没有一个请求被拒绝。所有请求都成功了,只是其中三个比它们期望的多花了五秒。
这既是它的吸引力所在,也是它的问题所在。什么都没有出错,但也没有任何东西告诉客户端该收敛一下了。
延迟增长的速度
delayMs 是 hits(时间窗口内统计到的请求数)的函数,所以延迟可以随压力增大而增长。
delayMs: () => 5000 // 固定值:始终 5 秒
delayMs: (hits) => (hits - 2) * 1000 // 递增:1秒、2秒、3秒
delayMs: (hits) => hits * hits * 1000 // 指数增长:9秒、16秒、25秒delayAfter: 2 时,这三种方式到第五个请求时表现差别很大:
| 命中次数 | 固定值 | 递增 | 指数增长 |
|---|---|---|---|
| 3 | 5s | 1s | 9s |
| 4 | 5s | 2s | 16s |
| 5 | 5s | 3s | 25s |
递增方式会减去 delayAfter,所以第一个被延迟的请求只等一秒而不是三秒。指数增长方式则是对命中次数取平方,增长得非常陡峭,持续的压力很快就会变得难以承受。
你选择的增长曲线比阈值本身更重要,而这一点只有把数字并排放在一起才看得清楚。
时间窗口统计的是什么
express-slow-down 使用的是滑动窗口。每个请求都会往回看 windowMs 这段时间,统计这段时间内的命中次数,如果超过这个时长都没有新请求到达,计数就会重新开始。
微妙之处在于:判断是否落在窗口内,看的是请求到达的时间,而不是它被处理的时间。
设置 windowMs: 10000、delayAfter: 2,同时发出五个请求,30 秒后再发五个:
- 前五个请求都在 0 秒到达。两个通过,三个被延迟,稍后才响应。
- 第二批请求在 30 秒时到达。往回看十秒覆盖的是 20 秒到 30 秒这段区间,这段时间里没有任何请求到达,尽管此时第一批被延迟的响应还在陆续发出。
- 所以计数重新开始,两个通过,三个再次被延迟。
从服务器发出的延迟响应并不会延长时间窗口,只有新到达的请求才会。
那些等待的孩子在到达的那一刻就已经被计数过了。再数一次,等于让他们为同一次入场重复付费。
把限流延迟和限流器叠加使用
这两者可以组合使用,而顺序很重要,因为 Express 是从左到右依次执行中间件的:
app.get('/api/data', throttle, limiter, handler)限流延迟放在前面,意味着请求会逐步变慢,只有超过硬性上限之后限流器才会拒绝它们。反过来,如果限流器在前,客户端在限流延迟还没来得及温柔对待它们之前就已经被切断了。
来看课程里的一个实战需求。系统在十秒内超过三个请求就会性能下降,所以第四个请求要延迟 1 秒,第五个延迟 4 秒,第六个延迟 9 秒。而且三十秒内总共不能超过七个请求。
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 反而被拒绝了。
被限流延迟的请求并不是排队等候,而是每个请求各自持有自己的计时器,到点了就自己到达。
如果是漏桶算法,它们会被保持在原来的队列顺序里。而限流延迟只是分发计时器,然后让它们各自运行。
动手试试
某个接口配置了 windowMs: 10000、delayAfter: 2、delayMs: (hits) => hits * hits * 1000,且没有限流器。
- 四个请求同时到达,每个请求分别在什么时候响应?
- 一个客户端每 15 秒发送四个请求,一直持续下去。延迟会随着时间推移变得越来越糟吗?
- 如果第五个请求到达,而它的延迟时间超过了客户端的超时时间,会发生什么?
- 你要怎样修改配置,才能防止单个客户端拖慢其他所有人?
对照答案
| # | 答案 |
|---|---|
| 1 | 请求 1 和请求 2 立刻响应。请求 3 是第 3 次命中,所以延迟 3² = 9 秒。请求 4 是第 4 次命中,所以延迟 4² = 16 秒 |
| 2 | 不会。时间窗口是 10 秒,而间隔是 15 秒,所以每一波请求回看窗口时都是空的,计数会从头开始。同样的"前两个免费,然后 9 秒,然后 16 秒"的模式会一直重复下去 |
| 3 | 客户端放弃了,但你的服务器仍然会一直保持连接,直到延迟结束。工作做完了,却没人接收结果,这就是为什么指数增长的延迟需要配上 maxDelayMs |
| 4 | 添加一个 keyGenerator,让限流延迟按客户端分别计数,而不是全局统计。不这样做的话,所有请求共用一个额度,一个流量大的客户端就会拖慢所有人 |
问题 2 是最让人意外的一个。指数增长的延迟听起来应该能惩罚持续的滥用行为,但如果客户端把突发流量的节奏控制在窗口之外,延迟就完全感受不到任何攀升。这条曲线只在单个时间窗口内部才会起作用。
接下来去哪里
五种算法,两种控制手段,最终都在回答同一个问题:这个客户端能被允许请求多少,超过之后又会怎样。
这套思路不只适用于你自己的服务器。你调用的每一个外部 API 都有自己的限制,所以理解这些内容也能帮你写出更好的客户端代码:怎么退避,以及遇到 429 时该怎么处理,而不是一个劲儿地去撞击一个本就吃紧的服务。
到这里,这本手册就讲完了。从像攻击者一样思考,到输入安全、身份识别与滥用防范,一共五个部分,每一部分都配有一个你可以亲手破坏再修复的可运行示例。

