正在加载,请稍候…

Node.js API 限流:令牌桶、滑动窗口与 Redis 策略

实现健壮的 API 限流——固定窗口、滑动窗口、令牌桶算法、基于 Redis 的分布式限流以及 IP 与用户级策略。

Node.js API 限流:令牌桶、滑动窗口与 Redis 策略

限流算法

Node.js API 限流:令牌桶、滑动窗口与 Redis 策略 示意图

固定窗口

简单,但允许在窗口边界突发。

滑动窗口

更平滑的分布,在滚动窗口内计数请求。

Node.js API 限流:令牌桶、滑动窗口与 Redis 策略 示意图

令牌桶

允许受控突发,同时强制执行平均速率。

express-rate-limit(基础)

npm install express-rate-limit
import rateLimit from 'express-rate-limit'

// 全局限流
app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 分钟
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: '请求过多,请稍后再试。' },
}))

// 认证端点的严格限流
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // 仅计数失败尝试
})
app.use('/auth/login', authLimiter)

Node.js API 限流:令牌桶、滑动窗口与 Redis 策略 示意图

基于 Redis 的滑动窗口

import Redis from 'ioredis'

const redis = new Redis()

async function slidingWindowLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
  const now = Date.now()
  const windowStart = now - windowMs
  
  const pipeline = redis.pipeline()
  pipeline.zremrangebyscore(key, '-inf', windowStart)   // 移除旧条目
  pipeline.zadd(key, now, `${now}-${Math.random()}`)    // 添加当前请求
  pipeline.zcard(key)                                    // 窗口内计数
  pipeline.pexpire(key, windowMs)                        // 设置过期
  
  const results = await pipeline.exec()
  const count = results?.[2]?.[1] as number ?? 0
  
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: new Date(now + windowMs),
  }
}

// 中间件
app.use(async (req, res, next) => {
  const identifier = req.user?.id ?? req.ip
  const { allowed, remaining, resetAt } = await slidingWindowLimit(
    `rate:${identifier}`,
    100,
    60 * 1000
  )
  
  res.set({
    'X-RateLimit-Limit': '100',
    'X-RateLimit-Remaining': remaining.toString(),
    'X-RateLimit-Reset': resetAt.toISOString(),
  })
  
  if (!allowed) {
    return res.status(429).json({ error: '超出速率限制' })
  }
  next()
})

令牌桶算法

class TokenBucket {
  private tokens: number
  private lastRefill: number
  
  constructor(
    private capacity: number,
    private refillRate: number,  // 每秒令牌数
  ) {
    this.tokens = capacity
    this.lastRefill = Date.now()
  }
  
  async consume(tokens = 1): Promise<boolean> {
    this.refill()
    if (this.tokens < tokens) return false
    this.tokens -= tokens
    return true
  }
  
  private refill() {
    const now = Date.now()
    const elapsed = (now - this.lastRefill) / 1000
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate)
    this.lastRefill = now
  }
}

// 使用 Redis Lua 脚本的分布式令牌桶
const consumeToken = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

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

local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens >= requested then
  tokens = tokens - requested
  redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
  redis.call('pexpire', key, 60000)
  return 1
end
return 0
`

async function consumeTokenRedis(userId: string): Promise<boolean> {
  const result = await redis.eval(
    consumeToken, 1,
    `bucket:${userId}`,
    100,    // 容量
    10,     // 填充速率(令牌/秒)
    Date.now(),
    1       // 消耗 1 个令牌
  )
  return result === 1
}

分层限流

const tiers = {
  free:    { requests: 100,   windowMs: 60 * 60 * 1000 },
  pro:     { requests: 1000,  windowMs: 60 * 60 * 1000 },
  enterprise: { requests: 10000, windowMs: 60 * 60 * 1000 },
}

app.use(async (req, res, next) => {
  const tier = req.user?.subscription ?? 'free'
  const { requests, windowMs } = tiers[tier]
  const { allowed } = await slidingWindowLimit(`rate:${req.user?.id}`, requests, windowMs)
  if (!allowed) return res.status(429).json({ error: '超出速率限制' })
  next()
})