Files
CamTalk/docs/13-令牌桶限流设计.md
hhs 85d47c1fc4 docs: 添加令牌桶限流模块设计文档
- 新建 docs/13-令牌桶限流设计.md,覆盖算法原理、内存/Redis 双实现、配置设计、接入点、测试用例等
- 更新 docs/01-架构设计.md Rate Limiter 模块行链接至新文档
- 更新 docs/README.md 文档索引和推荐阅读顺序
2026-06-20 17:08:23 +08:00

500 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 令牌桶限流设计
## 概述
CamTalk 采用令牌桶Token Bucket算法实现按用户维度的速率限制核心目标是**控制 AI 调用成本**,同时为 REST API 提供防暴力破解保护。
**设计原则**
- **成本优先**:主要限流对象是 WebSocket `query` 消息(每次触发 STT + LLM + TTS 完整调用链)
- **用户隔离**Per-user 维度限流,单用户超限不影响其他用户
- **弹性突发**:令牌桶允许合理的突发请求,优于固定窗口的滑动限流
- **存储适配**:内存 + Redis 双实现,单实例零依赖,多实例分布式一致
## 整体架构
```mermaid
graph TB
subgraph Entry["入口层"]
WS["WebSocket Handler<br/>query 消息"]
REST["REST API<br/>login / register"]
end
subgraph LimiterModule["Rate Limiter 模块"]
Interface["Limiter 接口<br/>Allow(userID) → (bool, retryAfter)"]
MemBucket["TokenBucket<br/>内存令牌桶"]
RedisBucket["RedisTokenBucket<br/>Redis 令牌桶Lua 脚本)"]
Middleware["RateLimitMiddleware<br/>Gin 中间件"]
end
subgraph Storage["存储层"]
MemSync["sync.RWMutex<br/>进程内 map"]
Redis["Redis<br/>分布式计数"]
end
WS -->|"限流检查"| Interface
REST -->|"中间件"| Middleware
Middleware --> Interface
Interface --> MemBucket
Interface --> RedisBucket
MemBucket --> MemSync
RedisBucket --> Redis
```
## 令牌桶算法
### 原理
令牌桶以固定速率向桶中添加令牌,桶有最大容量上限。每次请求消耗一个令牌,桶空时拒绝请求。
```
桶容量(capacity) = 允许的突发请求数上限
填充速率(rate) = 每秒补充的令牌数
时间线示例capacity=5, rate=0.2
t=0s 桶满 5 令牌 → 用户连续发 5 个 query 全部通过
t=0s 桶空 → 第 6 个 query 被拒绝retryAfter=5s
t=5s 桶补充 1 令牌 → 可再发 1 个 query
t=10s 桶补充 1 令牌 → 可再发 1 个 query
```
### 算法公式
```
elapsed = now - lastRefill
newTokens = elapsed * rate
currentTokens = min(capacity, lastTokens + newTokens)
if currentTokens >= 1:
currentTokens -= 1
allowed = true
else:
allowed = false
retryAfter = (1 - currentTokens) / rate
```
## 核心组件
### 1. Limiter 接口
**文件位置**`backend/internal/ratelimit/limiter.go`
```go
// Limiter 速率限制器接口。
type Limiter interface {
// Allow 判断 key 是否允许执行一次操作。
// key 通常为 "userID:action" 格式。
// 返回 (allowed, retryAfter)。retryAfter 表示需要等待的时间。
Allow(ctx context.Context, key string) (bool, time.Duration)
}
```
**设计要点**
- key 为字符串,不限定格式,由调用方决定维度(用户 ID、IP 地址等)
- 返回 `retryAfter` 供客户端/服务端设置 `Retry-After` header
- 接受 `context.Context` 支持超时和取消Redis 实现需要)
### 2. 内存令牌桶TokenBucket
**文件位置**`backend/internal/ratelimit/bucket.go`
```go
// TokenBucket 内存令牌桶,适用于单实例部署。
type TokenBucket struct {
capacity int // 桶容量
rate float64 // 每秒填充令牌数
tokens float64 // 当前令牌数
lastRefill time.Time // 上次填充时间
mu sync.Mutex
}
// Limiter 管理多个用户的令牌桶。
type Limiter struct {
buckets map[string]*TokenBucket
config Config
mu sync.RWMutex
stopOnce sync.Once
done chan struct{}
}
```
**并发安全**
- 每个桶内部用 `sync.Mutex` 保护读写
- 桶 map 用 `sync.RWMutex` 保护(读多写少场景)
- 用户首次请求时惰性创建桶
**内存回收**
- 后台 goroutine 定期扫描,清理超过 10 分钟无活动的桶
- 避免长期运行后内存泄漏
### 3. Redis 令牌桶RedisTokenBucket
**文件位置**`backend/internal/ratelimit/redis_bucket.go`
使用 Redis Lua 脚本保证原子性,避免竞态条件:
```lua
-- KEYS[1] = 限流 key
-- ARGV[1] = capacity桶容量
-- ARGV[2] = rate每秒填充数
-- ARGV[3] = now当前时间戳浮点
-- ARGV[4] = ttlkey 过期时间,秒)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
-- 计算新令牌
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * rate)
local allowed = 0
local retry_after = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
else
retry_after = (1 - tokens) / rate
end
-- 回写状态
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, ttl)
return {allowed, tostring(retry_after)}
```
**设计要点**
- 每个用户的限流状态存储为一个 Redis Hash`tokens` + `last_refill`
- TTL 自动过期,无需手动清理
- Lua 脚本保证"读取-计算-回写"原子执行
### 4. Gin 中间件
**文件位置**`backend/internal/ratelimit/middleware.go`
```go
// RateLimitMiddleware 返回 Gin 中间件,按 key 维度限流。
// keyFunc 从请求中提取限流 key如 IP、用户 ID
func RateLimitMiddleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc
```
**使用方式**
```go
// 按 IP 限流(登录/注册,未登录用户无 userID
loginGroup.POST("/login",
ratelimit.Middleware(limiter, func(c *gin.Context) string {
return c.ClientIP() + ":login"
}),
authHandler.Login,
)
// 按用户 ID 限流(已认证的 API
authorized.POST("/conversations",
ratelimit.Middleware(limiter, func(c *gin.Context) string {
return c.GetString("user_id") + ":conversation"
}),
convHandler.Create,
)
```
**错误响应**
REST API 返回 HTTP 429
```json
{
"code": "RATE_LIMITED",
"message": "too many requests, retry after 5s"
}
```
同时设置 `Retry-After` header
```
HTTP/1.1 429 Too Many Requests
Retry-After: 5
```
## 限流接入点
### WebSocket query 消息(核心)
`ws/handler.go``case "query"` 分支中orchestrator 调用前检查:
```go
case "query":
// ... 解析消息 ...
// 限流检查
if limiter != nil {
allowed, retryAfter := limiter.Allow(ctx, userID+":query")
if !allowed {
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
fmt.Errorf("rate limited, retry after %s", retryAfter))
continue
}
}
// ... 继续处理 query ...
```
### REST API 登录/注册
`api/auth.go` 的路由注册中添加中间件:
```go
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limiter) {
auth := rg.Group("/auth")
if limiter != nil {
auth.POST("/register",
ratelimit.Middleware(limiter, ipKeyFunc("register")),
h.Register,
)
auth.POST("/login",
ratelimit.Middleware(limiter, ipKeyFunc("login")),
h.Login,
)
} else {
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
}
auth.POST("/refresh", h.Refresh)
auth.POST("/logout", h.Logout)
}
```
### 不限流的端点
| 端点 | 原因 |
|------|------|
| `ping` / `pong` | 心跳保活,无 AI 调用成本 |
| `config` | 配置更新,无 AI 调用成本 |
| `interrupt` | 中断请求,取消操作不应被限流 |
| `GET /api/health` | 健康检查,运维必需 |
| `POST /api/auth/refresh` | Token 刷新,限流会导致用户被迫重新登录 |
| `POST /api/auth/logout` | 登出,限流会导致用户无法正常退出 |
| `GET /api/conversations` | 查询列表,无 AI 调用成本 |
## 配置设计
### 配置文件
```yaml
# config.yaml 新增
ratelimit:
enabled: true
# WebSocket query 消息限流(核心,控制 AI 成本)
query:
capacity: 10 # 突发容量:允许连续发 10 个 query
rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌
# REST API 登录限流(防暴力破解)
login:
capacity: 5 # 突发容量:允许连续 5 次登录尝试
rate: 0.1 # 填充速率:每 10 秒补充 1 次
# REST API 注册限流
register:
capacity: 3 # 突发容量:允许连续 3 次注册
rate: 0.05 # 填充速率:每 20 秒补充 1 次
```
### 配置结构体
```go
// config/config.go 新增
type RateLimitConfig struct {
Enabled bool `mapstructure:"enabled"`
Query BucketConfig `mapstructure:"query"`
Login BucketConfig `mapstructure:"login"`
Register BucketConfig `mapstructure:"register"`
}
type BucketConfig struct {
Capacity int `mapstructure:"capacity"` // 桶容量(突发上限)
Rate float64 `mapstructure:"rate"` // 每秒填充令牌数
}
```
### 默认值
```go
// setDefaults 新增
v.SetDefault("ratelimit.enabled", false)
v.SetDefault("ratelimit.query.capacity", 10)
v.SetDefault("ratelimit.query.rate", 0.2)
v.SetDefault("ratelimit.login.capacity", 5)
v.SetDefault("ratelimit.login.rate", 0.1)
v.SetDefault("ratelimit.register.capacity", 3)
v.SetDefault("ratelimit.register.rate", 0.05)
```
### 参数选择建议
| 场景 | capacity | rate | 含义 |
|------|----------|------|------|
| WebSocket query | 10 | 0.2 | 突发 10 个,之后每 5 秒 1 个 |
| 登录 | 5 | 0.1 | 突发 5 次,之后每 10 秒 1 次 |
| 注册 | 3 | 0.05 | 突发 3 次,之后每 20 秒 1 次 |
> **调参原则**capacity 决定"能忍多少次突发"rate 决定"稳态下多久能再请求一次"。query 的 rate 建议根据 AI 调用成本和目标月预算反推。
## 依赖注入
### main.go 初始化
```go
// 初始化限流器
var limiter ratelimit.Limiter
if cfg.RateLimit.Enabled {
if rdb != nil {
// 多实例:使用 Redis 令牌桶
limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit)
logger.Log.Info("rate limiter initialized with Redis backend")
} else {
// 单实例:使用内存令牌桶
limiter = ratelimit.NewLimiter(cfg.RateLimit)
logger.Log.Info("rate limiter initialized with in-memory backend")
}
defer limiter.Stop()
}
```
### 注入到各模块
```go
// WebSocket Handler —— 新增 limiter 参数
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
// Auth REST —— 新增 limiter 参数
authHandler := api.NewAuthHandler(authService, tokenMgr)
authHandler.RegisterRoutes(apiGroup, limiter)
```
## 错误码
复用已有错误码 `RATE_LIMITED``backend/internal/errors/codes.go`
| 传输层 | HTTP 状态码 | 错误格式 |
|--------|-----------|---------|
| REST API | 429 Too Many Requests | `{code: "RATE_LIMITED", message: "too many requests, retry after Xs"}` |
| WebSocket | — | `{type: "error", code: "RATE_LIMITED", request_id: "...", message: "..."}` |
## 文件结构
```
backend/internal/ratelimit/
├── limiter.go # Limiter 接口 + Config 类型定义
├── bucket.go # 内存令牌桶实现
├── bucket_test.go # 内存令牌桶单元测试
├── redis_bucket.go # Redis 令牌桶实现Lua 脚本)
├── redis_bucket_test.go# Redis 令牌桶单元测试
└── middleware.go # Gin 中间件
```
## 测试用例
### 单元测试
**内存令牌桶**`bucket_test.go`
- 首次请求通过
- 连续消耗至桶空
- 桶空后拒绝,返回正确 retryAfter
- 等待后令牌补充,请求通过
- 并发安全性(多个 goroutine 同时 Allow
- 桶容量边界capacity=0, capacity=1
- 填充速率边界rate=0, rate 极大值)
- 不活跃桶的内存回收
**Redis 令牌桶**`redis_bucket_test.go`
- 与内存实现行为一致性
- Lua 脚本原子性
- key TTL 自动过期
- 并发安全性(多个客户端同时请求)
### 集成测试
- 限流关闭时不拦截请求
- 限流开启后REST API 登录超限返回 429
- 限流开启后WebSocket query 超限返回 `RATE_LIMITED` 错误
- 单实例内存限流 vs 多实例 Redis 限流行为一致
- 重启后内存限流重置Redis 限流保持
## 扩展点
### 1. 多级限流
可扩展为多级限流策略:
```
全局限流(全用户共享) → 用户级限流(当前实现) → 端点级限流(不同 API 不同限制)
```
### 2. 动态调参
通过配置热更新或管理 API 动态调整限流参数,无需重启:
```go
// 预留接口
type DynamicLimiter interface {
Limiter
UpdateConfig(action string, cfg BucketConfig) error
}
```
### 3. 按用户等级差异化
不同用户等级使用不同的限流参数:
```yaml
ratelimit:
query:
capacity: 10 # 免费用户
rate: 0.2
query_premium:
capacity: 30 # 付费用户
rate: 1.0
```
### 4. 滑动窗口限流
令牌桶适合允许突发的场景。如果需要更平滑的限流,可增加滑动窗口实现:
```go
type SlidingWindowLimiter struct {
windowSize time.Duration
maxRequests int
}
```
### 5. 分布式全局限流
当前 Redis 实现是 Per-Instance 独立计数。如需全局精确限流,可改为 Redis 全局计数器(所有实例共享同一个 key
## 监控指标
### 关键指标
- **限流触发率**:被拒绝请求数 / 总请求数
- **各端点限流分布**query / login / register 各自的触发率
- **等待时长分布**retryAfter 的 P50/P99
- **桶状态**:各用户桶的平均令牌数(反映使用模式)
### 告警规则
- **限流触发率突增**:可能表示异常流量或攻击
- **单用户持续被限流**:可能表示客户端 bug死循环请求
## 参考资料
- [Token Bucket 算法](https://en.wikipedia.org/wiki/Token_bucket)
- [Redis Rate Limiting](https://redis.io/glossaries/rate-limiting/)
- [Cloudflare - How we built rate limiting capable of scaling to millions of domains](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/)