Day 10 Medium Rate Limiting Token Bucket / GCRA Distributed Counters

Rate Limiting — 不只是「每秒 100 次」Token Bucket vs Leaky Bucket vs Sliding Window · Distributed Counters · The 429 Contract

问题场景与约束

设计一个公开 API 平台的限流系统(参考 Stripe / GitHub)。全网 10 万+ 第三方集成方峰值 1M QPS、单 key 上限 100 req/s、平台总容量 500k QPS。一次糟糕的脚本可以瞬间占满所有 worker,把付钱的请求挤掉——这就是为什么 限流是保命底线,不是「防爬虫」附加功能。

今天讲四件事:算法选型、分布式实现、精度 vs 性能、429 契约

高层架构

graph LR
    C["Client / 第三方应用"]
    GW["API Gateway
(Envoy / Kong / 自研)"] L1["本地令牌桶
(per worker)"] L2["集中计数
Redis + Lua"] SHED["Load Shedder
(优先级丢弃)"] APP["业务服务"] C -->|"req + API key"| GW GW -->|"①快路径: 90% 拒绝/放行"| L1 L1 -->|"②慢路径: 周期同步"| L2 GW -.过载.-> SHED GW -->|✓| APP GW -.->|"✗ 429 + Retry-After"| C classDef edge fill:#0e2030,stroke:#64c8ff,color:#e8eef5 classDef store fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef origin fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class GW,L1 edge class L2 store class APP,SHED origin

网关用本地令牌桶扛快路径,Redis 做集中真值,决策永远在 1ms 内返回

关键技术点

1. 算法选型:Token Bucket vs Leaky Bucket vs Sliding Window vs GCRA

原理:四种算法各自回答一个不同的问题——允许突发吗?输出能匀速吗?窗口怎么滚?

算法核心思想允许突发输出速率实现复杂度
Fixed Window每秒清零计数不平滑极低
Sliding Window Log存所有时间戳精确滚动精确高(内存大)
Sliding Window Counter当前窗口 + 上窗口按比例外推近似
Token Bucket桶里放令牌,攒着可短时爆发✓ 自然支持有突发
Leaky Bucket请求入队、按固定速率漏出排队后匀速严格匀速
GCRA用「下一次允许时刻」单变量表达 leaky bucket可配置滚动平滑中(无队列)
Trade-off:
# Token Bucket 核心 (Python 伪代码)
def allow(key, capacity, refill_rate):
    now = time.time()
    tokens, last = state.get(key, (capacity, now))
    # 懒计算:本次取令牌时,按经过时间补充
    tokens = min(capacity, tokens + (now - last) * refill_rate)
    if tokens >= 1:
        state[key] = (tokens - 1, now)
        return True
    state[key] = (tokens, now)
    return False   # 拒绝, retry_after = (1 - tokens) / refill_rate
现实案例:

2. 分布式限流:Redis 中心化 vs 本地分摊 vs 异步聚合

原理:在一台机器上 token bucket 是平凡的;问题在于全网共享同一个 key 的配额时怎么协调。

Trade-off:
-- Redis 原子 token bucket (Lua, Stripe 风格)
-- KEYS[1]=桶 key  ARGV={capacity, refill_rate, now, cost}
local cap, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]),
                              tonumber(ARGV[3]), tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or cap
local last = tonumber(data[2]) or now
tokens = math.min(cap, tokens + (now - last) * rate)
local allowed = tokens >= cost and 1 or 0
if allowed == 1 then tokens = tokens - cost end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) * 2)
return {allowed, tokens}     -- 原子,无 race
现实案例:

3. 多维度限流与优先级抛弃

原理:单一维度限不住真实流量。一个 key 可能正常,但所有 key 加起来打爆 endpoint;某 endpoint 正常但下游 DB 挂了。要按 多个维度同时检查、最严的胜出

典型维度组合(每层独立桶,全过才放行):
# 多维度组合 (伪代码)
def admit(req):
    keys = [
        ("key", req.api_key, 100, 1),
        ("ip", req.ip, 10, 1),
        ("ep", f"{req.api_key}:{req.path}", endpoint_limit(req.path), 1),
    ]
    for kind, k, cap, cost in keys:
        if not bucket_allow(f"{kind}:{k}", cap, refill_for(cap), cost):
            return reject(retry_after_from(cap, k), reason=kind)
    if global_inflight() > SHED_THRESHOLD and priority(req) < HIGH:
        return reject(retry_after=30, reason="overload")
    return ALLOW
现实案例:

4. 429 响应契约:让 caller 优雅退避

原理:限流不只是「拒绝」,是给 caller 一个机器可读的退避说明书。否则客户端只能死循环重试,把你二次打爆。

标准响应头:
关键陷阱:客户端必须做 jitter。1000 个客户端都收到 Retry-After: 30,30 秒后又同时打过来 —— thundering herd 重演。服务端应该返回带随机抖动的值(30 ± 5s),或客户端自己加 jitter(Stripe SDK、AWS SDK 都内置 exponential backoff + jitter)。
现实案例:

扩展与优化

常见陷阱 / 面试追问

  1. 「为什么不用 sliding window log?」 — 内存代价。1M user × 100 req = 1 亿条 timestamp ≈ 几 GB;用 counter 近似就够,误差 < 5%。
  2. 「Redis 挂了怎么办?」 — fail-open + circuit breaker;Stripe 工程博客明确把这作为铁律。
  3. 「同一 user 多个进程并发请求,本地 token bucket 怎么共享?」 — 进程内桶不行,必须用 Redis;或用「本地子配额 + 异步对账」。
  4. 「429 vs 503,何时用哪个?」 — 429 = 客户端超限(用户自己的问题);503 = 服务过载(你的问题)。错用会让客户端的重试策略错。
  5. 「Retry-After 怎么算?」 — token bucket 下 = (1 - tokens) / refill_rate;sliding window 下 = 到下一秒还有几毫秒;并且服务端必须加 jitter。
  6. 「为什么 token bucket 比 leaky bucket 更主流?」 — 公开 API 客户端往往天然「攒着发」(cron job 一次发 50 个),token bucket 允许这种合理 burst;leaky bucket 严格匀速反而对正常用户不友好。

深入资源

深入思考(点击展开思路)

1. Token bucket 的 capacity 与 refill rate 设成什么关系?capacity = refill 是否合理?

常见误解:「100 req/s 就设 cap=100, rate=100」。但 cap=100 意味着用户可以攒到 100 个令牌后瞬间发 100 个请求 —— 1ms 内的瞬时 QPS 就是 100,000,把下游打爆。

正确思考:cap 是 允许的突发大小,rate 是 长期平均速率。两者解耦:

  • 下游能扛多大瞬时 burst?→ 决定 cap。常见 cap = rate × 1~3(允许 1-3 秒的突发)。
  • 给用户的「日均配额」是多少?→ 决定 rate。

Stripe 的做法是 「rate 是合同,cap 是仁慈」 —— cap 略大于 rate 让正常用户的偶尔 burst 不报错,但不至于让下游崩溃。极端场景(金融对账)甚至 cap = rate 让其严格匀速,等价 leaky bucket。

2. 1M QPS 全过 Redis 不可行。如果只用 1 个 Redis 节点(约 30 万 QPS),怎么把每秒 100 万限流决策塞进去?

三层思路叠加:

  • 本地快速拒绝:每个网关节点本地存一份「最近超限的 key」黑名单(TTL 100ms),超限 key 不去 Redis 直接 429。热点 key 99% 流量挡在本地。
  • 本地子配额:每个网关启动时向 Redis 一次性申请 N 秒额度(如 100 个令牌),本地消耗。用完后再申请下一批。Redis QPS 从 100w 降到 100w / 100 = 1w。代价:网关间分配不均、扩缩容时配额漂移。
  • Redis Cluster 分片:按 user_id hash 到不同 shard,每个 shard 承担 30w QPS。代价:跨 shard 的全局总配额不能用单 key 表达。

实战:本地黑名单 + 本地子配额 + Redis Cluster 三件套,Redis QPS 通常能压到 5-10 万。

3. 客户端集体收到 429 + Retry-After=30s 后同时重试,造成二次过载。服务端可以怎么做避免?
  • 服务端返回 jittered Retry-After:不是固定 30,而是 30 + uniform(0, 30),把重试时刻打散成一个区间。代价:客户端有时等更久。
  • 不同客户端给不同 reset 时刻:bucket 的过期时间锚定到 hash(api_key),不同 user 的窗口边界天然错开。
  • 客户端 SDK 强制 backoff + jitter:官方 SDK 内置 retry_after × random(0.5, 1.5),覆盖所有 caller。Stripe、AWS、Google SDK 都这么做。
  • 限流回包带「软提示」:在还没真触发 429 时,X-RateLimit-Remaining 已经接近 0,客户端 SDK 看到就主动放慢;不要等到 429 才反应。
  • 令牌的「逐渐恢复」语义:不要让所有 user 在 reset 那一刻同时恢复满血,而是 GCRA 那种连续滚动,重试时刻天然打散。
4. 限流系统自己挂了:fail-open 放行还是 fail-close 拒绝?哪种业务该走哪条?

默认 fail-open:理由是 「限流是保护性措施,不应让限流故障变业务故障」。Stripe、AWS 都这么做。但 fail-open 意味着限流挂的瞬间,攻击流量直通后端 —— 必须搭配 circuit breaker 让下游能自我保护。

该走 fail-close 的场景

  • 付费配额 / 计费配额:超额 = 公司亏钱(如「免费层每月 1000 调用」),宁可拒也不能放。
  • 下游有不可逆操作:如发短信 / 扣款,无脑放行可能引发批量误操作。
  • 合规要求:金融、医疗的某些限流是监管硬要求,挂掉必须停服。

实战分层:抗 DDoS 层 fail-open(保命);计费配额层 fail-close(保钱)。两层独立部署。

5. GraphQL 一次 query 可能消耗 100 倍的 CPU,怎么定义「一次请求」用于限流?

REST 的隐藏假设 —— 「每次请求成本差不多」 —— 在 GraphQL 下崩了。一个 nested query 可能扫几万行、跑十几次 join。

解法是「成本权重」

  • 静态成本:query 提交时静态分析每个字段的 cost(list 字段 × 假设返回 100 条),算出总分,token bucket 一次扣这么多分。GitHub GraphQL API 就这么做,每小时 5000 「点数」而不是 5000 请求。
  • 动态成本:请求执行完后按实际耗时 / 行数补扣令牌。代价:扣分滞后,恶意 query 已经把 CPU 烧完了。
  • query 深度 / 字段数硬限:在限流之前先在 query 解析阶段拒绝超过 depth 10 / 字段 1000 的查询。Apollo 自带支持。
  • 持久化 query(persisted queries):只允许预审过的 query hash 通过,把不可控的动态 query 拒之门外。Facebook、Shopify 大规模这样做。

同理适用于:批量 API(一次传 100 个 id)、文件上传(按字节扣)、AI inference(按 token 扣)—— 凡是「单请求成本差距 > 10 倍」的场景都需要权重。