限流器系统设计:算法与架构图

设计分布式限流器:令牌桶与滑动窗口、限流器放在哪里、Redis 计数器、429 响应,以及 Redis 宕机时怎么办。

· 创始人兼开发者

发布于

限流器系统设计:算法与架构图

每个公开的 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 客户端经过负载均衡器到达 API 网关。网关询问限流服务,限流服务用原子的 Lua 脚本更新 Redis 计数器。判断节点把未超限的请求发往 Orders API,其余请求返回带 Retry-After 的 429。计数上报到指标系统。

10 个组件 · 8 个连接

在 Archboard 中打开
图表内容
  • 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 中维护计数器,客户端超限时返回带 Retry-After 的 429,并上报放行和拒绝的数量。

这张图的读法如下:

  • 从左到右 是请求路径:客户端、负载均衡器、API 网关和限流服务。
  • 限流服务 拥有规则,每个请求做一次判断。它把计数器放在 Redis 中,用原子的 Lua 脚本更新。
  • 判断节点 把放行的请求发往 Orders API。其余请求收到带 Retry-After 的 429,永远到不了后端。
  • 指标 是异步上报的,所以画成虚线。客户说自己被拦住时,首先要看的就是按规则统计的拒绝数。
  • Markdown 块 按检查顺序列出规则,包括故障模式。

图上应该画什么

  • 规则及其键。 没有“按 API 密钥”或“按 IP”,“100/分钟”毫无意义。
  • 本地计数还是全局计数。 这一区别决定限额能否跨实例生效。
  • 故障模式。 失败开放还是失败关闭,应该画在图上,而不只写在代码里。
  • 拒绝路径。 标出 429 在哪里产生,免得有人以为后端能看到这些请求。

关于网关本身,API 网关架构图 介绍了路由、认证以及与限流器并行运行的其他过滤器。系统设计图示例 展示了限流器如何融入更大的设计。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 人员 表示客户端,再添加云节点包中的一个 云负载均衡器 和一个 API 网关。
  3. 添加 C4 模型节点包中的两个 容器 节点:限流服务和 Orders API。在限流服务下方添加数据节点包中的 Redis。
  4. 为放行或拒绝的分支添加流程图节点包中的一个 判断 和一个 开始 / 结束,再添加 DevOps 节点包中的 指标。
  5. 为连线加上标签,把指标连线画成虚线,再添加一个基础节点包中的 Markdown 块,写明规则。

这个示例共用 10 个图表节点,在 Free 套餐的限制之内。

常见问题

应该用哪种限流算法? 大多数 API 用令牌桶,因为它允许真实客户端产生的短时突发。如果你需要每个窗口内的严格限制,又不想付出完整请求日志的成本,就用滑动窗口计数器。

限流器应该是一个独立服务吗? 不一定。一个使用共享 Redis 的网关过滤器,对很多系统已经足够。当许多网关和服务共用同一套规则时,独立服务才更有帮助。

被限流的请求应该返回什么状态码? 429 Too Many Requests,并附上 Retry-After 头。不要用 503;它告诉客户端的是你的服务挂了,而不是他们请求太多。

请求经过很多台服务器时,如何按用户限流? 把计数器放在 Redis 这样的共享存储中,以用户或 API 密钥为键,并原子地更新。每台服务器各自的本地计数器只能当作粗略的上限。

限流器本身出故障会怎样? 提前做好选择。普通的产品限额失败开放,成本高或容易被滥用的操作失败关闭,两种情况都要告警。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。