...
Back

从被拒绝的那一边看限流

令牌桶还是 GCRA,调用方其实不太在乎;他们在乎的是限流器说「不」的方式。一个看起来像故障、又不说何时能重试的拒绝,最后会变成重试、工单和糟糕的界面。

从被拒绝的那一边看限流

从被拒绝的那一边看限流 🚦

周二,Gabor Koos 发表了 Rate Limiting Without the Refill Loop: Token Bucket vs GCRA。我们读到它的这一周,恰好没少挨别人限流器的拒绝,于是读出了一点不同的味道。限流器内部存什么,是一个值得讨论的问题;但调用方永远看不到你的存储,他们看到的只有你的拒绝。


同一份额度的两种存法

两种算法约束的是同样两个参数:持续速率,和有上限的突发。文章的例子是每秒 100 次调用、突发 20。

令牌桶存两个值:余额,以及余额上次更新的时间。每来一个调用,先把这段时间攒下的额度加上去(封顶于突发上限),余额够一整个令牌就放行。

GCRA(Generic Cell Rate Algorithm,通用信元速率算法)只存一个时间戳:理论到达时间 TAT,也就是下一个调用在一条均匀排布的时间表上"应该"出现的位置。按这个速率,发射间隔是 10 ms。调用可以比时间表提前到,但提前量不能超过一个容差,容差是 burst - 1 个间隔,这里就是 190 ms。早于 TAT - tolerance 到达的调用被拒绝;被放行的调用,会把 TAT 设为"旧 TAT 与当前时间中较晚的那个"再加一个间隔。

文章接着证明,这其实是用两种单位写出来的同一个限流器:令牌余额等于突发上限减去"当前时间到 TAT 的距离"(以间隔计),把它代入令牌桶"至少有一个令牌"的判断,推出来的正是 GCRA 的放行条件。只要补充是连续的、参数和初始状态对应、每次调用只计一个单位、不退还也不预留,两者放行的流量完全一样。

真正的区别在记账。令牌桶要维护两个必须一起变化的值,外加一套充值策略,里面埋着精度陷阱。GCRA 只是对一个值做"比较并推进"。拒绝时的重试提示,也只是从它唯一存着的那个值上做一次减法:TAT - tolerance - now。一次满额突发之后 5 毫秒再来,答案就是 5 ms。令牌桶也能算出同一个数 —— 用缺的那一小截令牌除以速率 —— 只是要多推一步。不管哪种,这都只是"按当前状态最早可以重试的时间",不是预留。

作者的结论是:两者没有谁更正确,选哪个,取决于团队更能解释、更能运维哪种状态。我们同意。只是换到另一边看,要紧的是另一个问题。


这一周,另一边是什么样子

这一周我们往搜索引擎提交网址、查询它们的站长工具、抓取页面,前后收到了四种"不"。

Google Search Console 的"请求编入索引",每个资源每天大约能用 11 个网址。第 12 个请求会回答 "Quota exceeded … try again tomorrow"。它没说是谁的明天:我们在自己时区(UTC+8)第二天上午发的请求,照样被拒。还有一次,配额明明还没用完,一个请求只回了一句 "Something went wrong, please try again later";同一个网址过一会儿重试,就被接受了。配额拒绝和临时故障之所以看得出区别,纯粹是因为两句话的措辞碰巧不一样。

Bing Webmaster Tools:我们几个脚本并行查询它的时候,工具里每一个页面都开始返回一段 JSON 格式的 "too many requests"。我们只好停一阵子。至于停多久,它没说。

IndexNow:用新建的密钥第一次提交,会返回 403,直到搜索引擎从我们站点上抓到密钥文件为止;密钥写错了,同样是 403。光看状态码,分不清"等一分钟就好"和"你配错了",所以我们的提交脚本会把响应体一起留下。如果是新密钥,一分钟后重试就能成功。

Reddit 拒绝了我们的抓取:先是 429,换一个端点又是 403。

每一种都逼着我们去猜。而客户端一旦只能靠猜,结局无非三种:猛砸、放弃得太早,或者去提工单。


什么样的拒绝才读得懂

坐在调用方的位置上,一个好的拒绝有四个特征:

  • 独立的状态码。 限流就是 429,不要用一个同时也表示"永远不行"的 403,更不要是笼统的错误。
  • 机器可读的原因。 触发的是哪条限制 —— 按密钥、按 IP 还是按天 —— 写在脚本能解析的响应体里。
  • 何时重试。 以秒计的 Retry-After,或者带时区的绝对时间。"明天"不是一个时间。
  • 可发现的额度。 公开写明的数字,或者剩余额度,让客户端能自己把握节奏。

我们自己的限流器,把答案扔掉了

我们没资格说教。我们有一条登录流程会发短信和语音一次性验证码,设了按手机号、按 IP 和全局的每日上限,外加最短重发间隔。有一次测试人员触发了单个手机号的上限,app 显示的是"发送失败":服务端明明返回了 429,客户端却把它吞掉,变成和投递失败一模一样的提示。屏幕上没有任何一处告诉用户:等一等就好。

服务端这边,网站有一个共用的限流器,守着那些每次调用都要花钱的端点(短信、AI 聊天、语音合成),以及服务器就绪探测。它是存在 SQLite(Cloudflare D1)里的固定窗口,每个键一条原子 upsert,检查和更新在同一步里完成 —— 这也正是文章对两种算法共同的要求:

INSERT INTO rate_limits (key, count, reset_at)
VALUES (?1, 1, ?2)                         -- ?2 = now + window
ON CONFLICT (key) DO UPDATE SET
  count    = CASE WHEN rate_limits.reset_at <= ?3   -- ?3 = now
                  THEN 1 ELSE rate_limits.count + 1 END,
  reset_at = CASE WHEN rate_limits.reset_at <= ?3
                  THEN excluded.reset_at ELSE rate_limits.reset_at END
RETURNING count;

返回的计数已经包含本次请求,所以判断条件是 count > limit。被拦下的请求照样累加计数,但不会顺延 reset_at,所以一个不停重试的客户端不会被无限期封住。生产环境里,数据库不可用时它会失败即拒绝(fail closed):在花钱的端点上,限流器失灵的代价比误伤一次大得多。

毛病出在返回值上:它只返回一个布尔值。重置时间就躺在我们刚写进去的那一行里,却被丢掉了,所以我们的 429 带不出一个准确的 Retry-After。要改的地方并不多:

// upsert 末尾改成:RETURNING count, reset_at
let retryAfter = 0;
for (const rule of rules) {
  const { count, reset_at } = await upsert(rule, now);
  // 可能有多个键同时超限,调用方得等最晚重置的那个
  if (count > rule.limit) retryAfter = Math.max(retryAfter, reset_at - now);
}
return { limited: retryAfter > 0, retryAfter };
 
// 端点里
const { limited, retryAfter } = await checkLimits(rules);
if (limited) {
  return Response.json(
    { error: "rate_limited", retryAfter },
    { status: 429, headers: { "Retry-After": String(retryAfter) } },
  );
}

固定窗口比文章里的两种算法都粗糙。将来如果换成 GCRA,这件事也不会变难:存 TAT 代替计数和重置时间,重试提示就是 TAT - tolerance - now。


两边各一份清单

服务端:

  • 限流用 429;403 留给那些等多久都解决不了的问题。
  • 在脚本能读的响应体里写明触发的是哪条限制。
  • 让限流器返回重置时间或下一个可放行的时间,而不是一个布尔值,并把它放进 Retry-After。
  • 如果一定要说"明天",写明是哪个时区的明天。给秒数更好。

客户端:

  • 发之前先做预算:一天只有 11 个,第 12 个就是调度器的 bug。共用一份配额的活,串行着跑。
  • 遇到 429,至少等够服务端给的时间,然后重新检查。没有提示,就指数退避加抖动,并设一个截止时间。
  • 留下响应体;两种原因共用一个状态码时,只有它能把两者分开。
  • 永远不要把 429 吞成笼统的错误。显示"N 分钟后再试";这是唯一一种用户靠等待就能解决的失败。

算法决定谁能通过。拒绝决定其他所有人接下来做什么。