Files

226 lines
5.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [rate-limiting, redis, lua, token-bucket, distributed-system, go]
create time: 2026-06-03 10:40
---
# 09. 限流
## 概述
Redis Lua 原子令牌桶 + 双层限流 + Fail-Open 降级,保护系统免受过载。
---
## 正文
```mermaid
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 脚本核心逻辑
```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 过期 |
### 执行顺序
```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 |
---
## Fail-Open 降级
当 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 故障导致全站不可用 |
> [!question] 为什么选择 Fail-Open 而不是 Fail-Close?
>
> 这是 **「可用性 vs 安全性」** 的经典抉择。在 Gen2D 的场景中:
> - 限流失效的代价:短时间内有人可能超出配额(几分钟到几小时)
> - 限流强固化的代价:**所有用户都无法使用服务**
>
> 显然,前者是可以接受的风险——超出配额的用户可以后续通过账单追缴;而后者意味着业务完全停摆。这种「宁可放宽、不可收紧」的设计哲学在基础设施层非常重要。
> [!note] 选择 Fail-Open
> 在"偶尔超限"和"完全不可用"之间,优先保证服务可用性。
---
## 中间件响应
限流中间件返回标准化的 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` | 建议重试等待秒数 |
---
## 指标采集
限流中间件自动采集 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%),及时发现异常流量。
---
## 关联文档
- [[10-中间件链]] — 限流中间件在链中的位置
- [[07-可观测性]] — 限流指标和告警规则