Files

5.7 KiB
Raw Permalink Blame History

tags, create time
tags create time
rate-limiting
redis
lua
token-bucket
distributed-system
go
2026-06-03 10:40

09. 限流

概述

Redis Lua 原子令牌桶 + 双层限流 + Fail-Open 降级,保护系统免受过载。


正文

flowchart LR
    A["Request"] --> B["Global Limiter"]
    B -->|pass| C["User Limiter"]
    B -->|deny| F["429"]
    B -->|redis-fail| C
    C -->|pass| D["Handler"]
    C -->|deny| F
    C -->|redis-fail| D

    style A fill:#e3f2fd,stroke:#1976d2
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#c8e6c9,stroke:#2e7d32
    style F fill:#ffcdd2,stroke:#c62828

令牌桶算法

Gen2D 使用 Redis + Lua 脚本 实现分布式令牌桶限流,保证原子性和一致性。

Lua 脚本核心逻辑

-- KEYS[1] = 限流 key
-- ARGV[1] = rate(每秒令牌数)
-- ARGV[2] = burst(桶容量)
-- ARGV[3] = now(当前时间戳,毫秒)
-- ARGV[4] = expiration(key 过期时间)

-- 1. 获取当前桶状态
local data = redis.call('HMGET', key, 'tokens', 'ts')

-- 2. 首次访问,初始化满桶
if tokens == nil then
    tokens = burst
    last_ts = now
end

-- 3. 计算时间差,补充令牌
local delta = now - last_ts
if delta > 0 and rate > 0 then
    local refill = (delta * rate) / 1000
    tokens = math.min(burst, tokens + refill)
end

-- 4. 判断是否允许
if tokens >= 1 then
    tokens = tokens - 1
    allowed = 1
else
    retry_after = math.ceil((deficit * 1000) / rate)
end

-- 5. 更新 Redis
redis.call('HSET', key, 'tokens', tokens, 'ts', last_ts)
redis.call('PEXPIRE', key, expiration)

return {allowed, tokens, retry_after}

固定窗口变体

当 Rate = 0 时,令牌桶退化为固定窗口模式:

  • 桶初始化为满(Burst 个令牌)
  • 用完后不补充(rate = 0 时跳过 refill)
  • 等待 key 过期后重置(Expiration 控制窗口大小)

[!tip] 适用场景 24 小时维度的配额控制,如"每天 30 次提示词优化"。


双层限流配置

Gen2D 对核心接口实施全局限流 + 用户限流双重保护:

接口 全局限流 用户限流 窗口
/api/v1/prompt/optimize 1000 次/24h 30 次/24h 25h 过期
/api/v1/generate 500 次/24h 15 次/24h 25h 过期

执行顺序

flowchart TB
    A["请求进入"] --> B["全局限流检查"]
    B -->|通过| C["用户限流检查"]
    B -->|拒绝| D["429 Too Many Requests"]
    C -->|通过| E["执行 Handler"]
    C -->|拒绝| D

    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#ffcdd2,stroke:#c62828
    style E fill:#c8e6c9,stroke:#2e7d32

全局限流在前:先检查系统总体配额,避免单个用户耗尽全局配额。

配置参数

type Config struct {
    Rate       int           // 每秒令牌数;0 = 固定窗口
    Burst      int           // 桶容量(窗口内总量上限)
    KeyPrefix  string        // Redis key 前缀
    Expiration time.Duration // key 过期时间
}
配置项 Prompt User Prompt Global Generate User Generate Global
Rate 0 0 0 0
Burst 30 1000 15 500
KeyPrefix ratelimit:prompt:user: ratelimit:prompt:global: ratelimit:generate:user: ratelimit:generate:global:
Expiration 25h 25h 25h 25h

Fail-Open 降级

当 Redis 不可用时限流器自动降级为 Fail-Open 模式:

func (l *TokenBucketLimiter) Allow(ctx context.Context, key string) (bool, int, time.Duration) {
    result, err := l.script.Run(ctx, l.client, ...).Int64Slice()
    if err != nil {
        // Redis 不可用时 fail-open,放行请求
        return true, l.config.Burst, 0
    }
    // ...
}

设计权衡:

策略 优点 缺点
Fail-Open ✅ 保证可用性,用户体验不受影响 可能短暂失去限流保护
Fail-Close 严格限流保护 Redis 故障导致全站不可用

[!question] 为什么选择 Fail-Open 而不是 Fail-Close?

这是 「可用性 vs 安全性」 的经典抉择。在 Gen2D 的场景中:

  • 限流失效的代价:短时间内有人可能超出配额(几分钟到几小时)
  • 限流强固化的代价:所有用户都无法使用服务

显然,前者是可以接受的风险——超出配额的用户可以后续通过账单追缴;而后者意味着业务完全停摆。这种「宁可放宽、不可收紧」的设计哲学在基础设施层非常重要。

[!note] 选择 Fail-Open 在"偶尔超限"和"完全不可用"之间,优先保证服务可用性。


中间件响应

限流中间件返回标准化的 HTTP 响应:

允许通过

HTTP/1.1 200 OK
X-RateLimit-Remaining: 12

被限流

HTTP/1.1 429 Too Many Requests
Retry-After: 3600
X-RateLimit-Remaining: 0

{
  "code": 429,
  "message": "请求过于频繁,请稍后再试"
}
Header 说明
X-RateLimit-Remaining 剩余令牌数
Retry-After 建议重试等待秒数

指标采集

限流中间件自动采集 Prometheus 指标:

metrics.RateLimitRequestsTotal.WithLabelValues(scope, endpoint, result).Inc()
metrics.RateLimitRemainingTokens.WithLabelValues(scope, endpoint).Set(float64(remaining))
  • scope:user / global
  • endpoint:prompt / generate
  • result:allowed / denied

配合告警规则 RateLimitHighDenialRate(拒绝率 > 5%),及时发现异常流量。


关联文档