使用 node:http 和 redis 的限流器

发布: (2025年12月18日 GMT+8 07:24)
9 min read
原文: Dev.to

Source: Dev.to

封面图片:“使用 node:http 和 Redis 的速率限制器”

Daniel Madrid

介绍

当你运行一个普通的 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();
  };
}

何时适用

  • 低至中等流量。
  • 简单的滥用防护需求。
  • 内部或其他受信任的客户端。

已知限制

  • 流量可能在窗口边界出现突增(“突发”问题)。
  • 限制执行较为粗粒度——仅在每个窗口检查一次限制。

如果这些约束可以接受,固定窗口因其简洁性几乎无可匹敌。

滑动窗口计数器

滑动窗口在稍微增加复杂度的同时,实现更平滑的限制。它不使用单一计数器,而是将请求分组到更小的 子窗口(或桶)中。

每个请求:

  1. 为当前子窗口的计数器递增。
  2. 汇总位于主窗口内的 所有 子窗口计数器。
  3. 如果总和超过配置的限制,则拒绝请求。
  4. 让 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 内存使用(每个子窗口一个字段)。
准确性近似真实滑动窗口;粒度 = subWindowSizesubWindowSize 较大时,近似会变得粗糙。
复杂度仍然相对简单;仅使用原生 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 完成。
  • 只需阅读代码即可轻松理解其行为。

如果这正是你想要的设置方式,这种方法在不引入框架的情况下非常合适。

Back to Blog

相关文章

阅读更多 »

澳大利亚首选的Web技术栈

为什么在澳大利亚选择技术栈很重要 澳大利亚企业优先考虑质量、安全性和性能。网站被期望能够无缝运行...