18.09-用户级限流

要点

  • 用户级限流按 user_id 或 API key 限流——每个用户独立计数,互不影响
  • 配额管理需要持久化存储——D1 表记录用户配额、已使用量、重置时间
  • 分级配额:不同套餐的用户(免费/付费)配额不同,鉴权后查配额再放行
  • KV 适合做高速计数(读写低延迟),D1 适合做持久化配额记录(可查可改)
  • 限流响应要带标准头:X-RateLimit-LimitX-RateLimit-RemainingRetry-After
  • 超限后的处理策略:直接拒绝(429)、降级(用便宜模型)、排队(走异步队列)

1. 为什么按用户限流

IP 级限流会把同一 IP 下的多个用户一起限——公司内网、NAT 环境下会误伤。按用户 ID 限流更精确:

  • 免费用户每天 10 次 LLM 调用
  • 付费用户每天 1000 次
  • 企业用户按合同约定

每个用户独立计数,互不影响。免费用户把自己的配额用完了,不影响付费用户继续使用。

2. 数据模型

配额数据存 D1,查询和更新都是 SQL:

-- schema.sql
CREATE TABLE user_quotas (
  user_id TEXT PRIMARY KEY,
  plan TEXT NOT NULL DEFAULT 'free',          -- free / pro / enterprise
  daily_limit INTEGER NOT NULL DEFAULT 10,    -- 每日调用上限
  used_today INTEGER NOT NULL DEFAULT 0,      -- 今日已用
  reset_at TEXT NOT NULL,                     -- 下次重置时间(ISO 8601)
  updated_at TEXT NOT NULL
);
 
-- 常用查询
-- 1. 查用户配额和已用
SELECT * FROM user_quotas WHERE user_id = ?;
 
-- 2. 增加已用计数
UPDATE user_quotas
SET used_today = used_today + 1, updated_at = ?
WHERE user_id = ?;
 
-- 3. 每日重置(跑定时任务或懒重置)
UPDATE user_quotas
SET used_today = 0, reset_at = datetime('now', '+1 day')
WHERE reset_at < datetime('now');

D1 的好处:数据持久化、可查可改、支持事务。KV 只能 get/put,做复杂查询不方便。

3. 限流流程

请求 → 鉴权(拿 user_id)→ 查配额 → 判断能否继续 → 放行/拒绝
                                     ↓
                              能:计数 +1,放行
                              不能:返回 429

3.1 Hono 中间件实现

// src/middleware/user-rate-limit.ts
import { Context, Next } from 'hono'
 
export async function userRateLimit(c: Context, next: Next) {
  const userId = c.get('userId')
  if (!userId) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
 
  // 1. 查配额
  const quota = await c.env.DB.prepare(`
    SELECT * FROM user_quotas WHERE user_id = ?
  `).bind(userId).first()
 
  // 新用户:创建配额记录
  if (!quota) {
    await c.env.DB.prepare(`
      INSERT INTO user_quotas (user_id, plan, daily_limit, used_today, reset_at, updated_at)
      VALUES (?, 'free', 10, 0, datetime('now', '+1 day'), datetime('now'))
    `).bind(userId).run()
 
    // 重新查一次
    const newQuota = await c.env.DB.prepare(`
      SELECT * FROM user_quotas WHERE user_id = ?
    `).bind(userId).first()
    return continueWithQuota(c, next, newQuota)
  }
 
  return continueWithQuota(c, next, quota)
}
 
async function continueWithQuota(c: Context, next: Next, quota: any) {
  // 2. 检查是否需要重置(懒重置)
  const now = new Date().toISOString()
  if (quota.reset_at < now) {
    await c.env.DB.prepare(`
      UPDATE user_quotas
      SET used_today = 0, reset_at = datetime('now', '+1 day'), updated_at = ?
      WHERE user_id = ?
    `).bind(now, quota.user_id).run()
    quota.used_today = 0
  }
 
  // 3. 判断能否继续
  if (quota.used_today >= quota.daily_limit) {
    c.header('X-RateLimit-Limit', String(quota.daily_limit))
    c.header('X-RateLimit-Remaining', '0')
    c.header('Retry-After', String(secondsUntil(quota.reset_at)))
    return c.json(
      { error: 'Daily quota exceeded', resetAt: quota.reset_at },
      429
    )
  }
 
  // 4. 计数 +1
  await c.env.DB.prepare(`
    UPDATE user_quotas
    SET used_today = used_today + 1, updated_at = ?
    WHERE user_id = ?
  `).bind(now, quota.user_id).run()
 
  c.header('X-RateLimit-Limit', String(quota.daily_limit))
  c.header('X-RateLimit-Remaining', String(quota.daily_limit - quota.used_today - 1))
  await next()
}
 
function secondsUntil(isoTime: string): number {
  const target = new Date(isoTime).getTime()
  const now = Date.now()
  return Math.max(0, Math.ceil((target - now) / 1000))
}

4. 分级配额

不同套餐的用户配额不同。典型分级:

套餐每日 LLM 调用每月 token 上限单请求 max_tokens
free10100,0001,000
pro1,00010,000,0008,000
enterprise无限制按合同按合同

4.1 套餐与配额的关系

CREATE TABLE plans (
  plan_id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  daily_limit INTEGER NOT NULL,
  monthly_token_limit INTEGER NOT NULL,
  max_tokens_per_request INTEGER NOT NULL,
  allowed_models TEXT NOT NULL  -- JSON array: ["llama-3.1-8b", "claude-haiku-4-5"]
);
 
-- 用户表引用 plan_id
CREATE TABLE users (
  user_id TEXT PRIMARY KEY,
  plan_id TEXT NOT NULL DEFAULT 'free',
  FOREIGN KEY (plan_id) REFERENCES plans(plan_id)
);

4.2 查用户配额时带上套餐信息

async function getUserQuota(db: Database, userId: string) {
  return db.prepare(`
    SELECT u.user_id, p.daily_limit, p.monthly_token_limit,
           p.max_tokens_per_request, p.allowed_models,
           uq.used_today, uq.reset_at
    FROM users u
    JOIN plans p ON u.plan_id = p.plan_id
    LEFT JOIN user_quotas uq ON u.user_id = uq.user_id
    WHERE u.user_id = ?
  `).bind(userId).first()
}

5. Token 级限流

除了「调用次数」,还要限「token 数」——不同调用消耗的 token 差异巨大:

  • 简单问答:200 token
  • 长文档分析:50,000 token

按调用次数限流,用户可以每次都用 50,000 token 把配额耗尽。更公平的做法是两者都限。

5.1 记录 token 消耗

app.post('/api/ai/query', async (c) => {
  const userId = c.get('userId')
  const body = await c.req.json()
 
  // 调 LLM
  const result = await callLLM(c.env, body)
 
  // 记录 token 消耗
  const tokens = result.usage.totalTokens
  await c.env.DB.prepare(`
    UPDATE user_quotas
    SET tokens_used_this_month = tokens_used_this_month + ?
    WHERE user_id = ?
  `).bind(tokens, userId).run()
 
  return c.json(result)
})

5.2 检查 token 配额

async function checkTokenQuota(db: Database, userId: string): Promise<boolean> {
  const quota = await db.prepare(`
    SELECT monthly_token_limit, tokens_used_this_month
    FROM user_quotas WHERE user_id = ?
  `).bind(userId).first()
 
  return quota.tokens_used_this_month < quota.monthly_token_limit
}

6. 高速计数:KV 缓存层

D1 每次读写都要 round trip,高并发下会成为瓶颈。用 KV 做高速计数层:

// src/lib/quota-counter.ts
export async function incrementUsage(
  env: { DB: Database; USAGE_KV: KVNamespace },
  userId: string
): Promise<number> {
  const today = new Date().toISOString().slice(0, 10)
  const key = `usage:${userId}:${today}`
 
  // KV 原子递增(如果支持)或 get+put
  const current = parseInt((await env.USAGE_KV.get(key)) || '0')
  const newCount = current + 1
  await env.USAGE_KV.put(key, String(newCount), {
    expirationTtl: 86400 * 2,  // 2 天后过期
  })
 
  // 异步同步到 D1(不阻塞响应)
  env.waitUntil(
    env.DB.prepare(`
      INSERT INTO daily_usage (user_id, date, count)
      VALUES (?, ?, 1)
      ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1
    `).bind(userId, today).run()
  )
 
  return newCount
}

KV 计数快但不严格准确——D1 作为权威数据源,KV 只是缓存。对账时用 D1 的数据。

7. 限流响应头

HTTP 标准定义了一组限流响应头,客户端可以据此调整行为:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100           # 总配额
X-RateLimit-Remaining: 0         # 剩余配额
X-RateLimit-Reset: 1687392000    # 重置时间(Unix timestamp)
Retry-After: 3600                # 多少秒后可以重试
Content-Type: application/json
 
{
  "error": "Daily quota exceeded",
  "resetAt": "2026-06-22T00:00:00Z"
}

前端拿到 Retry-After 后可以:

  • 显示「请 N 小时后重试」
  • 自动倒计时后重试
  • 提示升级套餐

8. 超限后的处理策略

直接 429 拒绝是最简单的,但不是唯一选择。

8.1 降级到便宜模型

if (quota.used_today >= quota.daily_limit) {
  // 不拒绝,而是降级到便宜模型
  body.model = '@cf/meta/llama-3.1-8b-instruct'  // 用开源模型兜底
  // 继续处理
}

用户体验比 429 好——至少能拿到回答,虽然质量可能差一点。

8.2 走异步队列

if (quota.used_today >= quota.daily_limit) {
  // 放进队列,等配额恢复后处理
  await env.TASK_QUEUE.send({
    userId,
    payload: body,
    priority: 'low',
  })
  return c.json({
    message: 'Request queued due to quota limit',
    estimatedProcessAt: quota.reset_at,
  }, 202)
}

用户体验更好——不需要等待,后台会处理。

8.3 通知用户

超限后发站内信或邮件:

if (quota.used_today === quota.daily_limit) {
  await env.NOTIFY_QUEUE.send({
    userId,
    type: 'quota_exceeded',
    plan: quota.plan,
    resetAt: quota.reset_at,
  })
}

9. 小结

用户级限流的核心:

  • 数据模型:D1 表存用户配额、已用、重置时间;套餐表定义各级配额
  • 限流流程:鉴权 → 查配额 → 判断 → 计数 +1 → 放行/拒绝
  • 分级配额:不同套餐(free/pro/enterprise)配额不同
  • Token 级限流:除了调用次数,还要限 token 数——防止单请求消耗过多
  • 高速计数:KV 做缓存层,D1 做权威数据源
  • 限流响应头X-RateLimit-LimitX-RateLimit-RemainingRetry-After
  • 超限处理:直接拒绝、降级模型、走异步队列——按业务需求选

下一篇讲 IP 级限流——按 IP 地址限流防滥用。Cloudflare 的 IP 识别机制、Hono 中间件实现、绕过与伪造的应对。