给 API 加限流,真正需要定下来的是三件事:用什么算法计数、计数放在哪里才是原子的、以及怎么把结果告诉客户端。前两件决定限流准不准,第三件决定调用方是能主动退让还是只能靠撞墙试探。响应头这块,IETF HTTPAPI 工作组的 RateLimitRateLimit-Policy 已经把格式定得相当清楚——注意它目前仍是 Internet-Draft,不是 RFC,落地时要按「可能还会变」来对待。

为什么要把配额写进响应头

不带配额信息的限流,客户端只能靠 429 来发现自己超了。这意味着每一次退让都以一次失败请求为代价,而且客户端无法区分「我快到上限了」和「我刚好撞上」。

结果是两种都不好的行为:保守的客户端把速率压得远低于实际配额,浪费你给出的容量;激进的客户端持续打到 429,把你的限流器变成了一个昂贵的忙等待。

把剩余配额和重置时间写进响应头,客户端就能在还没被拒之前主动放慢。这不是给调用方的礼物,是给你自己减负——被动的 429 重试风暴,最终消耗的是你的连接和 CPU。

RateLimit 与 RateLimit-Policy 怎么写

draft-ietf-httpapi-ratelimit-headers-11 由 HTTPAPI 工作组维护,当前版本发布于 2026 年 5 月 23 日,有效期到 2026 年 11 月 24 日。它是 Active Internet-Draft,尚未成为 RFC,这一点必须写在你的技术决策记录里:字段语义在标准化过程中仍可能调整。

两个头部都使用结构化字段(Structured Fields),形式是带参数的 Item 列表。

两个头部分工不同

RateLimit-Policy 描述服务端的配额策略——你允许多少、按什么窗口算。草案要求它「在一系列 HTTP 响应中保持一致」,也就是说它描述的是规则本身,不随每次请求跳变。

RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400

这里声明了两条并行策略:名为 burst 的每 60 秒 100 次,名为 daily 的每 86400 秒 1000 次。多条策略同时生效是常见需求——既要防瞬时突刺,又要控总量。

RateLimit 描述当前的服务限制,也就是此刻还剩多少:

RateLimit: "default";r=50;t=30

参数逐个说明

RateLimit-Policy 的参数:

参数 是否必需 含义
q 必需 分配的配额,非负整数
qu 可选 配额单位,默认 requests;允许 requestscontent-bytesconcurrent-requests
w 可选 时间窗口秒数,非负且非零整数
pk 可选 分区键,字节序列

qu 值得单独说。默认按请求数计,但草案明确允许按 content-bytes(内容字节数)和 concurrent-requests(并发请求数)计。如果你的接口是上传或大响应体,按字节计比按次数计更能反映真实成本;如果是长连接或流式接口,按并发数计才有意义。

RateLimit 的参数:

参数 是否必需 含义
r 必需 可用配额,非负整数
t 可选 该可用配额适用的有效窗口秒数
pk 可选 分区键,字节序列

关于 r 有一句话必须传达给你的客户端开发者,草案原文的措辞是:客户端不得假定正的可用配额就保证后续请求会被服务。换句话说,r=50 不是一张预付券。配额可能被同一分区里的其他调用方消耗,服务端也可能因为过载启用别的保护措施。客户端仍然必须正确处理 429。

和 Retry-After 的关系

草案对两者同时出现时的行为有明确规定,方向还不一样:

  • 服务端:同时返回 Retry-AfterRateLimit 时,Retry-After 的时间点不应早于有效窗口的结束时间。也就是说不要让客户端在配额还没恢复时就回来。
  • 客户端:同时收到两者时,Retry-After 必须优先。

实现时容易搞反:有人让 Retry-After 取一个比窗口短的值想让客户端早点回来试,结果制造了一轮必然失败的重试。

算法怎么选

算法 突发处理 内存 主要缺陷
固定窗口 最小 窗口边界可放行 2 倍配额
滑动窗口日志 精确 大,存每次请求时间戳 高 QPS 下内存和计算都吃紧
滑动窗口计数 近似值,边界有误差
令牌桶 可控允许突发 小,两个数 需要理解容量与填充率两个参数

固定窗口的边界问题值得展开,因为它是最常见的错误。配额设为每分钟 100,如果客户端在 12:00:59 发 100 次、12:01:00 再发 100 次,两次都合法,但在跨越边界的 2 秒内实际放行了 200 次——是你意图的两倍。对下游脆弱的服务来说,这个瞬时峰值往往就是压垮它的那一下。

令牌桶是多数场景的合理默认:桶容量决定允许多大的突发,填充率决定长期平均速率,两个参数分别对应两种真实需求。

在 Redis 上怎么做才是原子的

限流器天然是「读—改—写」,多实例部署时必须保证这个序列不可分割。

固定窗口:INCR 加 EXPIRE 的陷阱

最常见的写法是 INCREXPIRE

INCR ratelimit:user-42:1754400000
EXPIRE ratelimit:user-42:1754400000 60

问题在于这是两条命令。如果进程在 INCR 之后、EXPIRE 之前崩溃,这个键就永远不会过期——计数只增不减,那个用户被永久锁死。这类 bug 在测试里几乎不可能复现,只会在生产的偶发崩溃后出现,而且表现为「个别用户莫名被限流」,极难定位。

正确做法是让两步不可分割。Redis 的脚本执行是原子的,官方 Lua 脚本文档说明脚本在执行期间不会被其他命令打断:

-- KEYS[1] = 计数键, ARGV[1] = 上限, ARGV[2] = 窗口秒数
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return {0, 0, redis.call('TTL', KEYS[1])}
end
return {1, tonumber(ARGV[1]) - current, redis.call('TTL', KEYS[1])}

返回三个值:是否放行、剩余配额、距重置秒数——正好对应 RateLimitrt

令牌桶:为什么必须用脚本

令牌桶要读出上次填充时间和当前令牌数、按经过时间补充、判断是否够扣、再写回。这中间任何一步被打断,都可能让两个并发请求各自基于同一份旧状态做出「够扣」的判断,从而超发。

-- KEYS[1] = 桶键
-- ARGV[1] = 容量, ARGV[2] = 每秒填充速率, ARGV[3] = 当前时间(秒), ARGV[4] = 本次消耗
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)

local allowed = tokens >= cost
if allowed then tokens = tokens - cost end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 60)
return {allowed and 1 or 0, math.floor(tokens)}

两个细节。时间由调用方通过 ARGV 传入而不是在脚本里取,这样主从复制和 AOF 重放时行为一致——脚本里读系统时间会让同一段脚本在不同节点产生不同结果。过期时间设为「桶填满所需时间加余量」,让长期不活跃的键自动回收,避免键空间无限增长。

Redis 8.6 在原子操作和运维观测上都有变化,如果你正打算升级,XADD 幂等生产和 HOTKEYS 热键定位这些能力我们在 Redis 8.6 升级实战指南里单独写过——限流键很容易成为热键,HOTKEYS 正好用来确认。

429 该怎么返回

429 Too Many Requests 由 RFC 6585 第 4 节定义,Retry-After 的语义见 RFC 9110

一个完整的拒绝响应:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
RateLimit-Policy: "burst";q=100;w=60
RateLimit: "burst";r=0;t=23
Retry-After: 23

{"type":"https://example.com/problems/rate-limited","title":"Too Many Requests","status":429,"detail":"已超出每分钟 100 次的调用上限。"}

Retry-After: 23t=23 一致,符合草案「不早于有效窗口结束」的要求。响应体给出可读的原因,让接入方不用猜是哪条策略触发的。

需要注意的是,429 响应本身也要计入你的出口带宽和连接数。被限流的客户端如果不退让,你返回 429 的成本并不为零。对于持续超限的来源,在边缘层直接丢弃比在应用层生成完整响应更省资源。

分布式部署的现实问题

每实例限流不等于全局限流。 在内存里计数、部署了 4 个实例,实际放行量就是你配置值的 4 倍。要么把状态放进 Redis,要么明确接受这个倍数并按实例数分摊配额——后者在实例数会自动伸缩时不成立。

限流器不该成为单点。 Redis 不可用时,限流要么全放行(fail-open)要么全拒绝(fail-close)。这是个业务决策不是技术决策:对付费 API,fail-open 意味着被白嫖;对内部服务,fail-close 意味着自己造成一次故障。无论选哪个,都要显式写在代码里并加监控,而不是让它取决于异常恰好在哪一层被吞掉。

长连接需要单独考虑。 按请求数计的限流对 SSE 或 WebSocket 基本无效——建立一次连接就长期占用资源。这类接口应该按 concurrent-requests 计,或者对连接时长和消息速率单独设限。SSE 本身的连接管理、心跳与断线续传我们在 FastAPI SSE 生产实战里讨论过,限流维度的选择要和那套连接生命周期对齐。

分区键要选对。 按 IP 限流会误伤 NAT 后的整个办公室;按用户 ID 限流挡不住未登录的滥用;按 API key 限流最准确但只适用于已认证的调用。多数生产系统需要按分层组合,草案里的 pk 参数正是为了让服务端能告诉客户端当前配额是按哪个分区算的。

常见失败模式

限流器自己成了瓶颈。 每个请求都同步往 Redis 打一次往返。单次 1 毫秒看着不多,但它加在每一个请求上。高频接口可以考虑本地预扣一小批配额再批量同步,代价是精度下降。

时钟漂移导致窗口错位。 多实例各自用本地时间算窗口边界,机器间时钟差几百毫秒就会让边界不一致。把时间统一由 Redis 提供,或者接受近似并把窗口设得足够长。

重试放大。 客户端收到 429 立即重试,而且没有抖动,于是所有被拒的客户端在同一时刻一起回来。返回 Retry-After 并在文档里明确要求指数退避加随机抖动。

限流和幂等混为一谈。 限流控制的是频率,防的是重复提交的是幂等键。两者解决不同问题,webhook 这类需要防重放的场景要用签名和时间窗口,我们在 Webhook 签名与重放防护里单独写过那套机制。

配额改了但客户端不知道。 RateLimit-Policy 就是为此存在的。改配额时同步更新这个头,比发邮件通知接入方可靠得多。

上线检查清单

  • 计数状态放在共享存储,不是进程内存;如果是每实例限流,配额已按实例数分摊且实例数固定
  • 读—改—写序列通过 Lua 脚本或等价机制保证原子,不存在 INCREXPIRE 可能丢失的窗口
  • 脚本内不读系统时间,时间由调用方传入,保证主从与重放一致
  • 限流键设置了过期时间,键空间不会无限增长
  • Redis 不可用时的 fail-open / fail-close 行为是显式选择的,并有监控告警
  • 429 响应带 Retry-After,且其时间点不早于 RateLimitt 所指窗口结束
  • RateLimit-Policy 与实际配置一致,改配额时同步更新
  • 文档明确告知接入方:正的 r 不保证后续请求被服务,仍需处理 429
  • 长连接接口单独按并发数或时长限流,不混用按次计数
  • 分区键选择已评估 NAT 共享 IP、未认证流量等边界情况

常见问题

RateLimit 头部是标准吗? 目前不是。它是 IETF HTTPAPI 工作组的 Active Internet-Draft(当前 -11 版),尚未成为 RFC,字段语义仍可能调整。可以用,但要按「可能变更」管理,别写死在对外的兼容性承诺里。

必须同时返回两个头吗? 草案把它们定义为不同用途——RateLimit-Policy 描述规则、RateLimit 描述当前剩余。只返回后者客户端也能退让,但接入方无法预先知道配额规模。

X-RateLimit-* 这类旧头还能用吗? 大量现存 API 在用,客户端也普遍认识。过渡期同时返回新旧两套是务实做法,但要注意语义差异:旧头没有统一规范,不同服务的 X-RateLimit-Reset 有的是秒数有的是时间戳。

r=0 就一定会被拒吗? 表示当前分区配额已耗尽。但反过来不成立——草案明确说明正的 r 不保证后续请求被服务。

令牌桶和漏桶有什么区别? 令牌桶允许在桶满时一次性突发,漏桶强制恒定流出速率。要保护的下游能吃突发就用令牌桶,必须平滑就用漏桶。

应该在网关还是应用里限流? 能在边缘挡掉的就别进应用——429 响应本身也消耗资源。但按用户或按 API key 的精细配额通常需要应用层的身份信息。常见做法是边缘做粗粒度 IP 防护,应用做细粒度配额。