限流:四种算法,两种哲学
限流要在一串请求里决定:哪些放行、哪些等、哪些拒——让某种资源(CPU、下游服务、配额、人的收件箱)以它能承受的速率被消耗。每个设计要回答三个问题:按什么计数(按 IP、按用户、按 API key、按收件人——计数键决定谁共用一份预算)、预算怎么回充、超出的怎么办。
固定窗口。 在离散的时间桶里计数(这一分钟、这一小时)。超限即拒。实现最便宜(每个键、每个桶一个计数器),也最易推理。它公认的洞在边界:客户端可以踩着一个窗口的尾巴打满配额、再踩着下一个窗口的头再打满,瞬时速率翻倍。
滑动窗口。 按"到此刻为止的滚动时长"计数,没有固定边界可踩。两种实现:存时间戳日志(精确,但内存随流量涨),或加权估算——上一个窗口的计数按其与滚动窗口的重叠比例加权,再加上当前窗口的计数(近似,常量内存)。滑动窗口就是把边界洞堵上的固定窗口,代价是内存或精确度二选一。
漏桶。 请求进队列,队列以恒定速率往外漏。超出的不拒,而是延后(到队列满为止)。无论入流多突发,出流绝对平滑。这是整形(shaping):保护那些被突发本身伤到的东西——脆弱的下游、有严格 QPS 上限的第三方 API。
令牌桶。 桶里最多存 N 个令牌,以恒定速率回充;每个请求花令牌,够就放行。长期速率被回充速率封顶,但攒下的额度可以一次性花掉:桶深以内的突发即时通过。这是带突发额度的准入——漏桶的对偶。(两者在计量上等价:一边的水位就是另一边容量减令牌余额,GCRA 的表述把这个等价写成了精确形式。行为差别在突发去哪:令牌桶让它当场通过,排队版漏桶把它换成等待。)
底下的两种哲学。 管制(policing)把超出的丢掉或拒绝(固定/滑动窗口、令牌桶)——调用方立刻知道,通常是一个带 Retry-After 的 429。整形(shaping)把超出的延后(漏桶、任务层 snooze)——什么都不拒,但由延迟吸收突发。选哪个取决于:调用方能不能利用一次拒绝(稍后重试、退避),还是拒绝会丢掉不可替代的东西(一封排队的邮件、一份已付费的任务)。
失败策略是设计的一部分,不是事后补丁。 限流器依赖一个计数存储;存储够不着时它必须二选一:fail-open(放行——可用性优先,适合公开读取面)或 fail-closed(全拒——防护优先,适合登录这类爆破目标)。没想过这一条的限流器,等于已经替你随机选了。
难的是分布式计数,不是算法。 请求落在许多台机器上时:集中计数精确,但每次请求多一个往返(还多一个依赖);各节点本地计数快,但只是对全局上限的近似(N 台 × 本地限额 ≠ 全局限额)。真实系统通常在小而关键的安全面上用精确计数,在大流量面上用近似。算法之间也会叠用:上面一层粗粒度的长窗口配额、下面一层细的短窗口或令牌桶——每层回答不同的问题:总量多少、以什么节奏、允许多大突发。