返回博客列表
API 安全Node.js后端开发

API 请求频率限制:从内存实现到 Redis 升级

从零实现一个轻量级 Rate Limiter,并讨论何时该升级到分布式方案

作者: ekent·发布于 2026年1月18日

API 接口如果不做频率限制,迟早会被滥用——可能是爬虫、可能是恶意攻击、也可能是前端 bug 导致的重复请求。我们在实际项目中实现了一个从内存级方案起步、后续可平滑升级到 Redis 的 Rate Limiter,这里分享设计思路。

最简方案:内存 Map

核心思路是用一个 Map 记录每个 IP 的请求次数和时间窗口:

interface RateLimitEntry {
  count: number;
  resetTime: number;
}

const store = new Map<string, RateLimitEntry>();

const WINDOW_MS = 60 * 1000;  // 1 分钟窗口
const MAX_REQUESTS = 60;       // 每窗口最多 60 次

function isRateLimited(identifier: string): boolean {
  const now = Date.now();
  const entry = store.get(identifier);

  if (!entry || now > entry.resetTime) {
    store.set(identifier, { count: 1, resetTime: now + WINDOW_MS });
    return false;
  }

  entry.count++;
  return entry.count > MAX_REQUESTS;
}

在 API 路由中使用:

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  if (isRateLimited(ip)) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  // 正常业务逻辑...
}

IP 识别的细节

获取真实客户端 IP 并不像想象中简单。在反向代理(Nginx、CDN)后面,request.ip 可能是代理服务器的地址。正确的做法是按优先级读取:

function getClientIP(request: Request): string {
  const forwarded = request.headers.get('x-forwarded-for');
  if (forwarded) {
    // 取第一个 IP(最接近客户端的)
    return forwarded.split(',')[0].trim();
  }

  const realIP = request.headers.get('x-real-ip');
  if (realIP) return realIP;

  return 'unknown';
}

注意:x-forwarded-for 可以被伪造,生产环境中要确保你的反向代理会覆盖这个头而不是追加。

内存泄漏防护

内存方案最大的隐患是 Map 无限增长。每个不同的 IP 都会添加一条记录,长时间运行后内存持续上升。必须定期清理过期条目:

// 每 5 分钟清理一次
setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of store) {
    if (now > entry.resetTime) {
      store.delete(key);
    }
  }
}, 5 * 60 * 1000);

这个清理逻辑看似简单,但很容易被遗忘。我们在项目中就因为忘了加清理,导致一个月后服务内存从 200MB 涨到了 1.2GB。

分层限流策略

实际业务中,不同接口的限流策略往往不同:

const RATE_LIMITS = {
  login:    { window: 5 * 60 * 1000, max: 5 },   // 登录:5分钟5次
  api:      { window: 60 * 1000, max: 100 },      // 普通接口:1分钟100次
  upload:   { window: 60 * 1000, max: 10 },       // 上传:1分钟10次
};

function isRateLimited(ip: string, type: keyof typeof RATE_LIMITS): boolean {
  const config = RATE_LIMITS[type];
  const key = `${type}:${ip}`;
  // ...同样的逻辑,使用 config.window 和 config.max
}

登录接口要严格限制(防暴力破解),文件上传要限制(防资源滥用),普通查询可以宽松一些。

什么时候该升级到 Redis

内存方案在以下场景不够用:

  • 多实例部署:每个进程有自己的 Map,限流形同虚设。用户轮询到不同实例就绕过了限制。
  • 重启丢失:服务重启后所有计数器清零,等于限流短暂失效。
  • 内存压力:高并发场景下 IP 数量巨大,内存方案不经济。

升级到 Redis 的改动很小,核心逻辑不变,只是把存储从 Map 换成 Redis:

import Redis from 'ioredis';
const redis = new Redis();

async function isRateLimited(ip: string): Promise<boolean> {
  const key = `rate:${ip}`;
  const count = await redis.incr(key);

  if (count === 1) {
    await redis.expire(key, 60); // 60 秒过期
  }

  return count > MAX_REQUESTS;
}

Redis 的 INCR + EXPIRE 组合天然支持计数和自动过期,不需要手动清理。而且多实例共享同一个 Redis,限流策略全局生效。

响应头的规范

好的 Rate Limiter 应该在响应头中告知客户端限流状态:

const headers = {
  'X-RateLimit-Limit': String(MAX_REQUESTS),
  'X-RateLimit-Remaining': String(Math.max(0, MAX_REQUESTS - count)),
  'X-RateLimit-Reset': String(Math.ceil(resetTime / 1000)),
};

前端拿到这些信息后可以做自适应请求(比如接近限额时降低请求频率),而不是等到被 429 了才知道。

总结

Rate Limiter 的实现不复杂,但选对方案很重要。单实例应用用内存 Map 就够了,记得加过期清理;多实例部署必须上 Redis。不管哪种方案,核心都是:识别客户端 → 计数 → 判断阈值 → 返回合适的状态码和头信息。