Skip to content

三级限流与配额:防滥用也防超支 ​

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: 200

5.2 三级检查 ​

java
// ch04-platform/src/main/java/com/aitech/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:run
bash
# 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. 生产避坑 ​

  1. 限流必须集中计数(Redis),不能各副本本地计数。本地计数在多副本部署下等于配额放大 N 倍,而且这个值随扩缩容变化——表现是「压测时没事,扩容后配额不准」。
  2. 超限后优先降级,最后才拒绝。直接 429 会让用户在月底集体无法使用。正确的阶梯是告警 → 降级 → 拒绝,让用户有缓冲期且系统始终可用(只是变省)。
  3. 配额查询接口要对用户可见。用户不知道自己用了多少,就无法调整行为,只能在被拒时才知道——体验很差。做法是响应头带上 X-Quota-Used/Limit,并提供查询接口。

8. 延伸与锚点 ​