使用 node:http 和 redis 的限流器
Source: Dev.to

介绍
当你运行一个普通的 node:http 服务器时,添加速率限制往往显得比实际需要的更繁重。大多数解决方案假设有 Express 风格的中间件或在你的应用前面的 API 网关。
这种方法更贴近底层——没有框架假设,没有神奇的生命周期钩子——只有决定请求是否可以继续的函数。
设计目标
- Framework‑agnostic: 直接使用
http.createServer。 - Redis‑backed: 对多个进程或服务器安全。
结果是您可以端到端阅读,而无需了解特定生态系统。
基础请求处理器
速率限制器遵循服务器其他地方使用的相同 RequestHandler 类型。处理器检查请求,可能会写入响应,并且要么结束请求,要么让其继续。
// request-handler.ts
import http from "node:http";
export type RequestHandler = (
incomingMessage: http.IncomingMessage,
serverResponse: http.ServerResponse,
) => ReturnType;
限流策略
固定窗口计数器
固定窗口策略直观简单:
- 每个客户端拥有一个 Redis 键。
- 请求会递增计数器。
- 键在
windowSize秒后过期。
当达到限制时,后续请求会被拒绝,直至窗口重置。
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import type { RequestHandler } from "./request-handler";
export function fixedWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Current count (0 if the key does not exist or is not a number)
const raw = await client.get(key);
const currentValue = raw ? parseInt(raw, 10) : 0;
const count = Number.isNaN(currentValue) ? 0 : currentValue;
// If the limit has been reached, reject the request
if (count >= config.limit) {
const ttl = await client.ttl(key);
const retryAfter =
ttl > 0
? new Date(Date.now() + ttl * 1000).toUTCString()
: new Date(Date.now() + config.windowSize * 1000).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Increment the counter and set the expiration atomically
const transaction = client.multi();
transaction.incr(key);
// NX ensures we only set the expiry the first time the key is created
transaction.expire(key, config.windowSize, "NX");
await transaction.exec();
};
}
何时适用
- 低至中等流量。
- 简单的滥用防护需求。
- 内部或其他受信任的客户端。
已知限制
- 流量可能在窗口边界出现突增(“突发”问题)。
- 限制执行较为粗粒度——仅在每个窗口检查一次限制。
如果这些约束可以接受,固定窗口因其简洁性几乎无可匹敌。
滑动窗口计数器
滑动窗口在稍微增加复杂度的同时,实现更平滑的限制。它不使用单一计数器,而是将请求分组到更小的 子窗口(或桶)中。
每个请求:
- 为当前子窗口的计数器递增。
- 汇总位于主窗口内的 所有 子窗口计数器。
- 如果总和超过配置的限制,则拒绝请求。
- 让 Redis 自动过期子窗口桶。
// redis-rate-limiter.ts
import http from "node:http";
import { createClient, createClientPool } from "redis";
import * as Utils from "../utils";
import type { RequestHandler } from "./request-handler";
export function slidingWindowCounter(
client: ReturnType<typeof createClient> | ReturnType<typeof createClientPool>,
config: {
limit: number;
windowSize: number; // seconds (overall window)
subWindowSize: number; // seconds (size of each bucket)
baseKey: string;
getClientIdFromIncomingRequest: (
incomingMessage: http.IncomingMessage,
) => string;
},
): RequestHandler {
return async (incomingMessage, serverResponse) => {
const key = `${config.baseKey}:${config.getClientIdFromIncomingRequest(
incomingMessage,
)}`;
// Retrieve all bucket counters for this client
const rawBuckets = await client.hGetAll(key);
const bucketValues = Object.values(rawBuckets)
.map((v) => parseInt(v, 10))
.map((v) => (Number.isNaN(v) ? 0 : v));
// Total requests in the sliding window
const total = Utils.getNumberArraySum(bucketValues);
// If the limit is exceeded, reject the request
if (total >= config.limit) {
const retryAfter = new Date(
Date.now() + config.subWindowSize * 1000,
).toUTCString();
serverResponse.statusCode = 429;
serverResponse.setHeader("Retry-After", retryAfter);
serverResponse.setHeader("Content-Type", "text/plain");
serverResponse.end("Too Many Requests");
return;
}
// Determine the bucket that corresponds to the current time
const now = Date.now();
const bucketTimestamp =
Math.floor(now / (config.subWindowSize * 1000)) *
(config.subWindowSize * 1000);
// Increment the bucket and set its TTL (only the first time it is created)
const transaction = client.multi();
transaction.hIncrBy(key, bucketTimestamp.toString(), 1);
// The bucket should live for the remainder of the main window
transaction.hExpire(
key,
bucketTimestamp.toString(),
config.windowSize,
"NX",
);
await transaction.exec();
权衡
| 方面 | 优点 | 缺点 |
|---|---|---|
| 请求分布 | 更平滑,在窗口边界没有硬性峰值。 | 稍微增加 Redis 内存使用(每个子窗口一个字段)。 |
| 准确性 | 近似真实滑动窗口;粒度 = subWindowSize。 | subWindowSize 较大时,近似会变得粗糙。 |
| 复杂度 | 仍然相对简单;仅使用原生 Redis 命令。 | 需要额外的账务处理(哈希字段、TTL 管理)。 |
| 性能 | 读取单个哈希(HGETALL),并原子写入两个哈希命令。 | HGETALL 的开销随活跃子窗口数量增长(上限为 windowSize / subWindowSize)。 |
| 突发处理 | 在遵守整体速率的前提下允许有限的突发。 | 如果突发完全落在单个子窗口内,极端的突发仍可能通过。 |
提示: 选择
subWindowSize使得windowSize / subWindowSize在大多数工作负载下保持在约 20–30 个桶以下;这可以让HGETALL操作保持低成本,同时仍提供细粒度的滑动窗口。
选择合适的策略
| 场景 | 推荐策略 |
|---|---|
| 简单的内部 API | 固定窗口计数器 – 代码最少,易于理解。 |
| 面向公众的端点,流量突发 | 滑动窗口计数器 – 限流更平滑,用户体验更好。 |
| 极高 QPS 且 Redis 内存受限 | 固定窗口 或 令牌桶 实现(未展示),每个客户端仅存储一个数值。 |
| 需要精确的速率限制(例如每秒限制) | 使用小 subWindowSize(例如 1 秒)的滑动窗口,或真正的令牌桶算法。 |
上述两种实现都可以直接嵌入现有的 Node.js 服务;只需传入一个 Redis 客户端实例和与签名匹配的配置对象。祝限流愉快!
缺点
- 更多的 Redis 操作。
- 稍微增加了一些复杂性。
- 仍然是有意的实用性,而非理论性。
Source:
在服务器中使用速率限制器
下面是一个最小示例,演示如何在 Node HTTP 服务器中接入速率限制器。
// server.ts
import http from "node:http";
import { createClient } from "redis";
import { fixedWindowCounter } from "./rate-limiters";
const redis = createClient();
await redis.connect();
const rateLimiter = fixedWindowCounter(redis, {
limit: 100,
windowSize: 60,
baseKey: "rate-limit",
// Extract a client identifier from the incoming request
getClientIdFromIncomingRequest: (req) =>
req.socket.remoteAddress ?? "unknown",
});
const server = http.createServer();
server.on("request", async (req, res) => {
await rateLimiter(req, res);
// If the limiter has already ended the response, stop processing
if (res.writableEnded) {
return;
}
res.statusCode = 200;
res.end("OK");
});
server.listen(3000);
如果限制器判断请求不应继续,它会结束响应,随后处理程序的其余部分将被跳过。
结束语
此设置并非旨在取代专用网关或边缘速率限制。它是一种务实且具有教育意义的解决方案。
- 所有操作都在普通的 Node 中进行。
- 计数工作由 Redis 完成。
- 只需阅读代码即可轻松理解其行为。
如果这正是你想要的设置方式,这种方法在不引入框架的情况下非常合适。
