API 限流实战:从令牌桶到滑动窗口的完整实现指南

深入解析 API 限流(Rate Limiting)核心算法,手写令牌桶、滑动窗口、滑动日志三种实现,含 Redis 分布式方案、Nginx 配置、Node.js 中间件,附完整代码与性能对比

API 设计 2026-06-06 12 分钟

你的 API 在凌晨 3 点突然收到每秒 5000 次的异常请求,数据库连接池被打满,整个服务雪崩——这不是假设场景,而是每个后端开发者迟早会遇到的真实灾难。根据 Cloudflare 2025 年的报告,超过 67% 的 API 事故可以通过合理的限流(Rate Limiting)策略避免。限流不是可选的锦上添花,而是生产环境的生存底线。

本文不讲理论废话,直接从零手写三种核心限流算法,覆盖单机到分布式场景,给出可直接复制到生产环境的代码。

🔐 一、三种核心限流算法:原理与手写实现

1.1 令牌桶(Token Bucket)

令牌桶是最经典的限流算法。它以固定速率向桶中放入令牌,请求到来时消耗一个令牌,桶空则拒绝。它的核心优势是允许突发流量——只要桶中有足够的令牌,短时间内可以处理超过平均速率的请求。

// 令牌桶(Token Bucket)限流器 —— 手写实现
class TokenBucket {
  /**
   * @param {number} capacity   - 桶容量(最大令牌数)
   * @param {number} refillRate - 每秒补充的令牌数
   */
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.refillRate = refillRate;
    this.tokens = capacity;           // 初始满桶
    this.lastRefill = Date.now();
  }

  // 尝试消费一个令牌,返回 true 表示允许
  consume(tokens = 1) {
    this._refill();
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return { allowed: true, remaining: this.tokens };
    }
    // 计算需要等待多久才能获得令牌
    const waitMs = ((tokens - this.tokens) / this.refillRate) * 1000;
    return { allowed: false, remaining: this.tokens, retryAfterMs: waitMs };
  }

  // 按时间差补充令牌
  _refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000; // 秒
    const newTokens = elapsed * this.refillRate;
    this.tokens = Math.min(this.capacity, this.tokens + newTokens);
    this.lastRefill = now;
  }
}

// 使用示例:每秒放行 10 个请求,桶容量 20(允许最多 20 的突发)
const bucket = new TokenBucket(20, 10);

function handleRequest(req) {
  const result = bucket.consume();
  if (result.allowed) {
    return { status: 200, body: 'OK', remaining: result.remaining };
  }
  return {
    status: 429,
    body: 'Too Many Requests',
    retryAfterMs: result.retryAfterMs
  };
}

💡 **提示:**令牌桶适合大多数 API 场景。它既能控制平均速率,又允许合理的突发流量,用户体验最佳。

1.2 固定窗口计数器(Fixed Window Counter)

固定窗口是最简单的实现:按时间窗口计数,超限则拒绝。简单是优点也是致命缺陷——窗口边界突发问题。如果限制是每分钟 100 次,用户在第 59 秒发起 100 次、第 60 秒(新窗口)再发起 100 次,实际上 2 秒内就有 200 次请求。

// 固定窗口计数器 —— 简单但有边界问题
class FixedWindowCounter {
  /**
   * @param {number} maxRequests - 窗口内最大请求数
   * @param {number} windowMs    - 窗口大小(毫秒)
   */
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.count = 0;
    this.windowStart = Date.now();
  }

  consume() {
    const now = Date.now();
    // 窗口过期,重置计数
    if (now - this.windowStart >= this.windowMs) {
      this.count = 0;
      this.windowStart = now;
    }

    if (this.count < this.maxRequests) {
      this.count++;
      return { allowed: true, remaining: this.maxRequests - this.count };
    }

    const retryAfterMs = this.windowMs - (now - this.windowStart);
    return { allowed: false, remaining: 0, retryAfterMs };
  }
}

⚠️ **警告:**固定窗口的窗口边界突发问题在生产中非常危险。除非你只是做快速原型验证,否则不要单独使用固定窗口。

1.3 滑动窗口日志(Sliding Window Log)

滑动窗口日志记录每个请求的时间戳,统计过去 N 秒内的请求数。精度最高,但内存消耗与请求数成正比——如果限制是每分钟 10000 次,就需要存储 10000 个时间戳。

// 滑动窗口日志 —— 精确但内存开销大
class SlidingWindowLog {
  /**
   * @param {number} maxRequests - 窗口内最大请求数
   * @param {number} windowMs    - 窗口大小(毫秒)
   */
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.log = []; // 存储请求时间戳
  }

  consume() {
    const now = Date.now();
    const windowStart = now - this.windowMs;

    // 清理过期时间戳
    this.log = this.log.filter(ts => ts > windowStart);

    if (this.log.length < this.maxRequests) {
      this.log.push(now);
      return { allowed: true, remaining: this.maxRequests - this.log.length };
    }

    // 最早的请求过期时间就是重试时间
    const retryAfterMs = this.log[0] + this.windowMs - now;
    return { allowed: false, remaining: 0, retryAfterMs };
  }
}

1.4 算法对比

下表是三种核心算法的关键特性对比,帮助你根据场景做出选择:

特性 令牌桶 固定窗口 滑动窗口日志
突发容忍 ✅ 支持 ❌ 边界问题 ❌ 严格限制
内存消耗 ✅ 极低(2 个变量) ✅ 极低(1 个计数器) ❌ 与请求数成正比
实现复杂度 ⭐⭐ 低 ⭐ 最低 ⭐⭐⭐ 中
精度 ⭐⭐ 高 ⭐ 低(边界问题) ⭐⭐⭐ 最高
适合场景 通用 API 快速原型 安全敏感接口
分布式支持 ✅ 易实现 ✅ 易实现 ⚠️ 需要有序集合

⚡ **关键结论:**90% 的场景选择令牌桶就够了。安全敏感场景(如登录接口)用滑动窗口日志。

🚀 二、生产级实现:Redis 分布式限流

单机限流在多实例部署时失效——你有 4 个实例,每个限流 100 次/秒,实际总限制变成了 400 次/秒。分布式限流的核心是用 Redis 作为共享计数器

2.1 基于 Redis 的滑动窗口限流(Lua 脚本)

Lua 脚本在 Redis 中原子执行,避免了并发竞态条件。这是生产环境中最推荐的方案:

-- sliding_window_rate_limit.lua
-- 基于 Redis 的滑动窗口限流(原子操作)
-- KEYS[1] = 限流键名(如 "rate:user:12345")
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 最大请求数
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 唯一请求 ID(用于去重)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestId = ARGV[4]

local windowStart = now - window

-- 清理窗口外的过期成员
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)

-- 统计当前窗口内的请求数
local currentCount = redis.call('ZCARD', key)

if currentCount < maxRequests then
  -- 未超限,添加当前请求
  redis.call('ZADD', key, now, requestId)
  redis.call('PEXPIRE', key, window)
  return { 1, maxRequests - currentCount - 1 }  -- allowed, remaining
else
  -- 已超限,计算重试时间
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local retryAfter = tonumber(oldest[2]) + window - now
  return { 0, 0, retryAfter }  -- denied, remaining=0, retryAfter
end
// Node.js 中调用 Redis Lua 限流脚本
import Redis from 'ioredis';
import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';

const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 预加载 Lua 脚本,获取 SHA1 哈希(只需加载一次)
const luaScript = readFileSync('./sliding_window_rate_limit.lua', 'utf-8');
let scriptSha = null;

async function loadScript() {
  scriptSha = await redis.script('LOAD', luaScript);
}

async function checkRateLimit(userId, { windowMs = 60000, maxRequests = 100 } = {}) {
  const key = `rate:${userId}`;
  const now = Date.now();
  const requestId = randomUUID();

  // 使用 EVALSHA 执行已缓存的脚本(性能更好)
  const result = await redis.evalsha(
    scriptSha || await (loadScript(), scriptSha),
    1,               // KEYS 数量
    key,             // KEYS[1]
    windowMs,        // ARGV[1]
    maxRequests,     // ARGV[2]
    now,             // ARGV[3]
    requestId        // ARGV[4]
  );

  const [allowed, remaining, retryAfter] = result;
  return {
    allowed: allowed === 1,
    remaining,
    retryAfter: retryAfter || null,
    headers: {
      'X-RateLimit-Limit': maxRequests,
      'X-RateLimit-Remaining': remaining,
      'X-RateLimit-Reset': Math.ceil((now + (retryAfter || windowMs)) / 1000),
      ...(retryAfter && { 'Retry-After': Math.ceil(retryAfter / 1000) })
    }
  };
}

2.2 Express/Koa 中间件封装

// rate-limit-middleware.js —— 通用限流中间件
export function createRateLimiter(options = {}) {
  const {
    windowMs = 60 * 1000,     // 默认 1 分钟窗口
    maxRequests = 100,         // 默认每窗口 100 次
    keyGenerator = (req) => req.ip,  // 默认按 IP 限流
    skipSuccessfulRequests = false,
    message = '请求过于频繁,请稍后再试',
  } = options;

  return async function rateLimitMiddleware(req, res, next) {
    const key = keyGenerator(req);
    const result = await checkRateLimit(key, { windowMs, maxRequests });

    // 设置标准限流响应头
    for (const [header, value] of Object.entries(result.headers)) {
      res.setHeader(header, value);
    }

    if (!result.allowed) {
      return res.status(429).json({
        error: message,
        retryAfter: Math.ceil(result.retryAfter / 1000)
      });
    }

    next();
  };
}

// 使用示例
import express from 'express';

const app = express();

// 全局限流:每个 IP 每分钟 100 次
app.use(createRateLimiter());

// 登录接口严格限流:每个 IP 每 15 分钟 5 次
app.post('/api/login', createRateLimiter({
  windowMs: 15 * 60 * 1000,
  maxRequests: 5,
  keyGenerator: (req) => `login:${req.ip}`,
  message: '登录尝试过多,请 15 分钟后再试'
}), loginHandler);

// 按用户限流(需要认证后的用户 ID)
app.use('/api/', createRateLimiter({
  maxRequests: 1000,
  keyGenerator: (req) => `user:${req.user?.id || req.ip}`
}));

📌 **记住:**限流的 keyGenerator 至关重要。按 IP 限流容易被绕过(代理池),按用户 ID 限流更可靠但需要认证。生产中通常组合使用:IP 限流做第一层防御,用户 ID 限流做第二层。

💡 三、Nginx 层限流与多层防御架构

3.1 Nginx 原生限流配置

在应用层之前用 Nginx 做第一道限流是最高效的——请求根本不会到达你的 Node.js 进程:

# nginx.conf —— Nginx 层限流配置
http {
    # 定义限流区域:按客户端 IP,每秒 10 个请求
    # zone=addr:10m 表示共享内存 10MB,约能存储 16 万个 IP 状态
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # 按用户自定义变量限流(如 API Key)
    limit_req_zone $http_x_api_key zone=apikey_limit:10m rate=100r/s;

    # 全局连接数限制
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        listen 80;

        # API 接口限流
        location /api/ {
            # burst=20: 允许突发 20 个请求排队
            # nodelay: 突发请求立即处理,不排队等待
            limit_req zone=api_limit burst=20 nodelay;

            # 每个 IP 最多 50 个并发连接
            limit_conn conn_limit 50;

            # 自定义 429 响应
            limit_req_status 429;
            limit_req_log_level warn;

            # 返回 Retry-After 头
            add_header Retry-After 1 always;

            proxy_pass http://backend;
        }

        # 登录接口更严格
        location /api/auth/ {
            limit_req zone=api_limit burst=3 nodelay;
            proxy_pass http://backend;
        }
    }
}

⚠️ 警告:Nginx 的 limit_req 使用的是漏桶算法(Leaky Bucket),不是令牌桶。漏桶会强制排队突发请求,可能增加延迟。设置 nodelay 可以让突发请求立即处理,但超限的请求仍然被拒绝。

3.2 多层限流架构设计

生产环境应该采用多层防御:

层级 技术 限流粒度 作用
L1 - CDN/WAF Cloudflare / AWS WAF IP + 地域 抵御 DDoS、恶意爬虫
L2 - 网关层 Nginx / Kong IP + 路径 快速拒绝超量请求,零应用开销
L3 - 应用层 Redis + 中间件 用户 ID + API Key 精细化业务限流
L4 - 数据库层 连接池配置 连接数 兜底保护,防止连接耗尽
// 多层限流策略示例:Express + 多级限流
import express from 'express';
import Redis from 'ioredis';

const app = express();
const redis = new Redis();

// L3-1: IP 级限流(宽松)
const ipLimiter = createRateLimiter({
  windowMs: 60_000,
  maxRequests: 200,
  keyGenerator: (req) => `ip:${req.ip}`
});

// L3-2: 用户级限流(中等)
const userLimiter = createRateLimiter({
  windowMs: 60_000,
  maxRequests: 100,
  keyGenerator: (req) => `user:${req.user?.id || req.ip}`
});

// L3-3: 接口级限流(严格,针对昂贵操作)
const expensiveLimiter = createRateLimiter({
  windowMs: 60_000,
  maxRequests: 10,
  keyGenerator: (req) => `expensive:${req.user?.id || req.ip}`
});

// 组合使用
app.use('/api/', ipLimiter);
app.use('/api/', authenticate, userLimiter);

// 昂贵操作(如文件导出、AI 推理)单独限流
app.post('/api/export', expensiveLimiter, exportHandler);
app.post('/api/ai/analyze', expensiveLimiter, aiAnalyzeHandler);

🔧 四、避坑指南:生产中的限流陷阱

4.1 常见错误

// ❌ 错误:单机变量做限流,多实例失效
const counters = {};
function badRateLimit(ip) {
  counters[ip] = (counters[ip] || 0) + 1;
  return counters[ip] <= 100; // 4 个实例 = 实际 400 次/秒
}

// ✅ 正确:使用 Redis 共享状态
async function goodRateLimit(ip) {
  const key = `rate:${ip}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, 60); // 首次设置过期时间
  }
  return count <= 100;
}
// ❌ 错误:限流失败不返回 Retry-After 头
app.use((req, res, next) => {
  if (isRateLimited(req)) {
    return res.status(429).json({ error: 'Too many requests' });
    // 客户端不知道该等多久,只能盲猜或直接放弃
  }
  next();
});

// ✅ 正确:返回标准限流头
app.use((req, res, next) => {
  const result = checkRateLimit(req);
  res.setHeader('X-RateLimit-Limit', result.limit);
  res.setHeader('X-RateLimit-Remaining', result.remaining);
  if (!result.allowed) {
    res.setHeader('Retry-After', result.retryAfterSeconds);
    res.setHeader('X-RateLimit-Reset', result.resetTimestamp);
    return res.status(429).json({
      error: 'Too many requests',
      retryAfter: result.retryAfterSeconds
    });
  }
  next();
});

4.2 关键注意事项

  • 限流键设计要合理:API Key > 用户 ID > IP。优先用更精确的标识
  • 不要用 Date.now() 做分布式时间:多台机器时钟不同步会导致限流不准,用 Redis 的 TIME 命令
  • ⚠️ 注意 Redis 故障降级:Redis 挂了怎么办?选择放行(宁可放过)还是拒绝(宁可错杀)?
  • 记录限流日志:限流事件是安全分析的重要数据来源
  • 不要对健康检查接口限流/health/ready 等探针接口必须排除
// Redis 故障降级策略
async function checkRateLimitWithFallback(key, options) {
  try {
    return await checkRateLimit(key, options);
  } catch (err) {
    // Redis 不可用时的降级策略
    console.error('[RateLimit] Redis unavailable:', err.message);

    // 方案 A: 降级到本地限流(保守,可能误拒)
    // return localRateLimit(key, options);

    // 方案 B: 放行(推荐,宁可放过不可错杀)
    return { allowed: true, remaining: -1, degraded: true };
  }
}

4.3 响应头标准

遵循 IETF RFC 6585 和 draft-ietf-httpapi-ratelimit-headers 标准,返回规范的限流响应头:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717776000
Retry-After: 42
Content-Type: application/json

{
  "error": "请求过于频繁",
  "retryAfter": 42,
  "limit": 100,
  "window": "60s"
}

✅ 总结

API 限流的核心是选择合适的算法并在正确的层级实施:

  1. 算法选择:令牌桶适用于 90% 的场景;安全敏感接口用滑动窗口日志
  2. 分布式方案:用 Redis Lua 脚本实现原子性限流,避免竞态条件
  3. 多层防御:Nginx 层快速拒绝 + 应用层精细化控制 + 数据库层兜底
  4. 标准响应:返回 X-RateLimit-*Retry-After 头,让客户端优雅处理

⚡ **关键结论:**不要等到被攻击了才想起限流。在项目初期就集成限流中间件,成本几乎为零,但能在关键时刻拯救整个服务。

🔧 相关工具推荐:

📚 相关文章