构建一个限流器
一个接口,一个连续请求它十五次的脚本。
app.get('/api/data', (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})十五个请求,十五个 200。没有任何计数发生。
在前面加一道限流
express-rate-limit 是中间件,它挡在路由和处理函数之间,决定这个处理函数到底会不会被执行。
import { rateLimit } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
})
app.get('/api/data', limiter, (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})三个设置项:允许多少次、多少毫秒的窗口、拒绝时说什么。再跑一次十五个请求,前五个返回 200,剩下的都是 429 Too Many Requests。
这其实不算严格意义上的固定窗口
在这个算法的理论描述里,窗口是按固定节奏推进的,不管有没有请求到来。而这个库文档里的 windowMs 指的是一个从客户端第一次请求开始计算的窗口,到期后再重置该客户端的计数。
可以叫它"惰性启动的窗口"。边界处的突发请求问题依然存在,只是这个边界现在落在每个客户端各自的起始时刻,而不是时钟上一个共同的固定点。
这个中间件还会把自己的状态挂在请求对象上:
req.rateLimit // { limit, remaining, reset, used }调试时很有用,但客户端不应该从这里读取信息。
你完全不需要改动处理函数本身,需要改变的是它执行之前必须先发生的事情。
告诉客户端它们当前的状态
限流信息应该放在响应头里。JSON 是给应用程序返回数据用的,响应头则是用来描述这次交互本身的信息,和内容类型、状态码放在一起。
比起"整洁不整洁",实际效果的论证更有说服力。没有响应头,客户端只能靠撞上限制才能发现它。有了响应头,每个响应都会告诉客户端还剩多少额度,一个写得好的客户端就能在被拒绝之前主动放慢速度。
对于按次计费的 API 来说这一点尤其重要,因为浪费一次请求就是浪费钱,而且有了响应头,调试时也不用去解析响应体。
这里有两种格式,实际场景中都能碰到:
| 时期 | 响应头 |
|---|---|
| 旧版 | X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset |
| 标准版,约从 2021 年起 | RateLimit-Limit、RateLimit-Policy、RateLimit-Remaining、RateLimit-Reset |
这个库默认仍然使用旧版的那一组,所以要显式地要求使用现代版本:
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
standardHeaders: 'draft-6',
legacyHeaders: false,
})现在每个响应都携带了客户端当前的状态:
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60有了这些响应头,req.rateLimit 就该从 JSON 响应体里去掉了。响应头已经把这件事做得很妥当。
客户端可以看着 Remaining 一点点减少,在它降到零之前主动放慢速度,而不是一头撞上墙才发现限制的存在。
观察它的重置过程
如果十五个请求在一秒钟内全部打出去,它们全都落在同一个窗口里,你只会看到五个成功、十个被拒绝,永远看不到重置发生。
把窗口缩小,把客户端发请求的速度放慢,整个周期就变得可以观察了。限制为每 10 秒 3 次,请求间隔 2 秒:
| 请求序号 | 结果 |
|---|---|
| 第 1 到 3 个 | 200,Remaining 依次是 2、1、0 |
| 第 4、5 个 | 429,Reset 在倒计时 |
| 第 6 到 8 个 | 又变回 200,窗口已经重置 |
| 第 9、10 个 | 429 |
这一次运行就展示了这个算法的全部行为:先是一段允许通过的突发,然后撞墙,然后重置,再来一段突发。这其实也让边界问题变得可见了——重置前的三个请求和重置后的三个请求几乎在同一瞬间加起来放行了六个请求。
请求间隔两秒,你就能亲眼看到重置发生,而这正是真正能解释这个行为的部分。
练习一下
某个 API 限制为每分钟 100 个请求,客户端却不断反馈请求会毫无征兆地失败。
const limiter = rateLimit({ limit: 100, windowMs: 60000 })
app.use(limiter)
app.get('/api/data', (req, res) => {
res.json({ data, rateLimit: req.rateLimit })
})找出三个问题。
对照一下你的答案
1. 没有开启标准响应头。 由于没有设置 standardHeaders,这个库只会发出旧版的 X-RateLimit-* 响应头,遵循现代标准的客户端什么都看不到,也就无法知道自己正在接近限制。应该设置 standardHeaders: 'draft-6' 和 legacyHeaders: false。
2. 限流状态放在了 JSON 响应体里。 响应里的 req.rateLimit 只在这一个接口能用,别的地方都用不了。而 429 返回的是错误响应体,不是这个正常响应体,所以恰恰在最需要这条信息的时候它却消失了。响应头则会出现在每一个响应里,包括被拒绝的那些。
3. app.use(limiter) 给所有接口用了同一份额度。 登录尝试和数据读取共享同一个 100 次的额度,普通的浏览行为就能耗尽本该用来保护登录接口的额度。
应该按路由分别挂载限流器,在敏感接口上使用更严格的数字。
如果这个 API 运行在不止一个实例上,还有第四个问题值得注意:默认的存储方式是进程内的,每个实例各自计数,真实的限制其实是 100 乘以实例数量。
接下来往哪个方向走
限流器已经能正常工作了,它计数的每一个请求都被归到这个库认定的"发起者"名下。到目前为止,这个身份一直是客户端的 IP 地址——这是库默认的选择,不是你自己做的决定。
识别客户端 会把这个决定明确地摆到台面上来,因为限流计数所依据的对象不同,它保护的是谁、惩罚的又是谁,也就跟着不同。

