Files
CamTalk/docs/11-令牌桶限流.md
hhs 239f8f9877 docs: 同步限流和鉴权文档的日志实现说明
- 更新限流文档:limiter 内部使用 trace.FromContext 自动记录日志
- 更新鉴权文档:Redis 降级策略使用 trace-aware 日志
- 引用 13-日志追踪.md 作为详细说明
- 移除过时的手动 logger.Log 调用示例
2026-06-21 23:19:11 +08:00

24 KiB
Raw Permalink 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 调用成本

配置设计

配置文件

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

配置结构体

// 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.NewMemoryLimiter(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死循环请求

实际实现要点

文件结构

backend/internal/ratelimit/
├── limiter.go              # Limiter 接口定义
├── bucket.go               # 内存令牌桶实现 (MemoryLimiter + TokenBucket)
├── bucket_test.go          # 内存令牌桶单元测试11 个测试用例)
├── redis_bucket.go         # Redis 令牌桶实现Lua 脚本)
├── redis_bucket_test.go    # Redis 令牌桶单元测试
└── middleware.go           # Gin 中间件实现

TokenBucket 实现细节

核心数据结构bucket.go:12-18

type TokenBucket struct {
    capacity   int           // 桶容量
    rate       float64       // 每秒填充令牌数
    tokens     float64       // 当前令牌数(浮点数支持小数令牌)
    lastRefill time.Time     // 上次填充时间
    mu         sync.Mutex    // 保护并发访问
}

并发安全bucket.go:31-56

  • 每个桶内部使用 sync.Mutex 保护 tokenslastRefill 字段
  • allow() 方法的"读取-计算-回写"操作原子执行
  • 桶 map 使用 sync.RWMutex 保护,读多写少优化(bucket.go:62
  • 双重检查锁(bucket.go:106-113):先尝试读锁获取桶,不存在时升级写锁创建

内存回收机制bucket.go:132-161

  • 后台 goroutine 每 10 分钟扫描一次(cleanup() 方法)
  • 删除超过 10 分钟无活动的桶(lastRefill 超时判断)
  • 通过 done channel 和 sync.Once 保证优雅停止

惰性创建bucket.go:95-121

  • 用户首次请求时才创建桶,避免预分配内存
  • getOrCreateBucket() 使用读写锁分离,优化热路径性能

Gin 中间件实现

实际代码middleware.go:12-42

func Middleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
    return func(c *gin.Context) {
        if limiter == nil {
            c.Next()
            return
        }

        key := keyFunc(c)
        if key == "" {
            // key 为空时跳过限流
            c.Next()
            return
        }

        allowed, retryAfter := limiter.Allow(c.Request.Context(), key)

        if !allowed {
            // 设置 Retry-After header
            c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))

            c.JSON(http.StatusTooManyRequests, gin.H{
                "code":    "RATE_LIMITED",
                "message": fmt.Sprintf("too many requests, retry after %s", retryAfter.Round(1)),
            })
            c.Abort()
            return
        }

        c.Next()
    }
}

设计要点

  • nil limiter 自动跳过限流(支持配置关闭)
  • 空 key 跳过限流(支持匿名端点)
  • retryAfter 向上取整到秒(符合 HTTP 标准)
  • c.Abort() 阻止后续 handler 执行

WebSocket 限流接入

实际接入点internal/ws/handler.go:230-240

case "query":
    // ... 解析消息 ...

    // 限流检查
    if limiter != nil {
        key := fmt.Sprintf("%s:query", userID)
        allowed, retryAfter := limiter.Allow(ctx, key)
        if !allowed {
            // 限流触发时自动记录 Warn 日志(在 limiter 内部使用 trace.FromContext
            errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
                fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
            continue
        }
    }

    // ... 继续处理 query ...

设计要点

  • key 格式:userID:query(用户级限流)
  • 拒绝时发送 RATE_LIMITED 错误到客户端
  • 限流触发时 RedisLimiter.Allow 内部自动记录 Warn 日志(带 trace_id详见 docs/13-日志追踪.md
  • 不阻塞其他消息类型(ping/config/interrupt 不限流)

配置加载与依赖注入

配置文件路径

  • 基础配置:backend/config/config.yaml
  • 开发环境:backend/config/config.dev.yaml
  • 生产环境:backend/config/config.prod.yaml

实际配置示例config.yaml:63-76

ratelimit:
  enabled: false              # 是否启用限流
  # 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 次

依赖注入实现cmd/server/main.go:200-214

// 初始化限流器
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.NewMemoryLimiter(cfg.RateLimit)
        logger.Log.Info("rate limiter initialized with in-memory backend")
    }
    defer limiter.Stop()
} else {
    logger.Log.Info("rate limiter disabled")
}

自动选择策略

  1. 配置关闭(enabled: false)→ limiter = nil(完全跳过限流)
  2. Redis 可用 → NewRedisLimiter(分布式一致)
  3. Redis 不可用 → NewMemoryLimiter(单实例零依赖)

注入到模块

// WebSocket Handler
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))

// REST API
authHandler.RegisterRoutes(apiGroup, limiter)

实际测试用例

内存令牌桶测试bucket_test.go11 个用例):

测试用例 验证内容
TestTokenBucket_Allow_FirstRequest 首次请求通过
TestTokenBucket_Allow_ConsumeUntilEmpty 连续消耗至桶空
TestTokenBucket_Allow_RetryAfterCorrect retryAfter 计算准确性
TestTokenBucket_Allow_RefillAfterWait 等待后令牌补充
TestTokenBucket_Allow_CapacityLimit 桶容量上限限制
TestTokenBucket_Allow_ConcurrentSafe 100 并发请求正确性
TestTokenBucket_Allow_ZeroCapacity 边界:capacity=0
TestTokenBucket_Allow_ZeroRate 边界:rate=0
TestMemoryLimiter_Allow_DifferentKeys 不同用户隔离
TestMemoryLimiter_Cleanup 不活跃桶自动清理
TestMemoryLimiter_Stop 多次 Stop() 不 panic

中间件测试middleware_test.go7 个用例):

测试用例 验证内容
TestMiddleware_Allow 允许时正常响应
TestMiddleware_Deny 拒绝时返回 429 + Retry-After header
TestMiddleware_NilLimiter nil limiter 放行
TestMiddleware_EmptyKey 空 key 放行
TestMiddleware_KeyFunc keyFunc 正确提取 key
TestMiddleware_RetryAfterRounding retryAfter 向上取整

并发安全性验证bucket_test.go:83-107

func TestTokenBucket_Allow_ConcurrentSafe(t *testing.T) {
    bucket := newTokenBucket(100, 10.0)
    var wg sync.WaitGroup
    successCount := 0
    var mu sync.Mutex

    // 100 个并发请求
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            allowed, _ := bucket.allow()
            if allowed {
                mu.Lock()
                successCount++
                mu.Unlock()
            }
        }()
    }

    wg.Wait()

    // 应该正好 100 个成功(桶容量为 100
    assert.Equal(t, 100, successCount)
}

Redis Lua 脚本实现

实际脚本redis_bucket.go:15-53

-- 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
    if rate == 0 then
        retry_after = 86400  -- 24小时
    else
        retry_after = (1 - tokens) / rate
    end
end

-- 回写状态
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, ttl)

return {allowed, tostring(retry_after)}

设计要点

  • 使用 Hash 存储两个字段:tokens(当前令牌数)+ last_refill(上次填充时间)
  • 原子性:整个脚本在 Redis 单线程中执行,无竞态条件
  • 自动过期:每次操作设置 TTL默认 10 分钟),无需手动清理
  • 与内存实现算法一致(便于单元测试验证行为等价性)

编译期接口检查

接口契约bucket.go:172middleware_test.go:31

// 确保 MemoryLimiter 实现了 Limiter 接口
var _ Limiter = (*MemoryLimiter)(nil)

// 确保 mockLimiter 实现了 Limiter 接口
var _ Limiter = (*mockLimiter)(nil)

编译器会在类型不匹配时报错,避免运行时接口错误。

环境变量覆盖

配置文件中的 ratelimit 配置可通过环境变量覆盖:

export CAMTALK_RATELIMIT_ENABLED=true
export CAMTALK_RATELIMIT_QUERY_CAPACITY=20
export CAMTALK_RATELIMIT_QUERY_RATE=0.5

环境变量优先级高于配置文件Viper 配置绑定)。

参考资料