18.08-限流算法

要点

  • 限流有四种基本算法:固定窗口、滑动窗口、漏桶、令牌桶
  • 固定窗口最简单但有边界突发问题——窗口切换瞬间可以收到 2 倍请求
  • 滑动窗口解决了边界突发——但需要更多存储
  • 漏桶平滑输出速率——适合处理下游系统的速率限制
  • 令牌桶允许突发——适合 API 限流,用户体验更好
  • Worker 环境没有 Redis,限流计数用 KV(最终一致)或 Upstash Redis(原子操作)——高并发场景必须 Redis

1. 为什么需要限流

API 限流解决三个问题:

  1. 保护后端:防止突发流量压垮服务——数据库、第三方 API、LLM 都有处理能力上限
  2. 公平分配:多用户共享资源时,防止某个用户独占——按用户配额分配
  3. 成本控制:LLM 调用有 token 费用,限流防止成本失控

Worker 环境的限流有个特殊挑战——没有 Redis 的原子操作。KV 的 get + put 不是原子的,高并发下计数器会失准。后面会讲怎么解决。

2. 固定窗口计数器

原理

把时间切成固定窗口(比如每分钟),每个窗口内允许 N 次请求。窗口结束时计数器清零。

时间轴:|---窗口 1---|---窗口 2---|---窗口 3---|
计数:  |   7/10    |   3/10    |   9/10    |

实现

// 用 Redis(原子操作)
async function fixedWindowRedis(
  redis: Redis,
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
  const window = Math.floor(Date.now() / 1000 / windowSeconds)
  const windowKey = `${key}:${window}`
 
  const count = await redis.incr(windowKey)
  if (count === 1) {
    await redis.expire(windowKey, windowSeconds)
  }
 
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  }
}

边界突发问题

固定窗口有个严重缺陷——窗口切换瞬间可以收到 2 倍请求:

时间轴:   |--窗口 1--|--窗口 2--|--窗口 3--|
请求分布: 10     5 | 1     10 | 5     10
           ↑           ↑
        窗口 1 末尾   窗口 2 开头
        共 15 个请求(超过 10)

窗口 1 末尾 5 个 + 窗口 2 开头 10 个 = 15 个,超过了每分钟 10 个的限制。

3. 滑动窗口计数器

原理

不硬切窗口——看「过去 N 秒内」的请求数。用当前窗口 + 上一个窗口的加权值估算:

当前窗口:   |--过去 60 秒--|
             [上窗口 40%][当前窗口 60%]
总计数 = 上窗口计数 × 0.4 + 当前窗口计数

实现

async function slidingWindowRedis(
  redis: Redis,
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now() / 1000
  const currentWindow = Math.floor(now / windowSeconds)
  const prevWindow = currentWindow - 1
 
  const [prevCount, currentCount] = await redis.mget(
    `${key}:${prevWindow}`,
    `${key}:${currentWindow}`
  )
 
  // 当前窗口在时间窗内的比例
  const elapsedInWindow = now % windowSeconds
  const weight = 1 - elapsedInWindow / windowSeconds
 
  const estimatedCount =
    (Number(prevCount) || 0) * weight + (Number(currentCount) || 0)
 
  if (estimatedCount >= limit) {
    return { allowed: false, remaining: 0 }
  }
 
  // 计数 +1
  const newCount = await redis.incr(`${key}:${currentWindow}`)
  if (newCount === 1) {
    await redis.expire(`${key}:${currentWindow}`, windowSeconds * 2)
  }
 
  return {
    allowed: true,
    remaining: Math.max(0, Math.floor(limit - estimatedCount - 1)),
  }
}

优缺点

  • 解决了边界突发问题
  • 比固定窗口精确
  • 需要存两个窗口的计数(Redis 两个 key)
  • 估算值不是精确值——但对于限流足够

4. 漏桶(Leaky Bucket)

原理

请求像水一样注入桶,桶以固定速率流出(处理)。桶满了就溢出(拒绝)。

请求 → [桶] → 固定速率流出
     ↑
   桶满了就拒绝

漏桶的特点是平滑输出——不管输入多快,输出速率恒定。

实现

async function leakyBucketRedis(
  redis: Redis,
  key: string,
  capacity: number,      // 桶容量
  ratePerSecond: number  // 流出速率
): Promise<{ allowed: boolean; retryAfter?: number }> {
  const now = Date.now() / 1000
  const bucketKey = `${key}:bucket`
 
  // 用 Lua 脚本保证原子性
  const lua = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
 
    local bucket = redis.call('hmget', key, 'level', 'last_time')
    local level = tonumber(bucket[1]) or 0
    local last_time = tonumber(bucket[2]) or now
 
    -- 计算漏出的水量
    local elapsed = now - last_time
    level = math.max(0, level - elapsed * rate)
 
    if level + 1 > capacity then
      return {0, (level + 1 - capacity) / rate}  -- 拒绝,返回等待时间
    end
 
    level = level + 1
    redis.call('hmset', key, 'level', level, 'last_time', now)
    redis.call('expire', key, 60)
    return {1}
  `
 
  const result = await redis.eval(lua, [bucketKey], [
    capacity,
    ratePerSecond,
    now,
  ])
 
  if (result[0] === 1) {
    return { allowed: true }
  } else {
    return { allowed: false, retryAfter: result[1] as number }
  }
}

适用场景

  • 下游系统有严格的速率限制(比如数据库每秒只能处理 100 个写入)
  • 需要平滑流量,防止突发
  • 消息队列的消费者端——匀速消费

5. 令牌桶(Token Bucket)

原理

桶里以固定速率生成令牌。每个请求拿一个令牌——有令牌就通过,没令牌就拒绝。桶有容量上限,令牌可以积累。

         令牌生成 → [令牌桶] → 请求来就拿令牌
                   ↑
               桶满了就不再生成

令牌桶的特点是允许突发——如果积累了 N 个令牌,可以一次性处理 N 个请求。

实现

async function tokenBucketRedis(
  redis: Redis,
  key: string,
  capacity: number,      // 桶容量(最大积累令牌数)
  refillRate: number     // 每秒补充令牌数
): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
  const now = Date.now() / 1000
  const bucketKey = `${key}:tokens`
 
  const lua = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
 
    local bucket = redis.call('hmget', key, 'tokens', 'last_time')
    local tokens = tonumber(bucket[1]) or capacity
    local last_time = tonumber(bucket[2]) or now
 
    -- 计算新补充的令牌
    local elapsed = now - last_time
    tokens = math.min(capacity, tokens + elapsed * rate)
 
    if tokens < 1 then
      return {0, 0, (1 - tokens) / rate}  -- 拒绝,返回等待时间
    end
 
    tokens = tokens - 1
    redis.call('hmset', key, 'tokens', tokens, 'last_time', now)
    redis.call('expire', key, 60)
    return {1, math.floor(tokens)}
  `
 
  const result = await redis.eval(lua, [bucketKey], [
    capacity,
    refillRate,
    now,
  ])
 
  if (result[0] === 1) {
    return { allowed: true, remaining: result[1] as number }
  } else {
    return {
      allowed: false,
      remaining: 0,
      retryAfter: result[2] as number,
    }
  }
}

适用场景

  • API 限流——用户有配额(比如 1000 次/小时),可以突发用完再慢慢恢复
  • 客户端限速——下载、上传时限制带宽
  • 大多数 Web API——用户体验好,允许短时间高并发

6. 四种算法对比

算法突发允许平滑输出实现复杂度适用场景
固定窗口❌(边界 2 倍)简单场景、粗略限流
滑动窗口精确限流
漏桶保护下游系统
令牌桶API 限流、用户体验优先

选择建议:

  • Web API 限流:令牌桶——允许突发,用户体验好
  • 下游保护:漏桶——平滑流量,保护脆弱系统
  • 粗略限流:固定窗口——实现简单,够用
  • 精确限流:滑动窗口——无边界问题

7. Worker 环境的限流实现

Worker 没有 Redis 是最大挑战——KV 的 get + put 不是原子操作。

7.1 用 KV 做固定窗口(有误差)

async function fixedWindowKV(
  kv: KVNamespace,
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
  const window = Math.floor(Date.now() / 1000 / windowSeconds)
  const windowKey = `${key}:${window}`
 
  const current = parseInt((await kv.get(windowKey)) || '0')
 
  if (current >= limit) {
    return { allowed: false, remaining: 0 }
  }
 
  await kv.put(windowKey, String(current + 1), {
    expirationTtl: windowSeconds * 2,
  })
 
  return { allowed: true, remaining: limit - current - 1 }
}

高并发下会超出限制——两个请求同时读到 current = 99,都认为自己还能发,结果变成 101 个。

对于大多数业务来说,1-2% 的误差可以接受。如果需要严格限制,必须用 Redis。

7.2 用 Upstash Redis(严格限制)

用前面的 @upstash/redis 客户端 + Lua 脚本:

import { Redis } from '@upstash/redis/cloudflare'
 
const redis = Redis.fromEnv(env)
const result = await tokenBucketRedis(redis, `user:${userId}`, 1000, 1)
 
if (!result.allowed) {
  return c.json(
    { error: 'Rate limit exceeded', retryAfter: result.retryAfter },
    429
  )
}

7.3 用 Durable Objects 做限流

Durable Objects 提供单实例串行化——天然解决原子性问题:

// src/rate-limiter.ts
export class RateLimiter {
  private state: DurableObjectState
  private tokens = 1000
  private lastRefill = Date.now()
 
  constructor(state: DurableObjectState) {
    this.state = state
    this.state.blockConcurrencyWhile(async () => {
      const stored = await this.state.storage.get<number>('tokens')
      if (stored !== undefined) this.tokens = stored
    })
  }
 
  async fetch(request: Request) {
    this.refill()
 
    if (this.tokens < 1) {
      return new Response(JSON.stringify({ allowed: false }), {
        status: 429,
      })
    }
 
    this.tokens--
    await this.state.storage.put('tokens', this.tokens)
    return new Response(JSON.stringify({ allowed: true }))
  }
 
  private refill() {
    const now = Date.now()
    const elapsed = (now - this.lastRefill) / 1000
    this.tokens = Math.min(1000, this.tokens + elapsed * 1)  // 1 令牌/秒
    this.lastRefill = now
  }
}

每个用户一个 Durable Object 实例——所有请求串行化,计数绝对准确。

但 Durable Objects 的成本比普通 KV 高很多。只在「必须严格限制」的场景用。

8. Hono 中间件封装

把限流逻辑封装成 Hono 中间件:

// src/middleware/rate-limit.ts
import { Hono } from 'hono'
 
type RateLimitConfig = {
  limit: number
  windowSeconds: number
  keyFn: (c: Context) => string
}
 
export function rateLimit(config: RateLimitConfig) {
  return async (c: Context, next: Next) => {
    const key = config.keyFn(c)
    const result = await checkRateLimit(c.env, key, config)
 
    if (!result.allowed) {
      c.header('Retry-After', String(Math.ceil(result.retryAfter || config.windowSeconds)))
      c.header('X-RateLimit-Limit', String(config.limit))
      c.header('X-RateLimit-Remaining', '0')
      return c.json(
        { error: 'Rate limit exceeded' },
        429
      )
    }
 
    c.header('X-RateLimit-Limit', String(config.limit))
    c.header('X-RateLimit-Remaining', String(result.remaining))
    await next()
  }
}
 
// 使用
app.use('/api/*', rateLimit({
  limit: 100,
  windowSeconds: 60,
  keyFn: (c) => `user:${c.get('userId')}`,
}))

后面几篇会讲用户级、IP 级、模型调用级的具体限流策略。

9. 小结

限流四种基本算法:

  • 固定窗口:最简单,有边界突发问题
  • 滑动窗口:解决边界突发,需要存两个窗口
  • 漏桶:平滑输出,保护下游
  • 令牌桶:允许突发,适合 API 限流

Worker 环境的实现选择:

  • KV:简单但非原子,有 1-2% 误差
  • Upstash Redis:原子操作 + Lua 脚本,严格准确
  • Durable Objects:串行化保证准确,成本较高

选择建议:Web API 限流用令牌桶(KV 或 Redis 实现),下游保护用漏桶(Redis 实现),需要严格限制用 Durable Objects。

下一篇讲用户级限流——按 user_id/API key 限流、配额管理、分级配额、D1/KV 实现。