18.09-用户级限流
要点
- 用户级限流按
user_id或 API key 限流——每个用户独立计数,互不影响 - 配额管理需要持久化存储——D1 表记录用户配额、已使用量、重置时间
- 分级配额:不同套餐的用户(免费/付费)配额不同,鉴权后查配额再放行
- KV 适合做高速计数(读写低延迟),D1 适合做持久化配额记录(可查可改)
- 限流响应要带标准头:
X-RateLimit-Limit、X-RateLimit-Remaining、Retry-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 |
|---|---|---|---|
| free | 10 | 100,000 | 1,000 |
| pro | 1,000 | 10,000,000 | 8,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-Limit、X-RateLimit-Remaining、Retry-After - 超限处理:直接拒绝、降级模型、走异步队列——按业务需求选
下一篇讲 IP 级限流——按 IP 地址限流防滥用。Cloudflare 的 IP 识别机制、Hono 中间件实现、绕过与伪造的应对。