Appearance
三级限流与配额:防滥用也防超支
1. 本节产出
一个三级配额系统:全局 → 租户 → 用户,每级都有「速率」和「总量」两个维度,超限时按策略降级而非硬拒绝,并且配额消耗可实时查询。
2. 前置依赖
3. 为什么需要三级而不是一级
只有全局限流时:
场景:某租户写了个脚本,每秒 200 次调用
后果:全局限流触发 → 所有租户都被限 → 正常用户投诉全局限流无法隔离噪声邻居。这是多租户系统的经典问题。
只有租户级时:
场景:某租户内部有个用户疯狂调用
后果:该租户配额被一个人耗尽 → 同租户其他人都用不了所以需要三级,各自解决不同问题:
| 级别 | 防什么 |
|---|---|
| 全局 | 保护平台自身不超载、控制总成本 |
| 租户 | 隔离噪声邻居、落实商业配额 |
| 用户 | 防单用户滥用、防脚本刷 |
4. 核心原理
4.1 两个维度:速率 vs 总量
| 维度 | 单位 | 防什么 | 典型场景 |
|---|---|---|---|
| 速率 | QPS / 并发数 | 瞬时冲击 | 脚本刷接口 |
| 总量 | Token / 金额 / 次数(月) | 累计消耗 | 预算超支 |
只做速率不做总量:租户可以持续低频调用,月底账单爆掉。 只做总量不做速率:一天内的第一小时就把月额度用完,后面一个月不能用。
两个都要。
4.2 超限后的行为阶梯
正常
│ 用量 80%
▼
告警(通知租户管理员)
│ 用量 95%
▼
自动降级(切便宜模型 / 降低 maxTokens / 关闭精排)
│ 用量 100%
▼
限流拒绝(429 + 明确的重置时间)「95% 自动降级」是关键设计。它让系统在硬拒绝之前先「变省」,给用户一个缓冲期。
4.3 限流算法选择
| 算法 | 特点 | 适用 |
|---|---|---|
| 固定窗口 | 简单,但有边界突刺 | 不推荐 |
| 滑动窗口 | 平滑,但内存占用较高 | 中小规模 |
| 令牌桶 | 允许突发、平滑 | 推荐(Spring Cloud Gateway 内置) |
| 漏桶 | 严格匀速 | 需要严格整形时 |
推荐令牌桶:它允许短时突发(用户连问三个问题是合理的),同时限制长期平均速率。
4.4 分布式计数
问题:多副本网关,各自计数 → 实际是 N 倍配额
解法:集中计数(Redis)
INCR + EXPIRE(原子)
或 Lua 脚本保证原子性必须集中计数。本地计数在多副本下等于没有限流(N 倍配额)。
5. 代码走查
5.1 配额模型
yaml
quota:
global:
qps: 500
monthly-tokens: 500000000 # 5 亿
tenants:
acme:
qps: 50
monthly-tokens: 50000000
monthly-budget-yuan: 4000
tier: standard
beta:
qps: 10
monthly-tokens: 5000000
tier: trial
users:
default:
qps: 5
daily-requests: 2005.2 三级检查
java
// src/main/java/com/example/platform/quota/QuotaService.java
@Service
public class QuotaService {
private final RedisTemplate<String, String> redis;
public QuotaDecision check(String tenantId, String userId, int estimatedTokens) {
// 顺序:先便宜的检查
if (!rateAllow("global", quota.globalQps())) {
return QuotaDecision.reject("全局速率超限");
}
if (!rateAllow("tenant:" + tenantId, quota.tenantQps(tenantId))) {
return QuotaDecision.reject("租户速率超限");
}
if (!rateAllow("user:" + userId, quota.userQps())) {
return QuotaDecision.reject("用户速率超限");
}
// 总量检查(较慢,放后面)
long used = monthlyTokens(tenantId);
long limit = quota.tenantMonthlyTokens(tenantId);
double ratio = used / (double) limit;
if (ratio >= 1.0) return QuotaDecision.reject("租户月度额度已用尽");
if (ratio >= 0.95) return QuotaDecision.degrade(DEGRADED_HEAVY, ratio);
if (ratio >= 0.80) return QuotaDecision.degrade(DEGRADED_LIGHT, ratio);
return QuotaDecision.allow();
}
/** 令牌桶:Lua 脚本保证原子性 */
private boolean rateAllow(String key, int qps) {
String lua = """
local k = KEYS[1]
local rate = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local b = redis.call('HMGET', k, 'tokens', 'ts')
local tokens = tonumber(b[1]) or rate
local ts = tonumber(b[2]) or now
tokens = math.min(rate, tokens + (now - ts) * rate)
if tokens < 1 then return 0 end
redis.call('HMSET', k, 'tokens', tokens - 1, 'ts', now)
redis.call('EXPIRE', k, 10)
return 1
""";
return redis.execute(script, List.of("rl:" + key),
String.valueOf(qps), String.valueOf(System.currentTimeMillis() / 1000)) != 0L;
}
public record QuotaDecision(boolean allow, boolean degrade, Tier tier,
double ratio, String rejectReason) {}
}5.3 网关集成
java
// 在 ModelRouterFilter 里调用
QuotaDecision d = quotaService.check(tenantId, userId, req.estimatedTokens());
if (!d.allow()) {
return json(exchange, 429, Map.of(
"error", d.rejectReason(),
"resetAt", nextResetTime()));
}
if (d.degrade()) {
// 降级:改路由到便宜模型
req = req.withTier(d.tier());
}5.4 配额消耗记录与查询
java
// 实时记录
public void consume(String tenantId, String userId, int tokens) {
String month = YearMonth.now().toString();
redis.opsForValue().increment("quota:t:" + tenantId + ":" + month, tokens);
redis.opsForValue().increment("quota:u:" + userId + ":" + monthOfDay(), 1);
}
// 查询接口
@GetMapping("/api/quota/{tenantId}")
public QuotaStatus status(@PathVariable String tenantId) {
long used = monthlyTokens(tenantId);
long limit = quota.tenantMonthlyTokens(tenantId);
return new QuotaStatus(used, limit, used / (double) limit, currentTier(used, limit));
}6. 跑起来
bash
git checkout ch04-03-quota
docker compose up -d # Redis
mvn spring-boot:runbash
# 1. 正常调用
curl -X POST http://localhost:8080/v1/chat/completions -H "X-Tenant: acme" ...
# 期望:200,响应头 X-Quota-Used / X-Quota-Limit
# 2. 用户级限流(快速连打 20 次)
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " ... ; done
# 期望:前 5 次 200,之后 429
# 3. 租户级限流(多用户并发)
# 期望:并发超过租户 QPS 后 429
# 4. 降级验证(把测试租户额度压到 96%)
curl -X POST http://localhost:8080/admin/quota/set -d '{"tenantId":"demo","used":4800000,"limit":5000000}'
curl -X POST http://localhost:8080/v1/chat/completions -H "X-Tenant: demo" ...
# 期望:200 但 X-Quota-Tier: DEGRADED_HEAVY,使用了便宜模型
# 5. 配额查询
curl http://localhost:8080/api/quota/acme| 检查项 | 通过标准 |
|---|---|
| 三级生效 | 全局/租户/用户分别能限住 |
| 不误伤 | 一个租户超限不影响其他租户 |
| 降级可用 | 95% 时降级而非拒绝 |
| 分布式正确 | 多副本下配额不被放大(Redis 计数) |
| 可查询 | 实时看到已用/上限/档位 |
「不误伤」是本节最重要的验收:它证明噪声邻居被隔离了。
7. 生产避坑
- 限流必须集中计数(Redis),不能各副本本地计数。本地计数在多副本部署下等于配额放大 N 倍,而且这个值随扩缩容变化——表现是「压测时没事,扩容后配额不准」。
- 超限后优先降级,最后才拒绝。直接 429 会让用户在月底集体无法使用。正确的阶梯是告警 → 降级 → 拒绝,让用户有缓冲期且系统始终可用(只是变省)。
- 配额查询接口要对用户可见。用户不知道自己用了多少,就无法调整行为,只能在被拒时才知道——体验很差。做法是响应头带上
X-Quota-Used/Limit,并提供查询接口。
8. 延伸与锚点
- 思考题:网关、配额都有了,多个 AI 服务(RAG、Agent、批处理)之间边界怎么划?(答案在下一课时)
- 代码锚点:
git checkout ch04-03-quota - 下一课时:04-04 服务拆分与租户隔离
- 对应课件:L04-03 限流与配额