Node.js 的 express-rate-limit 中间件几乎是每个 Express 应用的首选限流方案。它配置简单,几行代码就能保护接口不被暴力刷爆。但当你信心满满地将应用部署到生产环境,用 PM2 开启多进程集群模式,或者用 Docker 容器横向扩展出多个实例后,这个看似可靠的防线瞬间就形同虚设了。问题根源在于 express-rate-limit 默认使用的内存存储是进程级别的,每个 Node.js 进程都维护着自己独立的一份计数器,它们之间完全隔离,互不可见。
进程内存储的工作机制与天然缺陷express-rate-limit 默认不配置 store 选项时,内部会创建一个 MemoryStore 实例。这个内存存储本质上就是一个 JavaScript 对象,键是客户端 IP 地址,值是包含请求次数和窗口起始时间的数据结构。每次请求进来,中间件就查这个对象,判断是否超限。在单进程模式下,这套机制运行得天衣无缝,所有请求都打到同一个进程,计数器自然是准确的。
但生产环境几乎不会只跑单进程。Node.js 是单线程事件循环模型,为了充分利用多核 CPU,运维通常会使用 PM2 的 cluster 模式启动多个工作进程,或者用容器编排工具水平扩展多个实例,前面再挂一个 Nginx 做负载均衡。问题就在这里出现了:同一个用户的请求可能被负载均衡随机分配到进程 A,下一次又被分配到进程 B。进程 A 的计数器里记录了这个用户已经请求了 5 次,但进程 B 完全不知道这件事,又从零开始计数。最终结果是,用户实际可以发出的请求数量是“限制次数乘以进程数”。如果你设置每分钟限制 100 次,部署了 8 个进程,攻击者理论上每分钟可以发出 800 次请求而不触发任何限流。
负载均衡策略带来的叠加效应有人可能会想,如果把负载均衡的会话保持机制打开,让同一个 IP 始终路由到同一个进程,是不是就解决问题了?确实,基于源 IP 哈希的会话保持能缓解这个问题,但它引入了新的复杂性。首先,Nginx 或 HAProxy 的 ip_hash 策略在代理层做了一层映射,但如果你后端服务动态扩缩容,进程数量变化会导致哈希重分配,部分用户会话突然跳到别的进程,计数器又从零开始。其次,如果前端还套了 CDN 或反向代理,真实客户端 IP 的获取本身就需要正确配置 X-Forwarded-For 头,而 express-rate-limit 需要额外设置 trust proxy 才能正确识别。这些环节任何一个配置不当,限流都会失效。
更关键的是,把会话保持作为解决方案本身就是治标不治本。它让应用架构与限流逻辑耦合了,你为了限流准确而限制了负载均衡的灵活性。真正健康的架构应该是无状态的,任何进程可以处理任何请求,限流逻辑应该从应用进程中剥离出来,放到一个共享的存储层。
分布式限流的三种主流实现路径要彻底解决进程存储局限,核心思路就是把计数器从进程内存迁移到外部共享存储。目前业界成熟的方案有三条路径,各有适用场景。
第一条路径是使用 Redis 作为集中式计数器。express-rate-limit 官方提供了 rate-limit-redis 这个 store 实现,内部使用 Redis 的 INCR 和 EXPIRE 命令来原子性地递增计数并设置过期时间。一个典型的配置如下:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redisClient = new Redis({
host: '127.0.0.1',
port: 6379,
enableOfflineQueue: false,
});
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
}),
});
app.use(limiter);
这个方案的优势在于 Redis 本身就是高性能的内存数据库,单机就能轻松支撑每秒数十万次的计数操作,而且 Redis 的命令是原子性的,天然避免了并发竞争问题。但引入 Redis 也意味着架构中多了一个需要维护的组件,需要处理连接断开重连、主从切换、内存上限等问题。如果你的应用本身已经在用 Redis 做缓存或会话存储,那这个方案几乎没有额外成本。
第二条路径是使用关系型数据库作为计数器存储。express-rate-limit 社区有基于 Sequelize、Knex 等 ORM 的 store 实现。但这条路通常不推荐,因为关系型数据库的磁盘 I/O 性能远不如内存存储,高并发下的 UPDATE 操作容易产生行锁竞争,反而成为性能瓶颈。除非你的应用流量很小,或者对限流精度要求不高,否则不要轻易尝试这条路。
第三条路径是在反向代理层面做限流,完全绕过应用层。Nginx 有 ngx_http_limit_req_module 模块,基于共享内存区域实现全局限流,所有 worker 进程共享同一个计数器。配置示例如下:
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend;
}
}
}
这个方案把限流职责前移到了入口网关,应用进程完全不用关心限流逻辑,彻底实现了关注点分离。Nginx 的限流基于共享内存,性能极高,而且不依赖外部服务。缺点也很明显:限流规则配置在 Nginx 配置文件里,修改不如应用层灵活,无法根据业务逻辑做精细化控制,比如针对不同 API 路径、不同用户角色设置不同的限制策略。
Redis 方案的深度剖析与生产实践回到最常用的 Redis 方案,有几个生产环境的关键细节需要特别注意。首先是 Redis 连接池的管理。rate-limit-redis 默认每次操作都通过 sendCommand 回调执行,如果你使用的是 ioredis 这类自带连接池的客户端,通常不需要额外处理。但如果你用的是 node-redis 旧版本,需要确认连接池配置是否合理,避免高并发下连接数耗尽。
其次是 Redis 不可用时的降级策略。Redis 服务器总有出故障的可能,网络抖动也会导致连接超时。express-rate-limit 提供了一个 skipFailedRequests 选项,当设置为 true 时,如果 Redis 操作失败,中间件会跳过限流检查,允许请求通过。这在某些业务场景下是合理的,优先保证服务可用性。但如果你对安全要求极高,应该设置为 false,让请求被拒绝。更稳妥的做法是结合 skip 函数做动态判断:
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
}),
skipFailedRequests: false,
skip: (req) => {
// 如果 Redis 连接断开,走本地内存降级
return redisClient.status !== 'ready';
},
});
这样当 Redis 不可用时,自动回退到进程内存储,虽然限流不准确了,但至少不会把所有请求都拦截掉。当然,这个降级策略需要配合监控告警,Redis 出问题必须第一时间通知运维。
另一个容易被忽略的细节是内存占用。Redis 里每个限流键都设置了过期时间,正常情况下过期后内存会自动释放。但如果你的应用遭受了大规模 IP 扫描攻击,短时间内可能产生海量的限流键,导致 Redis 内存暴涨。建议对 Redis 设置 maxmemory 并配置淘汰策略为 volatile-lru,这样即使内存满了,也只会淘汰带过期时间的限流键,不影响其他持久化数据。
固定窗口算法的边界问题与滑动窗口的改进express-rate-limit 默认使用的是固定窗口计数算法。这个算法有个著名的边界突刺问题:如果窗口是 1 分钟,用户在 0 分 59 秒发满 100 个请求,然后在 1 分 01 秒又发 100 个请求,实际上在短短几秒内发出了 200 个请求,但两次都落在不同的窗口里,都没有触发限流。这个缺陷与存储介质无关,是算法本身的问题。
要解决这个问题,可以引入滑动窗口算法。rate-limit-redis 实际上支持多种后端算法,你可以选择滑动窗口日志或滑动窗口计数器。滑动窗口日志会记录每次请求的时间戳,判断时统计窗口内实际请求数,精度最高但存储开销大。滑动窗口计数器则结合了固定窗口和加权计算,是一种折中方案。如果你的业务对限流精度要求很高,比如支付接口或短信验证码发送接口,建议升级到滑动窗口算法。
实现滑动窗口并不复杂,你可以基于 Redis 的 Sorted Set 数据结构自行实现。每次请求时,用 ZREMRANGEBYSCORE 删除窗口外的记录,再用 ZCARD 统计当前窗口内的请求数,最后 ZADD 添加当前请求的时间戳。这套操作可以用 Lua 脚本打包成一个原子操作,避免多次网络往返。
多维度限流的存储设计实际业务中,限流往往不是只按 IP 来做的。你可能需要对某个用户 ID 限流,对某个 API 端点限流,或者对“IP + 端点”的组合限流。express-rate-limit 的 keyGenerator 函数允许你自定义限流键的生成逻辑:
const limiter = rateLimit({
keyGenerator: (req) => {
const userId = req.user?.id || req.ip;
const path = req.originalUrl;
return `${userId}:${path}`;
},
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rate-limit:',
}),
});
这种多维度限流对 Redis 的键数量有显著影响。假设你有 100 个 API 端点,每个用户都可能触发针对不同端点的限流键,键的总数就是用户数乘以端点数。随着业务增长,Redis 内存压力会线性增加。建议对键的命名做规范化设计,统一设置合理的过期时间,并定期监控键数量的增长趋势。
从进程存储到集中式存储的迁移检查清单如果你正在计划从默认内存存储迁移到 Redis 或其他集中式存储,以下是一份实用的检查清单。第一,确认你的 express-rate-limit 版本,v6 以上版本的 store 接口与 v5 不兼容,升级时注意 API 变化。第二,在测试环境模拟多进程场景,用 Apache Bench 或 k6 工具压测,验证限流阈值在多进程下是否准确。第三,检查所有自定义的 skip、keyGenerator、handler 函数,确保它们在集中式存储下行为符合预期。第四,为 Redis 连接配置健康检查和自动重连,ioredis 默认会自动重连,但你需要设置 retryStrategy 来控制重试间隔和最大重试次数。第五,添加监控指标,包括限流触发次数、Redis 操作延迟、Redis 连接状态,这些数据在排查问题时至关重要。
express-rate-limit 的进程存储局限本质上是一个架构演进问题。单机开发时它开箱即用,足够方便。但当应用走向分布式部署,就必须正视这个设计边界,主动将限流状态外移。好在解决方案已经非常成熟,Redis 这条路径经过了大量生产环境的验证,只要注意好连接管理、降级策略和算法选择,就能构建起真正可靠的分布式限流防线。
