分布式限流算法深度解析:从令牌桶到滑动窗口的 Redis 实战

详解四种主流限流算法(令牌桶、漏桶、滑动窗口日志、滑动窗口计数器)的原理与 Redis 实现,含完整可运行代码、性能对比数据和分布式场景避坑指南。

API 设计 2026-06-02 15 分钟

你的 API 在上线第一天就被爬虫打挂了——这不是假设,而是无数团队的真实经历。限流(Rate Limiting)是高并发系统的第一道防线,但「限流」两个字背后藏着至少四种截然不同的算法,每种在突发流量、平滑性和分布式一致性上的表现天差地别。选错算法,要么用户体验极差(正常请求被误杀),要么形同虚设(流量照样打穿后端)。

本文将从原理推导到 Redis 生产级实现,手把手带你掌握限流的核心技术栈。所有代码均基于 Node.js + Redis,完整可运行。

🔐 一、四种限流算法原理与特性对比

在动手写代码之前,必须先理解每种算法的数学本质和行为特征。很多团队直接照搬「令牌桶」的代码却不理解它和「漏桶」的区别,结果在突发流量场景下系统被意外打穿。

1.1 令牌桶(Token Bucket)—— 最通用的选择

令牌桶的核心思想:系统以固定速率向桶中放入令牌,每个请求消耗一个令牌。桶有最大容量,满了就丢弃新令牌。

它的关键特性是允许突发流量——如果桶里积累了 N 个令牌,瞬间可以处理 N 个请求。这在实际业务中非常有用:用户刷新页面后连续加载 5 张图片,不应该是 5 秒加载一张。

令牌桶工作原理示意:

时间轴:  → → → → → → → → → → →
桶容量:  [⬤⬤⬤⬤⬤] (最大5个令牌)
放入速率:每秒1个令牌
请求消耗:每个请求取1个令牌

场景1 - 稳定请求:
t=0: 桶满[⬤⬤⬤⬤⬤], 请求→取出⬤, 桶[⬤⬤⬤⬤] ✅
t=1: 桶[⬤⬤⬤⬤⬤], 请求→取出⬤, 桶[⬤⬤⬤⬤] ✅

场景2 - 突发请求:
t=0: 桶满[⬤⬤⬤⬤⬤], 连续5个请求 → 全部通过 ✅
t=0.5: 桶[空], 第6个请求 → 拒绝 ❌

1.2 漏桶(Leaky Bucket)—— 最平滑的选择

漏桶的请求以任意速率进入桶,但桶以固定速率流出处理。它能将突发流量完全「削峰」为平滑输出,但代价是延迟增加——所有请求都要排队等待。

漏桶适合对输出速率有严格要求的场景,比如向第三方 API 发送请求(对方有严格 QPS 限制)。但不适合对延迟敏感的用户请求。

1.3 滑动窗口日志(Sliding Window Log)—— 最精确的选择

每个请求记录精确的时间戳,判断窗口内的请求数是否超限。精度最高,但内存消耗巨大——如果每秒 1000 请求,需要存储窗口内所有时间戳。

1.4 滑动窗口计数器(Sliding Window Counter)—— 生产环境首选

这是对固定窗口计数器的改进,用加权计算近似滑动窗口效果。内存消耗极低(只需两个计数器),精度在绝大多数场景下足够。Nginx、Redis 的限流实现大多基于此算法。

滑动窗口计数器原理:

当前时间点落在"当前窗口"内,但需要考虑上一窗口的剩余权重:

上一窗口 [████████░░] 80% 已过  → 剩余权重 20%
当前窗口 [████░░░░░░] 40% 已过  → 100% 计入

有效请求数 ≈ 上一窗口计数 × 剩余权重 + 当前窗口计数
           = 100 × 0.2 + 30
           = 50

📊 四种算法特性对比

特性 令牌桶 漏桶 滑动窗口日志 滑动窗口计数器
突发流量处理 ✅ 允许突发 ❌ 完全削峰 ✅ 精确控制 ✅ 近似控制
内存消耗 低(2个变量) 低(2个变量) 高(存时间戳) 低(2个计数器)
实现复杂度 ⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐
分布式实现 简单 较难 简单 简单
精度 粒度级 粒度级 精确 近似
适用场景 API 网关通用 第三方对接 小流量高精度 生产环境首选

关键结论: 90% 的生产场景选择「滑动窗口计数器」或「令牌桶」就够了。滑动窗口日志只在极低 QPS 且需要精确控制的场景(如短信发送限制)才值得使用。

🚀 二、Redis 分布式限流实现

单机限流用内存计数即可,但分布式环境下多个实例必须共享计数状态。Redis 的 INCR + EXPIRE 原子操作天然适合这个场景。

2.1 滑动窗口计数器(推荐方案)

这是我在生产环境中验证过最稳定的方案,用 Redis Sorted Set 实现精确的滑动窗口:

// rate-limiter-sliding-window.js
// 滑动窗口计数器 - 基于 Redis Sorted Set 实现

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

/**
 * 滑动窗口限流器
 * @param {string} key - 限流键(如 user:123:api)
 * @param {number} limit - 窗口内最大请求数
 * @param {number} windowMs - 窗口大小(毫秒)
 * @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
 */
async function slidingWindowRateLimit(key, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;

  // Lua 脚本保证原子性:移除过期记录 + 添加新记录 + 计数 + 设置过期
  const luaScript = `
    local key = KEYS[1]
    local window_start = tonumber(ARGV[1])
    local now = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    local window_ms = tonumber(ARGV[4])

    -- 移除窗口外的过期记录
    redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

    -- 获取当前窗口内的请求数
    local current_count = redis.call('ZCARD', key)

    if current_count < limit then
      -- 未超限:添加当前请求
      redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
      redis.call('PEXPIRE', key, window_ms)
      return {1, limit - current_count - 1, 0}
    else
      -- 已超限:计算需要等待的时间
      local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
      local retry_after = 0
      if #oldest > 0 then
        retry_after = tonumber(oldest[2]) + window_ms - now
      end
      return {0, 0, retry_after}
    end
  `;

  const result = await redis.eval(
    luaScript, 1, key,
    windowStart.toString(), now.toString(),
    limit.toString(), windowMs.toString()
  );

  return {
    allowed: result[0] === 1,
    remaining: result[1],
    retryAfter: Math.max(0, result[2])
  };
}

// 使用示例:每个用户每分钟最多 60 次请求
async function handleRequest(userId) {
  const result = await slidingWindowRateLimit(
    `ratelimit:user:${userId}`,
    60,      // 60 次
    60000    // 60 秒窗口
  );

  if (!result.allowed) {
    return {
      status: 429,
      headers: {
        'X-RateLimit-Remaining': '0',
        'Retry-After': Math.ceil(result.retryAfter / 1000).toString()
      },
      body: { error: '请求过于频繁,请稍后再试' }
    };
  }

  return { status: 200, headers: { 'X-RateLimit-Remaining': result.remaining.toString() } };
}

📌 记住: 必须用 Lua 脚本保证原子性。如果用多条 Redis 命令拼接,在高并发下会出现竞态条件,实际通过的请求数可能超过限制。

2.2 令牌桶实现

令牌桶适合需要允许突发流量的 API 网关场景。核心难点在于「惰性填充」——不在定时器里放令牌,而是在每次请求时根据时间差计算应放多少:

// rate-limiter-token-bucket.js
// 令牌桶限流器 - Redis 实现惰性填充

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

/**
 * 令牌桶限流器
 * @param {string} key - 限流键
 * @param {number} capacity - 桶容量(最大令牌数)
 * @param {number} refillRate - 每秒补充的令牌数
 * @param {number} tokens - 本次请求需要的令牌数(默认1)
 */
async function tokenBucketRateLimit(key, capacity, refillRate, tokens = 1) {
  const luaScript = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local requested = tonumber(ARGV[3])
    local now = tonumber(ARGV[4])

    -- 获取上次的令牌数和时间
    local data = redis.call('HMGET', key, 'tokens', 'last_refill')
    local current_tokens = tonumber(data[1]) or capacity
    local last_refill = tonumber(data[2]) or now

    -- 计算需要补充的令牌(惰性填充)
    local elapsed = (now - last_refill) / 1000  -- 转换为秒
    local new_tokens = math.min(capacity, current_tokens + elapsed * refill_rate)

    -- 判断是否有足够的令牌
    if new_tokens >= requested then
      new_tokens = new_tokens - requested
      redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
      redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
      return {1, math.floor(new_tokens), 0}
    else
      -- 计算需要等待多久才有足够令牌
      local deficit = requested - new_tokens
      local wait_time = math.ceil(deficit / refill_rate * 1000)
      redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
      return {0, math.floor(new_tokens), wait_time}
    end
  `;

  const now = Date.now();
  const result = await redis.eval(
    luaScript, 1, key,
    capacity.toString(), refillRate.toString(),
    tokens.toString(), now.toString()
  );

  return {
    allowed: result[0] === 1,
    remaining: result[1],
    retryAfter: result[2]  // 毫秒
  };
}

// 使用示例:API 网关限流,每秒 100 请求,允许突发 200
async function apiGatewayLimit(clientIp) {
  const result = await tokenBucketRateLimit(
    `gateway:ip:${clientIp}`,
    200,   // 桶容量:允许突发 200 请求
    100,   // 每秒补充 100 个令牌
    1      // 每次消耗 1 个令牌
  );

  console.log(`IP ${clientIp}: allowed=${result.allowed}, remaining=${result.remaining}`);
  return result;
}

2.3 多级限流策略

生产环境通常需要多级限流——先按 IP 限制防爬虫,再按用户限制防滥用,最后按接口限制保护后端资源:

// rate-limiter-multi-level.js
// 多级限流策略:IP → 用户 → 接口 三层防护

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

// 滑动窗口限流核心函数(复用之前的 Lua 脚本)
async function checkLimit(key, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;
  const luaScript = `
    local key = KEYS[1]
    local window_start = tonumber(ARGV[1])
    local now = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    local window_ms = tonumber(ARGV[4])
    redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
    local count = redis.call('ZCARD', key)
    if count < limit then
      redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
      redis.call('PEXPIRE', key, window_ms)
      return {1, limit - count - 1}
    end
    return {0, 0}
  `;
  const result = await redis.eval(luaScript, 1, key, windowStart.toString(), now.toString(), limit.toString(), windowMs.toString());
  return { allowed: result[0] === 1, remaining: result[1] };
}

/**
 * 多级限流中间件(Express/Koa 通用)
 *
 * 限流策略:
 * - IP 级:每分钟 300 次(防爬虫)
 * - 用户级:每分钟 60 次(防滥用)
 * - 接口级:每秒 1000 次(保护后端)
 */
function multiLevelRateLimit(options = {}) {
  const {
    ipLimit = { limit: 300, window: 60000 },      // IP: 300次/分钟
    userLimit = { limit: 60, window: 60000 },      // 用户: 60次/分钟
    endpointLimit = { limit: 1000, window: 1000 }, // 接口: 1000次/秒
  } = options;

  return async function (req, res, next) {
    const ip = req.ip || req.connection.remoteAddress;
    const userId = req.user?.id || 'anonymous';
    const endpoint = `${req.method}:${req.path}`;

    // 第一层:IP 限流
    const ipResult = await checkLimit(`rl:ip:${ip}`, ipLimit.limit, ipLimit.window);
    if (!ipResult.allowed) {
      res.set('X-RateLimit-Level', 'ip');
      return res.status(429).json({ error: 'IP 请求过于频繁' });
    }

    // 第二层:用户限流
    const userResult = await checkLimit(`rl:user:${userId}`, userLimit.limit, userLimit.window);
    if (!userResult.allowed) {
      res.set('X-RateLimit-Level', 'user');
      return res.status(429).json({ error: '用户请求过于频繁' });
    }

    // 第三层:接口限流
    const epResult = await checkLimit(`rl:endpoint:${endpoint}`, endpointLimit.limit, endpointLimit.window);
    if (!epResult.allowed) {
      res.set('X-RateLimit-Level', 'endpoint');
      return res.status(429).json({ error: '接口繁忙,请稍后重试' });
    }

    // 设置限流头
    res.set({
      'X-RateLimit-Remaining-IP': ipResult.remaining.toString(),
      'X-RateLimit-Remaining-User': userResult.remaining.toString(),
      'X-RateLimit-Remaining-Endpoint': epResult.remaining.toString(),
    });

    next();
  };
}

// Express 使用示例
const express = require('express');
const app = express();

// 全局应用多级限流
app.use(multiLevelRateLimit());

// 或者只对特定路由限流
app.post('/api/send-sms', multiLevelRateLimit({
  ipLimit: { limit: 10, window: 60000 },      // SMS 接口更严格
  userLimit: { limit: 5, window: 60000 },
}), (req, res) => {
  res.json({ success: true });
});

💡 提示: 多级限流的顺序很重要——先检查成本最低的(IP 限流),再检查需要查询用户信息的(用户限流),最后检查业务级别的(接口限流)。这样可以尽早拒绝恶意请求,减少后端压力。

⚠️ 三、分布式限流的「坑」与最佳实践

3.1 Redis 集群下的限流一致性问题

在 Redis Cluster 模式下,同一个限流 key 可能在不同的分片上。虽然 Redis 会根据 key 的 hash 自动路由,但有几个容易踩的坑:

坑 1:Lua 脚本的 key 必须在同一 slot

Redis Cluster 要求 Lua 脚本中所有 key 必须属于同一个 slot。如果你的限流 key 包含用户 ID,正常使用没问题,但如果你想在一个脚本里同时操作多个 key(比如同时更新 IP 限流和用户限流),就需要用 Hash Tag:

// ❌ 错误:两个 key 可能在不同 slot
const key1 = `rl:ip:${ip}`;
const key2 = `rl:user:${userId}`;

// ✅ 正确:用 Hash Tag 确保在同一 slot(但通常不需要,因为是独立的限流检查)
const key1 = `rl:{${ip}}:ip`;
const key2 = `rl:{${ip}}:user`;

// ✅ 更好的做法:每层限流用独立的 Lua 脚本,不要混在一起

坑 2:Redis 主从切换导致计数丢失

Redis 异步复制意味着主节点写入成功后,如果主节点宕机,从节点提升为主节点时可能丢失最近的写入。在限流场景下,这意味着短暂的限流失效——可能有几十到几百个请求在主从切换期间漏过。

⚠️ 警告: 如果你的限流是安全相关的(如防暴力破解密码),不要只依赖 Redis。应该在应用层增加一层内存计数器作为兜底,或者使用 Redis 的 WAIT 命令强制同步复制(会增加延迟)。

3.2 限流响应的 HTTP 规范

很多团队的限流实现只返回一个 429 状态码,但缺少关键的响应头信息。根据 RFC 6585 和 draft-ietf-httpapi-ratelimit-headers,正确的做法是:

// rate-limit-headers.js
// 符合 RFC 规范的限流响应头

function setRateLimitHeaders(res, result, limit, windowSeconds) {
  // 标准限流头(draft-ietf-httpapi-ratelimit-headers-07)
  res.set({
    'RateLimit-Limit': limit.toString(),
    'RateLimit-Remaining': result.remaining.toString(),
    'RateLimit-Reset': Math.ceil((Date.now() + windowSeconds * 1000) / 1000).toString(),
    'Retry-After': result.allowed ? '0' : Math.ceil(windowSeconds).toString(),
  });
}

// 中间件中的使用
async function rateLimitMiddleware(req, res, next) {
  const result = await slidingWindowRateLimit(`rl:${req.ip}`, 100, 60);

  // 设置标准限流头(无论是否超限都要设置)
  setRateLimitHeaders(res, result, 100, 60);

  if (!result.allowed) {
    return res.status(429).json({
      error: 'Too Many Requests',
      message: '请求频率超过限制,请稍后重试',
      retryAfter: Math.ceil(result.retryAfter / 1000)
    });
  }

  next();
}

📌 记住: 即使请求通过了也要返回 RateLimit-Remaining 头,这样前端可以在接近限制时主动降级(如减少轮询频率),而不是等到被拒绝才处理。

3.3 不同业务场景的限流策略

限流不是一刀切的——不同业务场景需要完全不同的参数配置:

场景 推荐算法 窗口 限制 原因
公开 API 令牌桶 1秒 100 QPS 允许突发,保持响应性
登录接口 滑动窗口 15分钟 5次 防暴力破解,严格限制
短信验证码 滑动窗口 60秒 1次/用户 防刷短信,成本控制
文件上传 令牌桶 1小时 10次 防滥用存储,允许偶尔批量
搜索接口 滑动窗口 1秒 20 QPS 保护数据库,防止慢查询
第三方 Webhook 漏桶 1秒 50 QPS 平滑输出,保护下游

✅ 总结与工具推荐

限流看似简单,但在分布式环境下要做到精确、高性能、不误杀,需要对算法原理和 Redis 特性有深入理解。以下是关键建议:

  1. 默认选择滑动窗口计数器 —— 内存效率高、精度足够、实现简单,适合 90% 的场景
  2. 必须使用 Lua 脚本 —— 保证原子性是分布式限流的生命线,绝不能用多条命令拼接
  3. 多级限流分层防御 —— IP → 用户 → 接口,由粗到细,逐层过滤
  4. 返回标准限流头 —— 让客户端能感知剩余配额,主动做好降级准备
  5. 兜底策略不能少 —— Redis 宕机时,应用层内存计数器或令牌桶是最后的防线

关键结论: 限流不是上线前临时加的功能,而是架构设计时就必须考虑的基础设施。在项目初期就引入限流中间件,远比线上被打挂后再补要轻松得多。

相关工具推荐:

  • 🔧 Redis — 分布式限流的核心存储,建议使用 Redis 7.0+ 的 Function 特性替代 Lua 脚本
  • 🔧 rate-limiter-flexible — Node.js 最成熟的限流库,支持多种后端(Redis、MongoDB、内存)
  • 🔧 express-rate-limit — Express 生态最流行的限流中间件,简单场景开箱即用
  • 🔧 Sentinel — 阿里开源的分布式流量治理组件,Java 生态首选
  • 🔧 Cloudflare Rate Limiting — CDN 层限流,无需改代码即可防护

📚 相关文章