识别客户端
"每分钟五次请求"这个限制,在你说清楚"五次针对什么"之前,什么都不意味着。
速率限制只有在你能持续判断出是谁在发起请求时才有效,而你选择的标识符会同时决定四件事:
- 公平性。 你限制的是不是正确的对象?
- 有效性。 有没有人能绕开它?
- 体验。 合法用户会不会被误伤?
- 安全性。 标识符本身会不会被滥用?
每一条速率限制背后都藏着这个问题:你到底想限制的是谁,或者是什么?
IP 地址
这是默认选项,也是你不用特意去要就会得到的那个。express-rate-limit 默认使用客户端的地址,除非你另行指定——上一章里的限流器一直就是这么做的。
适用于没有登录机制的公开接口、基础的滥用防护,以及简单洪水攻击的钝化处理。
它的吸引力在于它总是存在。不需要身份验证,对匿名流量也有效,实现简单,而且普通用户没法随意更改它。
问题在于地址是共享的,而且会变动。一间办公室里的几百名员工共用一条出口连接,在系统看来就是同一个客户端;图书馆里的所有人、运营商背后大量的移动用户也是同样的情况。
与此同时,一个地址不断变动的用户每换一次地址就会得到一份全新的额度,而 IPv6 又会在一个子网内分配大量地址。
与其被动继承默认行为,不如把这个选择明确写出来:
import { rateLimit, ipKeyGenerator } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
keyGenerator: (req, res) => ipKeyGenerator(req.ip),
})keyGenerator 返回的是请求计数所依据的字符串。下面所有内容都基于这个钩子。
这个策略的所有问题,都源于这个落差。
req.ip 拿到的是代理自己的地址,于是每个请求看起来都来自同一个客户端,你的限制实际上一次性套用在了全世界头上。 Express 需要设置 app.set('trust proxy', ...) 才能改为读取转发过来的地址。要把它设为你实际部署的代理数量,因为盲目信任这个请求头会让客户端随意伪造任意地址,从而完全绕开限制。
答案是按前缀限流。express-rate-limit 8 为此暴露了 ipv6Subnet 选项,默认值为 56,数值越小分组就越激进。
ipKeyGenerator 这个辅助函数存在的理由也一样:它把地址标准化,让 IPv4 和 IPv6 客户端能用一致的方式生成键。直接把 req.ip 写进键里,等到 IPv6 流量到来的那天就会悄无声息地失效。
已认证用户 ID
一旦有人登录,你就能准确知道他是谁——通过会话、JWT 声明或数据库查询得知。
适用于需要认证的接口、按用户配额限流,以及分级套餐。
每个真实用户都有自己独立的额度,谁都不会影响到别人。这个标识会跟着用户跨设备、跨网络移动,换地址也没用,因为身份认证的是"人",而不是"连接"。
它做不到的是保护登录之前的任何环节。注册、密码重置和公开接口都发生在用户 ID 存在之前,而这些恰恰是滥用最常盯上的地方。
const limiter = rateLimit({
limit: 3,
windowMs: 10000,
keyGenerator: (req) => req.user.id,
})
app.get('/api/data', authenticate, limiter, handler)顺序很关键。Express 的中间件是从左到右执行的,所以身份验证必须先设置好 req.user,限流器才能读到它。如果把限流器放在前面,它读到的会是所有人都一样的 undefined,等于把每一个请求都归到了同一个桶里。
这就是公平性在起作用。换成按地址限流,同一间办公室的三个人就得共用一份三次的额度了。
req.user.id 的限流器,要么会因为 undefined 而报错,要么会把所有人都归到同一个值上——等于给你的整个用户群设了一个共享限制。 这两种情况在正常路径的测试中都不会暴露出来,因为测试里只有一个用户,根本看不出差别。
一旦限制按用户区分,它本身也就成了产品的一部分。免费和付费套餐拥有不同额度,就意味着限制值必须是请求的函数而不是常量,而套餐等级的查询也因此落在了每个请求的关键路径上,记得把它缓存起来。
API 密钥与会话 ID
再来两种标识符,各自适合不同类型的调用方。
| API 密钥 | 会话 ID | |
|---|---|---|
| 适用于 | 第三方集成、面向企业的 API、分级套餐 | 有会话机制的 Web 应用、购物车、结账和表单流程 |
| 优势 | 绑定到具体应用、支持分级、滥用时可撤销、易于追踪 | 对登录用户和访客都适用、比地址更持久、能跟踪完整的操作流程 |
| 劣势 | 密钥容易被共享和泄露、一个密钥可能服务成千上万的终端用户、需要密钥管理 | 清除 cookie 就会重置、存在会话固定攻击的风险、无状态 API 根本没有会话 |
用 API 密钥做分级很自然,因为 limit 可以是请求的函数:
import { getUserTier } from './services/billing.js'
const limiter = rateLimit({
windowMs: 60000,
keyGenerator: (req) => req.headers['x-api-key'],
limit: (req) => (getUserTier(req.headers['x-api-key']) === 'pro' ? 1000 : 100),
})会话 ID 的读取方式相同,来自 req.session.id。
API 密钥标识的是一个应用。用户 ID 标识的是一个人。会话标识的是一次访问。地址标识的是一条连接。选那个和你想限制的对象相匹配的。
一个拥有五万终端用户的合作伙伴只有一个密钥,于是一个用户捣乱,就会消耗掉大家共用的额度,导致整个集成都被限流。
如果这个问题要紧,密钥就需要同时拥有一个按密钥计算的总额度,以及内部再按终端用户细分的额度——这意味着需要在集成中传递一个终端用户标识符。
无论按什么生成键,都有两件事值得做。永远不要把原始密钥直接放进键里,因为限流器的键最终会出现在日志和存储数据的转储中,所以要先对 API 密钥做哈希处理。
还要想清楚标识符缺失时会发生什么,因为 keyGenerator 返回 undefined 会把所有这类请求都归到同一个桶里。这可能是个有用的兜底方案,也可能是意外造成的共享限制——关键在于这应该是你主动做出的选择。
组合使用
实际系统很少只选一种。常见的做法是一条带回退的链条,优先使用最精确的:
- 已认证用户 ID,用户已登录时使用。
- API 密钥,用于集成场景。
- 会话 ID,用于有会话的访客。
- IP 地址,作为最后的兜底手段。
每往下一步都精确度更低、可用性更高,所以每个请求总能用它实际拥有的最佳标识符来计数。
已登录的用户是你能识别到的最清晰的对象。地址是最模糊的,也是唯一始终存在的那个。
通常更好的做法是运行多个各自拥有独立额度的限流器:给匿名流量一个严格的限制,给已认证用户一个宽松的限制,而不是用一个限流器去切换不同的键。
user:4821,而不是裸的 4821。不这样做的话,用户 ID 和会话 ID 可能撞上同一个字符串,导致两个毫不相关的客户端共用了一份额度。 这条链条还掩盖了另一件事:它本质上是一条升级路径——攻击者会挑成本最低的那一级下手。
所以这条链条的强度取决于最薄弱的那一环,限制应该随着标识符越来越匿名而收得更紧,而不是从头到尾保持一致。
动手试试
某个 API 按每个 IP 地址每分钟 100 次请求做限流。三条投诉找上门来。
- 一家有 400 名员工的公司说应用每天下午都会失灵。
- 一个用移动网络的用户说这个限制对他好像从来都不起作用。
- 有人正在以每小时约一万次请求的速度爬取公开目录,而且没有任何东西能拦住他。
对照一下你的答案
| # | 实际发生了什么 | 解决方法 |
|---|---|---|
| 1 | 400 人共用一个出口地址,于是 400 人共用每分钟 100 次请求的额度 | 改为按已认证用户 ID 计数,把按地址限流仅保留给匿名流量 |
| 2 | 运营商不断把他在不同地址之间切换,每换一次都会得到一份全新的额度 | 同样的解决方法。跟随用户本人的身份不会因为重新连接而被甩掉 |
| 3 | 目录是公开的,没有用户可供计数,而地址对爬虫来说获取成本几乎为零 | 收紧匿名限制,按 IPv6 前缀而不是单个地址来限流,再加上前置的其他手段。这个问题只能被缓解,无法被彻底解决 |
第一条和第二条其实是同一个缺陷从两个角度看到的结果:地址对办公室来说太粗糙,对移动用户来说又太精细。
第三条则是这整套技术诚实的天花板。速率限制让滥用行为付出代价,而面对一个公开接口,这个代价的上限永远只能等于你最便宜的那个标识符的获取成本。
接下来往哪走
限流器现在能正确计数,也计到了正确的对象上。它还剩下的问题是固定窗口边界处的突发流量,会在窗口重置前后放行两倍于限制的请求量。
滑动窗口算法 解决了这个问题,它衡量的窗口会随请求移动,而不是死死卡在时钟的某个区块上。

