设计一个公开 API 平台的限流系统(参考 Stripe / GitHub)。全网 10 万+ 第三方集成方、峰值 1M QPS、单 key 上限 100 req/s、平台总容量 500k QPS。一次糟糕的脚本可以瞬间占满所有 worker,把付钱的请求挤掉——这就是为什么 限流是保命底线,不是「防爬虫」附加功能。
Retry-After 头要存在且准确。今天讲四件事:算法选型、分布式实现、精度 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 内返回
原理:四种算法各自回答一个不同的问题——允许突发吗?输出能匀速吗?窗口怎么滚?
| 算法 | 核心思想 | 允许突发 | 输出速率 | 实现复杂度 |
|---|---|---|---|---|
| Fixed Window | 每秒清零计数 | 否 | 不平滑 | 极低 |
| Sliding Window Log | 存所有时间戳精确滚动 | — | 精确 | 高(内存大) |
| Sliding Window Counter | 当前窗口 + 上窗口按比例外推 | — | 近似 | 低 |
| Token Bucket | 桶里放令牌,攒着可短时爆发 | ✓ 自然支持 | 有突发 | 低 |
| Leaky Bucket | 请求入队、按固定速率漏出 | 排队后匀速 | 严格匀速 | 中 |
| GCRA | 用「下一次允许时刻」单变量表达 leaky bucket | 可配置 | 滚动平滑 | 中(无队列) |
redis-cell、Cloudflare 工作流多用它。# 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
redis-cell 模块直接实现 GCRA,一条 CL.THROTTLE 命令搞定。原理:在一台机器上 token bucket 是平凡的;问题在于全网共享同一个 key 的配额时怎么协调。
总限额 / N。✅ 零依赖、低延迟;❌ 流量倾斜时 A 节点已满、B 节点空闲,整体利用率低;扩缩容时分配漂移。-- 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
原理:单一维度限不住真实流量。一个 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
X-Rate-Limit-Resource 头表明哪个桶在管。原理:限流不只是「拒绝」,是给 caller 一个机器可读的退避说明书。否则客户端只能死循环重试,把你二次打爆。
Retry-After: 30 — 必填,秒数或 HTTP-date。RFC 9110 标准。X-RateLimit-Limit: 100 — 配额。X-RateLimit-Remaining: 0 — 剩余。X-RateLimit-Reset: 1735689600 — 重置时间戳。X-RateLimit-Resource: search — 触发哪个桶(多桶时关键)。error_code + doc_url,让用户直接查文档。Retry-After: 30,30 秒后又同时打过来 —— thundering herd 重演。服务端应该返回带随机抖动的值(30 ± 5s),或客户端自己加 jitter(Stripe SDK、AWS SDK 都内置 exponential backoff + jitter)。
Retry-After;官方 SDK 内置 idempotency key + exponential backoff 自动重试。X-RateLimit-Reset(用 epoch),文档明示「等 reset 后重试,否则继续 403」。ThrottlingException,所有官方 SDK 自动 retry with jitter。key:0..15),每 shard 一份子配额,client 哈希落 shard。代价:精度损失(局部超额)。(1 - tokens) / refill_rate;sliding window 下 = 到下一秒还有几毫秒;并且服务端必须加 jitter。常见误解:「100 req/s 就设 cap=100, rate=100」。但 cap=100 意味着用户可以攒到 100 个令牌后瞬间发 100 个请求 —— 1ms 内的瞬时 QPS 就是 100,000,把下游打爆。
正确思考:cap 是 允许的突发大小,rate 是 长期平均速率。两者解耦:
Stripe 的做法是 「rate 是合同,cap 是仁慈」 —— cap 略大于 rate 让正常用户的偶尔 burst 不报错,但不至于让下游崩溃。极端场景(金融对账)甚至 cap = rate 让其严格匀速,等价 leaky bucket。
三层思路叠加:
N 秒额度(如 100 个令牌),本地消耗。用完后再申请下一批。Redis QPS 从 100w 降到 100w / 100 = 1w。代价:网关间分配不均、扩缩容时配额漂移。实战:本地黑名单 + 本地子配额 + Redis Cluster 三件套,Redis QPS 通常能压到 5-10 万。
30 + uniform(0, 30),把重试时刻打散成一个区间。代价:客户端有时等更久。hash(api_key),不同 user 的窗口边界天然错开。retry_after × random(0.5, 1.5),覆盖所有 caller。Stripe、AWS、Google SDK 都这么做。X-RateLimit-Remaining 已经接近 0,客户端 SDK 看到就主动放慢;不要等到 429 才反应。默认 fail-open:理由是 「限流是保护性措施,不应让限流故障变业务故障」。Stripe、AWS 都这么做。但 fail-open 意味着限流挂的瞬间,攻击流量直通后端 —— 必须搭配 circuit breaker 让下游能自我保护。
该走 fail-close 的场景:
实战分层:抗 DDoS 层 fail-open(保命);计费配额层 fail-close(保钱)。两层独立部署。
REST 的隐藏假设 —— 「每次请求成本差不多」 —— 在 GraphQL 下崩了。一个 nested query 可能扫几万行、跑十几次 join。
解法是「成本权重」:
同理适用于:批量 API(一次传 100 个 id)、文件上传(按字节扣)、AI inference(按 token 扣)—— 凡是「单请求成本差距 > 10 倍」的场景都需要权重。