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

14 KiB
Raw Blame History

令牌桶限流设计

概述

CamTalk 采用令牌桶Token Bucket算法实现按用户维度的速率限制核心目标是控制 AI 调用成本,同时为 REST API 提供防暴力破解保护。

设计原则

  • 成本优先:主要限流对象是 WebSocket query 消息(每次触发 STT + LLM + TTS 完整调用链)
  • 用户隔离Per-user 维度限流,单用户超限不影响其他用户
  • 弹性突发:令牌桶允许合理的突发请求,优于固定窗口的滑动限流
  • 存储适配:内存 + Redis 双实现,单实例零依赖,多实例分布式一致

整体架构

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

// 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

// 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 脚本保证原子性,避免竞态条件:

-- 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 Hashtokens + last_refill
  • TTL 自动过期,无需手动清理
  • Lua 脚本保证"读取-计算-回写"原子执行

4. Gin 中间件

文件位置backend/internal/ratelimit/middleware.go

// RateLimitMiddleware 返回 Gin 中间件,按 key 维度限流。
// keyFunc 从请求中提取限流 key如 IP、用户 ID
func RateLimitMiddleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc

使用方式

// 按 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

{
    "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.gocase "query" 分支中orchestrator 调用前检查:

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 的路由注册中添加中间件:

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 调用成本

配置设计

配置文件

# 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 次

配置结构体

// 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"`     // 每秒填充令牌数
}

默认值

// 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 初始化

// 初始化限流器
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()
}

注入到各模块

// 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_LIMITEDbackend/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 动态调整限流参数,无需重启:

// 预留接口
type DynamicLimiter interface {
    Limiter
    UpdateConfig(action string, cfg BucketConfig) error
}

3. 按用户等级差异化

不同用户等级使用不同的限流参数:

ratelimit:
  query:
    capacity: 10    # 免费用户
    rate: 0.2
  query_premium:
    capacity: 30    # 付费用户
    rate: 1.0

4. 滑动窗口限流

令牌桶适合允许突发的场景。如果需要更平滑的限流,可增加滑动窗口实现:

type SlidingWindowLimiter struct {
    windowSize time.Duration
    maxRequests int
}

5. 分布式全局限流

当前 Redis 实现是 Per-Instance 独立计数。如需全局精确限流,可改为 Redis 全局计数器(所有实例共享同一个 key

监控指标

关键指标

  • 限流触发率:被拒绝请求数 / 总请求数
  • 各端点限流分布query / login / register 各自的触发率
  • 等待时长分布retryAfter 的 P50/P99
  • 桶状态:各用户桶的平均令牌数(反映使用模式)

告警规则

  • 限流触发率突增:可能表示异常流量或攻击
  • 单用户持续被限流:可能表示客户端 bug死循环请求

参考资料