每个公开的 API 都需要限流器,而大多数团队发现这一点的方式都一样:一个带重试循环的客户端,或者一个爬虫,把服务拖垮,所有人都用不了。限流器限制单个密钥、用户或 IP 能发出的请求量,这样一个捣乱者让你付出的代价只是一个 429,而不是一次故障。
设计问题总是那几个:用哪种算法,限流器放在哪里,计数器存在哪里,计数器存储宕机时会发生什么。本文逐一回答,最后给出一张部署在 API 前面的分布式限流器架构图。
限流器必须做到什么
- 对每个请求,在几毫秒内判断是否放行。
- 对不同调用方使用不同规则:按 API 密钥、按用户、按 IP、按路由。
- 无论哪个服务器实例处理请求,行为都一致。
- 清楚地告诉客户端何时被限流、何时可以重试。
- 自身存储出问题时,要么继续工作,要么以可预期的方式失败。
第三点让它成为一个分布式系统问题。如果十个网关实例各自数自己的 100 次,“每分钟 100 个请求”这个限制就毫无意义。
五种算法
| 算法 | 计数方式 | 优点 | 缺点 |
|---|---|---|---|
| 令牌桶 | 桶以固定速率补充令牌,每个请求取走一个令牌 | 允许不超过桶容量的短时突发 | 有两个参数需要调整 |
| 漏桶 | 请求从队列中以固定速率流出 | 输出速率平滑 | 突发请求在队列中等待,增加延迟 |
| 固定窗口计数器 | 每个密钥每分钟一个计数器 | 最便宜:只需一次自增 | 在窗口边界附近可能放过两倍的限额 |
| 滑动窗口日志 | 为每个请求保存一个时间戳 | 精确 | 内存随流量增长 |
| 滑动窗口计数器 | 按重叠比例对上一个窗口的计数加权 | 接近精确,每个密钥两个计数器 | 只是近似值 |
固定窗口的问题很容易看出来:客户端在 12:00:59 发送 100 个请求,又在 12:01:00 发送 100 个,两个窗口都会接受。对于每分钟 100 个的限制,这相当于两秒内放过了 200 个请求。
令牌桶通常是 API 的默认选择,因为突发本来就是客户端的正常行为。“每分钟 100 个请求,突发 20 个”这样的限制可以直接对应到补充速率和桶容量。Stripe 在 用限流器扩展 API 一文中介绍了他们在 Redis 上运行令牌桶限流器的做法,同时还用一个单独的限流器限制并发请求。
如果你更想要每个窗口内的硬性限制,滑动窗口计数器是消除边界问题的低成本方案。
限流器放在哪里
限流可以在三个地方执行,很多系统会同时用到不止一处:
- 边缘(CDN 或 WAF)。 按 IP 做粗粒度限制,在洪峰到达你之前把它吸收掉。
- API 网关。 需要知道调用方是谁的按密钥、按路由限制。大多数产品级限额都放在这里。
- 服务内部。 依赖业务数据的限制,例如“每个账号最多两个并发导出任务”。
网关可以在本地计数(每个实例有自己的计数器),也可以全局计数(所有实例共享一个存储)。本地限流没有额外成本,足以提供粗粒度保护;把限额除以实例数,接受其中的误差即可。客户付费购买的产品限额则需要全局存储。比如 Envoy 两种都支持:一个本地限流过滤器,以及一个调用外部限流服务(由 Redis 支撑)的全局模式。
用 Redis 计数
全局计数器通常选用 Redis:它很快,把计数器放在内存里,还能自动让键过期。陷阱在于读取计数器和写回之间的竞争。两个网关都读到 99,都放行请求,又都写回 100。
让每一次判断都是原子的。对于固定窗口,在新键上先 INCR 再 EXPIRE 就够了;Redis 的 INCR 文档 展示了基于它的限流模式。对于令牌桶,运行一个小的 Lua 脚本,一步完成读取桶、按经过的时间补充、取走一个令牌、写回。Redis 执行脚本时不会穿插其他命令,所以并发请求看到的桶始终一致。
-- KEYS[1] = bucket key, ARGV = rate per second, capacity, now (ms), cost
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local rate, cap, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local tokens = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000))
return allowed and 1 or 0
时间要么由调用方传入,要么使用 Redis 的 TIME 命令;混用许多网关各自的时钟,是出现怪异结果的常见原因。如果同一个 Redis 还兼作缓存,要注意它的淘汰策略:一个桶被淘汰,就等于限额被重置。缓存策略一文 介绍了淘汰相关的设置。
告知客户端
对被限流的请求,返回 RFC 6585 中定义的 429 Too Many Requests,并附上 Retry-After 头,写明需要等待的秒数。规矩的客户端会退避;其他客户端至少能收到一个明确的信号。IETF 的一份草案《RateLimit header fields for HTTP》提出了一组标准头,让客户端在触及限额之前就知道自己的配额和剩余额度;截至本文写作时(2026 年 10 月)它仍是草案,所以其中的头名称请视为暂定。
把限额写进文档。客户端被拦住之后才发现的限额,最后会变成一张工单。
Redis 宕机时
在事情发生之前就做好决定:
- 失败开放(fail open)。 限流器无法判断时放行请求。API 继续可用,但在此期间失去保护。产品级限额通常这样选。
- 失败关闭(fail closed)。 拒绝请求。适用于登录尝试、发送短信这类成本高或容易被滥用的操作。
- 退回本地限流。 在存储恢复之前,每个网关执行一个粗粒度的本地限额。
无论选哪种,都要为它设置告警。一个悄无声息地失败开放了一整周的限流器,就不能算限流器。
架构图
图表内容
- API clients
- Load balancer
- API gateway
- Rate limit service
- Redis: counters
- Under the limit?
- Orders API
- 429 + Retry-After
- Metrics
- key + route
- atomic Lua script
- yes
- no
- allowed vs rejected
这张图的读法如下:
- 从左到右 是请求路径:客户端、负载均衡器、API 网关和限流服务。
- 限流服务 拥有规则,每个请求做一次判断。它把计数器放在 Redis 中,用原子的 Lua 脚本更新。
- 判断节点 把放行的请求发往 Orders API。其余请求收到带 Retry-After 的 429,永远到不了后端。
- 指标 是异步上报的,所以画成虚线。客户说自己被拦住时,首先要看的就是按规则统计的拒绝数。
- Markdown 块 按检查顺序列出规则,包括故障模式。
图上应该画什么
- 规则及其键。 没有“按 API 密钥”或“按 IP”,“100/分钟”毫无意义。
- 本地计数还是全局计数。 这一区别决定限额能否跨实例生效。
- 故障模式。 失败开放还是失败关闭,应该画在图上,而不只写在代码里。
- 拒绝路径。 标出 429 在哪里产生,免得有人以为后端能看到这些请求。
关于网关本身,API 网关架构图 介绍了路由、认证以及与限流器并行运行的其他过滤器。系统设计图示例 展示了限流器如何融入更大的设计。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 人员 表示客户端,再添加云节点包中的一个 云负载均衡器 和一个 API 网关。
- 添加 C4 模型节点包中的两个 容器 节点:限流服务和 Orders API。在限流服务下方添加数据节点包中的 Redis。
- 为放行或拒绝的分支添加流程图节点包中的一个 判断 和一个 开始 / 结束,再添加 DevOps 节点包中的 指标。
- 为连线加上标签,把指标连线画成虚线,再添加一个基础节点包中的 Markdown 块,写明规则。
这个示例共用 10 个图表节点,在 Free 套餐的限制之内。
常见问题
应该用哪种限流算法? 大多数 API 用令牌桶,因为它允许真实客户端产生的短时突发。如果你需要每个窗口内的严格限制,又不想付出完整请求日志的成本,就用滑动窗口计数器。
限流器应该是一个独立服务吗? 不一定。一个使用共享 Redis 的网关过滤器,对很多系统已经足够。当许多网关和服务共用同一套规则时,独立服务才更有帮助。
被限流的请求应该返回什么状态码? 429 Too Many Requests,并附上 Retry-After 头。不要用 503;它告诉客户端的是你的服务挂了,而不是他们请求太多。
请求经过很多台服务器时,如何按用户限流? 把计数器放在 Redis 这样的共享存储中,以用户或 API 密钥为键,并原子地更新。每台服务器各自的本地计数器只能当作粗略的上限。
限流器本身出故障会怎样? 提前做好选择。普通的产品限额失败开放,成本高或容易被滥用的操作失败关闭,两种情况都要告警。