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

构建一个限流器

一个接口,一个连续请求它十五次的脚本。

js
app.get('/api/data', (req, res) => {
  res.json({ timestamp: new Date().toISOString() })
})

十五个请求,十五个 200。没有任何计数发生。

在前面加一道限流

express-rate-limit 是中间件,它挡在路由和处理函数之间,决定这个处理函数到底会不会被执行。

js
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 指的是一个从客户端第一次请求开始计算的窗口,到期后再重置该客户端的计数。

可以叫它"惰性启动的窗口"。边界处的突发请求问题依然存在,只是这个边界现在落在每个客户端各自的起始时刻,而不是时钟上一个共同的固定点。

这个中间件还会把自己的状态挂在请求对象上:

js
req.rateLimit  // { limit, remaining, reset, used }

调试时很有用,但客户端不应该从这里读取信息。

Juno在前面加一道限流 中间件所在的位置就是整个设计的核心,这和校验那一章的安排是一样的:某个东西挡在前面,处理函数只有在它放行之后才会执行。

你完全不需要改动处理函数本身,需要改变的是它执行之前必须先发生的事情。

Juno在前面加一道限流 把限流器挂在具体路由上,而不是挂在整个 app 上,这个选择值得刻意去做。登录接口和只读的数据接口需要完全不同的额度,一个全局限流器却给了它们同一个额度。

常见的做法是:一个宽松的全局限流器,加上少数几个需要特殊保护的接口上更严格的独立限流器。

Juno在前面加一道限流 默认的存储方式是进程内内存,这意味着每个实例各自计数,你实际的限流额度会随着运行的实例数成倍增加。负载均衡后面跑两个实例,限制为 5 的额度就变成了 10。

要解决这个问题需要一个共享存储,而朴素的 Redis 实现存在竞态条件。一个"读取-判断-写入"式的计数器,在限制为 100 的情况下用 400 个并发请求测试,结果 400 个全部通过了。

正确的做法是让这个检查变成原子操作,用原子自增或者 Lua 脚本,因为 Redis 会把一段脚本完整地执行完再处理别的。

还值得了解一下 passOnStoreError,它决定了当存储不可达时会发生什么。它默认是 false,也就是说流量会被阻断——Redis 一旦挂掉,你的服务也就跟着挂了。

到底该选这个还是选"默认放行",完全取决于这个接口具体做什么。

告诉客户端它们当前的状态

限流信息应该放在响应头里。JSON 是给应用程序返回数据用的,响应头则是用来描述这次交互本身的信息,和内容类型、状态码放在一起。

比起"整洁不整洁",实际效果的论证更有说服力。没有响应头,客户端只能靠撞上限制才能发现它。有了响应头,每个响应都会告诉客户端还剩多少额度,一个写得好的客户端就能在被拒绝之前主动放慢速度。

对于按次计费的 API 来说这一点尤其重要,因为浪费一次请求就是浪费钱,而且有了响应头,调试时也不用去解析响应体。

这里有两种格式,实际场景中都能碰到:

时期响应头
旧版X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
标准版,约从 2021 年起RateLimit-LimitRateLimit-PolicyRateLimit-RemainingRateLimit-Reset

这个库默认仍然使用旧版的那一组,所以要显式地要求使用现代版本:

js
const limiter = rateLimit({
  limit: 5,
  windowMs: 60000,
  message: 'Too many requests, please try again later.',
  standardHeaders: 'draft-6',
  legacyHeaders: false,
})

现在每个响应都携带了客户端当前的状态:

http
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60

有了这些响应头,req.rateLimit 就该从 JSON 响应体里去掉了。响应头已经把这件事做得很妥当。

Juno告诉客户端它们当前的状态 这些响应头出现在每一个响应里,而不只是被拒绝的那些。这才是它们真正有用的地方。

客户端可以看着 Remaining 一点点减少,在它降到零之前主动放慢速度,而不是一头撞上墙才发现限制的存在。

Juno告诉客户端它们当前的状态 要留意 Reset 的单位,因为这两种格式的约定并不一样。在 8.7.0 版本上验证过:旧版的 X-RateLimit-Reset 是一个 Unix 时间戳,而 draft-6 的 RateLimit-Reset 表示的是"还剩多少秒"。

把一个当成另一个来读,要么得到一个要等五十多年的荒谬结果,要么得到一个立刻就重试的结果,而且这两种情况看起来都像是客户端的 bug,而不是单位理解错了。

Juno告诉客户端它们当前的状态 这个标准从 draft-6 之后还在继续演进,这个库也一直在跟进。draft-7 和 draft-8 把原来分开的几个字段合并成了一个响应头,8 版本可以接受 'draft-6''draft-7''draft-8',而旧版本接受的是布尔值。传 true 依然有效,等同于 draft-6。

draft-8 的样子和其他几个完全不一样,在 8.7.0 上验证过:

RateLimit: "3-in-10sec"; r=2; t=10

策略名称、剩余次数、重置时间,全都压缩在一个结构化字段里。在你写解析这些响应头的客户端代码之前,值得先了解清楚;也值得显式指定 draft 版本,这样以后默认值变了也不会在你不知情的情况下把你的代码搞坏。

观察它的重置过程

如果十五个请求在一秒钟内全部打出去,它们全都落在同一个窗口里,你只会看到五个成功、十个被拒绝,永远看不到重置发生。

把窗口缩小,把客户端发请求的速度放慢,整个周期就变得可以观察了。限制为每 10 秒 3 次,请求间隔 2 秒:

请求序号结果
第 1 到 3 个200Remaining 依次是 2、1、0
第 4、5 个429Reset 在倒计时
第 6 到 8 个又变回 200,窗口已经重置
第 9、10 个429

这一次运行就展示了这个算法的全部行为:先是一段允许通过的突发,然后撞墙,然后重置,再来一段突发。这其实也让边界问题变得可见了——重置前的三个请求和重置后的三个请求几乎在同一瞬间加起来放行了六个请求。

Juno观察它的重置过程 把测试客户端的速度放慢,这个技巧值得记住。如果一口气全打出去,所有事情都发生在同一个窗口里,限流器看起来就像一个简单的开关。

请求间隔两秒,你就能亲眼看到重置发生,而这正是真正能解释这个行为的部分。

Juno观察它的重置过程 用真正的客户端来测试,而不是刷新浏览器。浏览器会自己额外发出请求去加载图标和其他资源,每一个都会消耗掉你本来没打算花的额度,这会让数字在你还没搞明白之前就变得混乱。

一个按已知间隔发出已知数量请求的小脚本,才是能让限流器的行为变得清晰可读的工具。

Juno观察它的重置过程 像"每 10 秒 3 次"这种严格的限制只是用来观察行为的,永远不要用在生产环境里。真实的数字应该来自对合法客户端实际行为的测量,然后在最繁忙的那部分之上留出余量。

如果条件允许,先以"监控模式"上线限流器:只统计和记录本该被拒绝的请求,但先不真的拒绝它们。

观察一周之后,你就能知道这个数字到底是在保护你,还是给自己安排了一场停机事故。这比通过客服工单才发现问题要划算得多。

练习一下

某个 API 限制为每分钟 100 个请求,客户端却不断反馈请求会毫无征兆地失败。

js
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 地址——这是库默认的选择,不是你自己做的决定。

识别客户端 会把这个决定明确地摆到台面上来,因为限流计数所依据的对象不同,它保护的是谁、惩罚的又是谁,也就跟着不同。