正在加载,请稍候…

什么是速率限制?算法、实现与最佳实践

了解速率限制——为什么重要、主要算法(令牌桶、滑动窗口)、如何在 Express 中实现以及标准的速率限制 HTTP 头部。

什么是速率限制?算法、实现与最佳实践

什么是速率限制?

速率限制控制客户端在时间窗口内可以向 API 发出的请求数量。如果没有它,单个行为异常的客户端可能会淹没你的服务器,从而降低其他所有人的服务质量。

速率限制的重要性:

  • 防御 DoS/DDoS 攻击
  • 防止 API 滥用和爬取
  • 确保客户端之间的公平资源分配
  • 降低基础设施成本
  • 捕获意外错误(如紧循环频繁调用 API)

什么是速率限制?算法、实现与最佳实践 插图

速率限制算法

1. 固定窗口计数器

在固定时间窗口内计数请求(例如,每个从 :00 开始的 60 秒分钟)。

async function isRateLimited(clientId, limit = 100, windowSeconds = 60) {
  const key = `rate:${clientId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, windowSeconds);
  return count > limit;
}

问题: 边界突发——客户端可以在 1:59 发出 100 个请求,在 2:00 再发出 100 个,2 秒内发送 200 个请求。

什么是速率限制?算法、实现与最佳实践 插图

2. 滑动窗口计数器(推荐)

使用当前窗口和前一个窗口的加权计数来近似真正的滑动窗口:

async function isRateLimitedSliding(clientId, limit = 100, windowSeconds = 60) {
  const now = Math.floor(Date.now() / 1000);
  const currWindow = Math.floor(now / windowSeconds);
  const prevWindow = currWindow - 1;
  const elapsed = now % windowSeconds;

  const [prevCount, currCount] = await redis.mget(
    `rate:${clientId}:${prevWindow}`,
    `rate:${clientId}:${currWindow}`
  );

  const weighted = (Number(prevCount) || 0) * (1 - elapsed / windowSeconds)
                 + (Number(currCount) || 0);
  if (weighted >= limit) return true;

  const pipeline = redis.pipeline();
  pipeline.incr(`rate:${clientId}:${currWindow}`);
  pipeline.expire(`rate:${clientId}:${currWindow}`, windowSeconds * 2);
  await pipeline.exec();
  return false;
}

3. 令牌桶

桶中持有 N 个令牌。请求消耗令牌。桶以固定速率补充。允许短时突发,同时保持平均速率。

// Token bucket in Redis using Lua (atomic)
const script = `
  local key, capacity, rate, now = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
  local data = redis.call('HMGET', key, 'tokens', 'ts')
  local tokens = tonumber(data[1]) or capacity
  local ts = tonumber(data[2]) or now
  tokens = math.min(capacity, tokens + (now - ts) * rate)
  if tokens < 1 then return 0 end
  redis.call('HMSET', key, 'tokens', tokens - 1, 'ts', now)
  redis.call('EXPIRE', key, 3600)
  return 1
`;

async function isAllowed(clientId) {
  return (await redis.eval(script, 1,
    `bucket:${clientId}`, 100, 1.67, Date.now()/1000)) === 1;
}

什么是速率限制?算法、实现与最佳实践 插图

速率限制 HTTP 头部

RateLimit-Limit: 100           # 每个窗口的最大请求数
RateLimit-Remaining: 42        # 当前窗口剩余请求数
RateLimit-Reset: 1716854460    # 窗口重置的 Unix 时间戳
Retry-After: 30               # 等待秒数(在 429 响应中)

Express 中间件

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');

// 全局速率限制
app.use(rateLimit({
  windowMs: 60 * 1000,  max: 100,
  standardHeaders: true, legacyHeaders: false,
  store: new RedisStore({ client: redis }),
}));

// 对敏感端点更严格
app.use('/auth/login', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  skipSuccessfulRequests: true,
}));

客户端:处理 429 并重试

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let i = 0; i <= maxRetries; i++) {
    const res = await fetch(url, options);
    if (res.status !== 429) return res;
    const delay = (parseInt(res.headers.get('Retry-After')) || Math.pow(2, i)) * 1000;
    await new Promise(r => setTimeout(r, delay));
  }
  throw new Error('Max retries exceeded');
}

速率限制策略

  • 按 IP: 简单但容易被代理绕过。适用于匿名访问。
  • 按 API 密钥: 更可靠,需要身份验证。
  • 按端点: 昂贵操作设置更严格的限制(搜索:10/分钟,读取:1000/分钟)。
  • 分层: 根据订阅级别(免费/专业/企业)设置不同限制。

→ 使用令牌生成器生成安全的 API 密钥令牌。