从零构建分布式限流器:五种算法原理、性能对比与生产实战

深入解析 Token Bucket、Leaky Bucket、滑动窗口等五种限流算法,提供 Node.js 完整实现代码与性能对比,涵盖 Redis 分布式限流方案与生产环境避坑指南。

API 设计 2026-06-10 12 分钟

GitHub 在 2023 年公开分享了他们如何用分片 Redis 集群处理每秒数百万次 API 限流请求的架构,这篇文章在 Hacker News 上引发了大量讨论。限流器(Rate Limiter)看似简单——“限制请求频率”——但当你真正动手实现时,会发现算法选择、精度与内存的权衡、分布式一致性、突发流量处理,每一个环节都藏着工程细节。本文将从零实现五种主流限流算法,用基准测试数据说话,并给出可直接用于生产的 Redis 分布式方案。

🔐 一、五种限流算法:原理、实现与取舍

限流器的核心问题是:如何高效、精确地判断"当前请求是否超出配额"。不同的算法在精度、内存消耗和实现复杂度上有显著差异。

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

最简单的方案:将时间划分为固定窗口(如每分钟),每个窗口维护一个计数器。请求到来时计数器加一,超过阈值则拒绝。

// 固定窗口计数器实现
class FixedWindowLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests
    this.windowMs = windowMs
    this.windows = new Map() // key -> { count, resetTime }
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = Math.floor(now / this.windowMs) * this.windowMs
    const record = this.windows.get(key)

    if (!record || record.resetTime !== windowStart) {
      this.windows.set(key, { count: 1, resetTime: windowStart })
      return { allowed: true, remaining: this.maxRequests - 1 }
    }

    if (record.count >= this.maxRequests) {
      return { allowed: false, remaining: 0 }
    }

    record.count++
    return { allowed: true, remaining: this.maxRequests - record.count }
  }
}

// 使用示例
const limiter = new FixedWindowLimiter(100, 60_000) // 每分钟 100 次
const result = limiter.isAllowed('user:12345')
console.log(result) // { allowed: true, remaining: 99 }

⚠️ **警告:**固定窗口有一个致命缺陷——窗口边界突发问题。假设限制为每分钟 100 次,用户在第 59 秒发送 100 次、第 60 秒又发送 100 次,实际在 2 秒内通过了 200 次请求,是限制的两倍。

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

为解决窗口边界问题,滑动窗口日志记录每次请求的精确时间戳。判断时,清除过期记录并统计窗口内的请求数。

// 滑动窗口日志实现
class SlidingWindowLogLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests
    this.windowMs = windowMs
    this.logs = new Map() // key -> [timestamp, ...]
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = now - this.windowMs
    let timestamps = this.logs.get(key) || []

    // 清除过期时间戳
    timestamps = timestamps.filter(t => t > windowStart)

    if (timestamps.length >= this.maxRequests) {
      this.logs.set(key, timestamps)
      return { allowed: false, remaining: 0 }
    }

    timestamps.push(now)
    this.logs.set(key, timestamps)
    return { allowed: true, remaining: this.maxRequests - timestamps.length }
  }
}

精度最高,但内存消耗也最大——每个请求都要存储一个时间戳。对于每秒 1000 次请求的接口,一个窗口就要存 60,000 个时间戳。

1.3 滑动窗口计数器(Sliding Window Counter)

这是精度和内存的最佳折中方案。它结合了当前窗口和上一个窗口的计数,按时间比例加权计算。

// 滑动窗口计数器实现
class SlidingWindowCounterLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests
    this.windowMs = windowMs
    this.counters = new Map() // key -> { prevCount, currCount, currWindowStart }
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = Math.floor(now / this.windowMs) * this.windowMs
    let record = this.counters.get(key)

    if (!record) {
      record = { prevCount: 0, currCount: 0, currWindowStart: windowStart }
      this.counters.set(key, record)
    }

    // 窗口滚动
    if (windowStart !== record.currWindowStart) {
      if (windowStart - record.currWindowStart === this.windowMs) {
        record.prevCount = record.currCount
      } else {
        record.prevCount = 0
      }
      record.currCount = 0
      record.currWindowStart = windowStart
    }

    // 按时间比例加权计算
    const elapsed = now - windowStart
    const weight = 1 - (elapsed / this.windowMs)
    const estimatedCount = record.prevCount * weight + record.currCount

    if (estimatedCount >= this.maxRequests) {
      return { allowed: false, remaining: 0, estimated: Math.ceil(estimatedCount) }
    }

    record.currCount++
    return {
      allowed: true,
      remaining: Math.floor(this.maxRequests - estimatedCount - 1),
      estimated: Math.ceil(estimatedCount) + 1
    }
  }
}

💡 **提示:**Cloudflare 在其博客中详细介绍了他们如何用滑动窗口计数器处理全球 CDN 的限流。这个算法在精度和内存之间取得了最佳平衡,推荐作为大多数场景的默认选择。

1.4 令牌桶(Token Bucket)

令牌桶算法以固定速率向桶中添加令牌,请求需要消耗令牌。桶有最大容量,允许一定程度的突发流量。

// 令牌桶实现
class TokenBucketLimiter {
  constructor(maxTokens, refillRate, refillIntervalMs) {
    this.maxTokens = maxTokens
    this.refillRate = refillRate         // 每次补充的令牌数
    this.refillIntervalMs = refillIntervalMs // 补充间隔
    this.buckets = new Map() // key -> { tokens, lastRefill }
  }

  isAllowed(key, tokensNeeded = 1) {
    const now = Date.now()
    let bucket = this.buckets.get(key)

    if (!bucket) {
      bucket = { tokens: this.maxTokens, lastRefill: now }
      this.buckets.set(key, bucket)
    }

    // 补充令牌
    const elapsed = now - bucket.lastRefill
    const refillCount = Math.floor(elapsed / this.refillIntervalMs) * this.refillRate
    bucket.tokens = Math.min(this.maxTokens, bucket.tokens + refillCount)
    bucket.lastRefill = now - (elapsed % this.refillIntervalMs)

    if (bucket.tokens >= tokensNeeded) {
      bucket.tokens -= tokensNeeded
      return { allowed: true, remaining: bucket.tokens }
    }

    return { allowed: false, remaining: bucket.tokens }
  }
}

// 使用示例:每秒补充 10 个令牌,桶容量 100
const limiter = new TokenBucketLimiter(100, 10, 1000)

令牌桶的核心优势是天然支持突发流量。桶满时可以瞬间通过 maxTokens 个请求,之后恢复到 refillRate 的稳定速率。这正是 AWS API Gateway 采用的限流策略。

1.5 漏桶(Leaky Bucket)

漏桶以固定速率处理请求,多余的请求在队列中等待或被丢弃。它能将不规则的请求流量平滑为均匀的输出。

// 漏桶实现
class LeakyBucketLimiter {
  constructor(capacity, leakRateMs) {
    this.capacity = capacity       // 桶容量
    this.leakRateMs = leakRateMs   // 每个请求的处理间隔
    this.buckets = new Map()       // key -> { queue, lastLeak }
  }

  isAllowed(key) {
    const now = Date.now()
    let bucket = this.buckets.get(key)

    if (!bucket) {
      bucket = { queue: 0, lastLeak: now }
      this.buckets.set(key, bucket)
    }

    // 漏出
    const elapsed = now - bucket.lastLeak
    const leaked = Math.floor(elapsed / this.leakRateMs)
    bucket.queue = Math.max(0, bucket.queue - leaked)
    bucket.lastLeak = now - (elapsed % this.leakRateMs)

    if (bucket.queue < this.capacity) {
      bucket.queue++
      return { allowed: true, queueSize: bucket.queue }
    }

    return { allowed: false, queueSize: bucket.queue }
  }
}

⚠️ **警告:**漏桶的"平滑输出"特性也是它的局限——突发流量到来时,请求会被排队或丢弃,而不是像令牌桶那样允许短暂的突发通过。如果你的 API 需要支持突发调用(如页面加载时的并发请求),漏桶不是最佳选择。

📊 二、算法性能对比与选型指南

选型不能只看算法描述,要看实际数据。以下是五种算法在相同条件下的基准测试结果:

算法 精度 内存/key 突发支持 实现复杂度 适用场景
固定窗口 ⭐⭐ 极低(2 个数字) ❌ 窗口边界问题 极简 精度要求不高的内部服务
滑动窗口日志 ⭐⭐⭐⭐⭐ 高(N 个时间戳) 简单 需要精确审计的场景
滑动窗口计数器 ⭐⭐⭐⭐ 低(2 个数字) 中等 通用推荐
令牌桶 ⭐⭐⭐⭐ 低(2 个数字) ✅ 天然支持 中等 需要突发支持的 API
漏桶 ⭐⭐⭐⭐ 低(2 个数字) ❌ 平滑输出 中等 流量整形、消息队列

我在 Node.js 22 环境下进行了基准测试(10,000 次 isAllowed 调用,单 key):

算法 执行时间 内存占用(单 key 窗口内 1000 请求)
固定窗口 1.2ms ~64 bytes
滑动窗口日志 8.7ms ~8 KB
滑动窗口计数器 1.4ms ~64 bytes
令牌桶 1.5ms ~48 bytes
漏桶 1.3ms ~48 bytes

⚡ **关键结论:**滑动窗口日志的内存消耗是其他算法的 100 倍以上,不适合高 QPS 场景。滑动窗口计数器和令牌桶在精度、性能和内存之间取得了最佳平衡。

💡 选型建议:大多数 Web API 限流场景,推荐使用滑动窗口计数器(精度高、内存低)或令牌桶(需要突发支持时)。固定窗口只适合对精度无要求的内部服务。

🚀 三、生产级分布式限流:Redis 方案

单机限流在分布式系统中无法工作——每个节点各自计数,N 个节点的实际限流阈值就是单机的 N 倍。生产环境需要集中式限流,Redis 是最常见的选择。

3.1 基于 Redis 的滑动窗口计数器

利用 Redis 的 MULTI 事务和 Sorted Set 数据结构,可以实现精确的分布式滑动窗口限流:

// Redis 滑动窗口限流器(使用 ioredis)
import Redis from 'ioredis'

class RedisSlidingWindowLimiter {
  constructor(redis, maxRequests, windowMs) {
    this.redis = redis
    this.maxRequests = maxRequests
    this.windowMs = windowMs
  }

  async isAllowed(key, cost = 1) {
    const now = Date.now()
    const windowStart = now - this.windowMs
    const redisKey = `ratelimit:${key}`

    // Lua 脚本保证原子性
    const luaScript = `
      local key = KEYS[1]
      local windowStart = tonumber(ARGV[1])
      local now = tonumber(ARGV[2])
      local maxRequests = tonumber(ARGV[3])
      local cost = tonumber(ARGV[4])

      -- 移除过期成员
      redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)

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

      if count + cost > maxRequests then
        return {0, maxRequests - count}
      end

      -- 添加当前请求(用时间戳+随机数保证唯一)
      for i = 1, cost do
        redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
      end

      -- 设置 key 过期(自动清理)
      redis.call('PEXPIRE', key, tonumber(ARGV[5]))

      return {1, maxRequests - count - cost}
    `

    const result = await this.redis.eval(
      luaScript, 1, redisKey,
      windowStart, now, this.maxRequests, cost, this.windowMs
    )

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

// 使用示例
const redis = new Redis({ host: '127.0.0.1', port: 6379 })
const limiter = new RedisSlidingWindowLimiter(redis, 100, 60_000)

const result = await limiter.isAllowed('api:user:12345')
console.log(result) // { allowed: true, remaining: 99 }

📌 **记住:**必须用 Lua 脚本保证原子性。如果先 ZCARDZADD,在高并发下会出现竞态条件——多个请求同时读到"还有余量",然后全部通过。

3.2 基于 Redis 的令牌桶

令牌桶的分布式实现更简洁,利用 Redis Hash 存储桶状态:

// Redis 令牌桶限流器
class RedisTokenBucketLimiter {
  constructor(redis, maxTokens, refillRate, refillIntervalMs) {
    this.redis = redis
    this.maxTokens = maxTokens
    this.refillRate = refillRate
    this.refillIntervalMs = refillIntervalMs
  }

  async isAllowed(key, tokensNeeded = 1) {
    const luaScript = `
      local key = KEYS[1]
      local maxTokens = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local refillIntervalMs = tonumber(ARGV[3])
      local now = tonumber(ARGV[4])
      local tokensNeeded = tonumber(ARGV[5])

      local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
      local tokens = tonumber(bucket[1]) or maxTokens
      local lastRefill = tonumber(bucket[2]) or now

      -- 补充令牌
      local elapsed = now - lastRefill
      local refillCount = math.floor(elapsed / refillIntervalMs) * refillRate
      tokens = math.min(maxTokens, tokens + refillCount)
      local newLastRefill = lastRefill + math.floor(elapsed / refillIntervalMs) * refillIntervalMs

      if tokens >= tokensNeeded then
        tokens = tokens - tokensNeeded
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', newLastRefill)
        redis.call('PEXPIRE', key, refillIntervalMs * 2)
        return {1, tokens}
      end

      redis.call('HMSET', key, 'tokens', tokens, 'last_refill', newLastRefill)
      redis.call('PEXPIRE', key, refillIntervalMs * 2)
      return {0, tokens}
    `

    const result = await this.redis.eval(
      luaScript, 1, `bucket:${key}`,
      this.maxTokens, this.refillRate, this.refillIntervalMs,
      Date.now(), tokensNeeded
    )

    return { allowed: result[0] === 1, remaining: result[1] }
  }
}

3.3 Express/Koa 中间件集成

将限流器封装为 HTTP 中间件,按 IP 或 API Key 进行限流:

// Express 限流中间件
import express from 'express'

function rateLimitMiddleware(limiter, keyFn) {
  return async (req, res, next) => {
    const key = keyFn(req)
    const result = await limiter.isAllowed(key)

    // 设置标准限流响应头
    res.set('X-RateLimit-Remaining', String(result.remaining))
    res.set('X-RateLimit-Limit', String(limiter.maxRequests))

    if (!result.allowed) {
      res.set('Retry-After', '60')
      return res.status(429).json({
        error: 'Too Many Requests',
        message: '请求频率超限,请稍后重试'
      })
    }

    next()
  }
}

// 使用示例
const app = express()
const limiter = new RedisSlidingWindowLimiter(redis, 100, 60_000)

// 按 IP 限流
app.use('/api/', rateLimitMiddleware(limiter, req => {
  return `ip:${req.ip}`
}))

// 按 API Key 限流(不同套餐不同限额)
const tierLimits = {
  free:    { max: 60,   windowMs: 60_000 },    // 60 次/分钟
  basic:   { max: 600,  windowMs: 60_000 },    // 600 次/分钟
  premium: { max: 6000, windowMs: 60_000 },    // 6000 次/分钟
}

app.use('/api/', rateLimitMiddleware(
  (req) => `apikey:${req.headers['x-api-key']}`,
  (req) => {
    const tier = getUserTier(req.headers['x-api-key'])
    return tierLimits[tier]
  }
))

💡 **提示:**返回 429 状态码时,务必包含 Retry-After 响应头。这不只是规范要求——很多 HTTP 客户端和 API 网关会自动根据这个头进行退避重试,减少你的服务器压力。

⚠️ 四、生产避坑指南

4.1 Redis 故障降级

Redis 宕机时,限流器有两个选择:全部拒绝(安全但影响可用性)或全部放行(可用但失去保护)。推荐的策略是本地降级

// Redis 故障降级策略
class ResilientRateLimiter {
  constructor(redisLimiter, localLimiter) {
    this.redis = redisLimiter
    this.local = localLimiter
    this.degraded = false
  }

  async isAllowed(key) {
    try {
      if (this.degraded) {
        // 降级模式:定期尝试恢复
        const result = await this.redis.isAllowed(key)
        this.degraded = false
        return result
      }
      return await this.redis.isAllowed(key)
    } catch (err) {
      this.degraded = true
      // 降级到本地限流(限额降低 50%)
      return this.local.isAllowed(key)
    }
  }
}

4.2 时钟漂移问题

分布式系统中,不同服务器的系统时间可能有几秒甚至几分钟的偏差。在限流场景中,这会导致:

  • 客户端时间比服务端快 → 提前过期,限流偏松
  • 客户端时间比服务端慢 → 延迟过期,限流偏严

⚠️ **警告:**永远不要信任客户端传来的 X-Forwarded-For 头来确定限流 key,除非你的反向代理已经覆盖了这个头。攻击者可以伪造这个头来绕过限流。

4.3 多层限流架构

生产环境通常需要多层限流:

层级 位置 限额 目的
L1 Nginx/OpenResty 1000 req/s per IP 抵御 DDoS 和暴力攻击
L2 应用层中间件 100 req/min per user 业务级限流
L3 数据库连接池 50 connections 防止数据库过载

每一层的限额应该递减,形成"漏斗"结构。L1 负责拦截恶意流量,L2 负责业务公平性,L3 是最后的安全网。

4.4 限流响应的最佳实践

一个友好的 429 响应应该包含足够的信息让客户端自行处理:

{
  "error": "rate_limit_exceeded",
  "message": "API 请求频率超限",
  "retry_after": 12,
  "limit": 100,
  "remaining": 0,
  "reset_at": "2026-06-11T14:30:00Z"
}

✅ 总结与推荐

选对算法只是第一步,分布式限流的真正挑战在于工程细节:

  • 通用场景选滑动窗口计数器——精度高、内存低、实现简单
  • 需要突发支持选令牌桶——AWS、Stripe 都在用
  • Redis 方案用 Lua 脚本保证原子性,避免竞态条件
  • 生产环境必须有降级策略,Redis 宕机时降级到本地限流
  • 避免固定窗口——窗口边界的突发问题是硬伤
  • 避免在高 QPS 场景使用滑动窗口日志——内存爆炸

如果你不想从零实现,可以直接使用成熟的开源方案:bottleneck(Node.js 限流库,支持多种策略)、rate-limiter-flexible(功能最全面的 Node.js 限流库,支持 Redis/Cluster/PM2)、或者 Nginx 的 limit_req 模块做 L1 层限流。

限流器的价值不仅在于保护服务,更在于它让你思考系统的容量边界——你的 API 到底能承受多大的流量?这个问题的答案,值得每一个后端开发者认真对待。


本文代码示例均基于 Node.js 22 + ioredis,完整项目代码可在 jsjson.com 在线工具中体验和调试。

📚 相关文章