# 令牌桶限流设计 ## 概述 CamTalk 采用令牌桶(Token Bucket)算法实现按用户维度的速率限制,核心目标是**控制 AI 调用成本**,同时为 REST API 提供防暴力破解保护。 **设计原则**: - **成本优先**:主要限流对象是 WebSocket `query` 消息(每次触发 STT + LLM + TTS 完整调用链) - **用户隔离**:Per-user 维度限流,单用户超限不影响其他用户 - **弹性突发**:令牌桶允许合理的突发请求,优于固定窗口的滑动限流 - **存储适配**:内存 + Redis 双实现,单实例零依赖,多实例分布式一致 ## 整体架构 ```mermaid graph TB subgraph Entry["入口层"] WS["WebSocket Handler
query 消息"] REST["REST API
login / register"] end subgraph LimiterModule["Rate Limiter 模块"] Interface["Limiter 接口
Allow(userID) → (bool, retryAfter)"] MemBucket["TokenBucket
内存令牌桶"] RedisBucket["RedisTokenBucket
Redis 令牌桶(Lua 脚本)"] Middleware["RateLimitMiddleware
Gin 中间件"] end subgraph Storage["存储层"] MemSync["sync.RWMutex
进程内 map"] Redis["Redis
分布式计数"] 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] = ttl(key 过期时间,秒) 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 # backend/config/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/)