2026-06-03 10:30:42 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags: [rate-limiting, redis, lua, token-bucket, distributed-system, go]
|
|
|
|
|
|
create time: 2026-06-03 10:40
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# 09. 限流
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 概述
|
|
|
|
|
|
|
|
|
|
|
|
Redis Lua 原子令牌桶 + 双层限流 + Fail-Open 降级,保护系统免受过载。
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 正文
|
|
|
|
|
|
|
2026-06-03 10:12:49 +08:00
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart LR
|
2026-06-03 10:30:42 +08:00
|
|
|
|
A["Request"] --> B["Global Limiter"]
|
|
|
|
|
|
B -->|pass| C["User Limiter"]
|
|
|
|
|
|
B -->|deny| F["429"]
|
2026-06-03 10:12:49 +08:00
|
|
|
|
B -->|redis-fail| C
|
2026-06-03 10:30:42 +08:00
|
|
|
|
C -->|pass| D["Handler"]
|
2026-06-03 10:12:49 +08:00
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 令牌桶算法
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
Gen2D 使用 **Redis + Lua 脚本** 实现分布式令牌桶限流,保证原子性和一致性。
|
|
|
|
|
|
|
|
|
|
|
|
### 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` 控制窗口大小)
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
> [!tip] 适用场景
|
|
|
|
|
|
> 24 小时维度的配额控制,如"每天 30 次提示词优化"。
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 双层限流配置
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
Gen2D 对核心接口实施**全局限流 + 用户限流**双重保护:
|
|
|
|
|
|
|
|
|
|
|
|
| 接口 | 全局限流 | 用户限流 | 窗口 |
|
|
|
|
|
|
|------|---------|---------|------|
|
|
|
|
|
|
| `/api/v1/prompt/optimize` | 1000 次/24h | 30 次/24h | 25h 过期 |
|
|
|
|
|
|
| `/api/v1/generate` | 500 次/24h | 15 次/24h | 25h 过期 |
|
|
|
|
|
|
|
|
|
|
|
|
### 执行顺序
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**全局限流在前**:先检查系统总体配额,避免单个用户耗尽全局配额。
|
|
|
|
|
|
|
|
|
|
|
|
### 配置参数
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## Fail-Open 降级
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
当 Redis 不可用时限流器自动降级为 **Fail-Open** 模式:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
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 故障导致全站不可用 |
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
> [!question] 为什么选择 Fail-Open 而不是 Fail-Close?
|
|
|
|
|
|
>
|
|
|
|
|
|
> 这是 **「可用性 vs 安全性」** 的经典抉择。在 Gen2D 的场景中:
|
|
|
|
|
|
> - 限流失效的代价:短时间内有人可能超出配额(几分钟到几小时)
|
|
|
|
|
|
> - 限流强固化的代价:**所有用户都无法使用服务**
|
|
|
|
|
|
>
|
|
|
|
|
|
> 显然,前者是可以接受的风险——超出配额的用户可以后续通过账单追缴;而后者意味着业务完全停摆。这种「宁可放宽、不可收紧」的设计哲学在基础设施层非常重要。
|
|
|
|
|
|
|
|
|
|
|
|
> [!note] 选择 Fail-Open
|
|
|
|
|
|
> 在"偶尔超限"和"完全不可用"之间,优先保证服务可用性。
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 中间件响应
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
限流中间件返回标准化的 HTTP 响应:
|
|
|
|
|
|
|
|
|
|
|
|
### 允许通过
|
|
|
|
|
|
|
|
|
|
|
|
```http
|
|
|
|
|
|
HTTP/1.1 200 OK
|
|
|
|
|
|
X-RateLimit-Remaining: 12
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 被限流
|
|
|
|
|
|
|
|
|
|
|
|
```http
|
|
|
|
|
|
HTTP/1.1 429 Too Many Requests
|
|
|
|
|
|
Retry-After: 3600
|
|
|
|
|
|
X-RateLimit-Remaining: 0
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
"code": 429,
|
|
|
|
|
|
"message": "请求过于频繁,请稍后再试"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| Header | 说明 |
|
|
|
|
|
|
|--------|------|
|
|
|
|
|
|
| `X-RateLimit-Remaining` | 剩余令牌数 |
|
|
|
|
|
|
| `Retry-After` | 建议重试等待秒数 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 指标采集
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
|
|
|
|
|
限流中间件自动采集 Prometheus 指标:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
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%),及时发现异常流量。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
## 关联文档
|
2026-06-03 10:12:49 +08:00
|
|
|
|
|
2026-06-03 10:30:42 +08:00
|
|
|
|
- [[10-中间件链]] — 限流中间件在链中的位置
|
|
|
|
|
|
- [[07-可观测性]] — 限流指标和告警规则
|