跳转到内容
← 返回算法
系统算法计算机科学 · 分布式系统 · 网络14 分钟阅读

限流算法

Rate Limiting Algorithms

你写了一个公开的 API,某天凌晨流量突然暴涨——可能是真实的爆款,也可能是某个客户端写错代码陷入死循环、每秒打来上万次请求,还可能是恶意攻击。如果什么都不做,你的服务器会被压垮,连正常用户也一起遭殃。限流(rate limiting) 就是给请求装上一个阀门:超过约定速率的请求被拒绝或排队,从而保护系统、保证公平。 …

限流令牌桶漏桶滑动窗口流量控制

你写了一个公开的 API,某天凌晨流量突然暴涨——可能是真实的爆款,也可能是某个客户端写错代码陷入死循环、每秒打来上万次请求,还可能是恶意攻击。如果什么都不做,你的服务器会被压垮,连正常用户也一起遭殃。限流(rate limiting) 就是给请求装上一个阀门:超过约定速率的请求被拒绝或排队,从而保护系统、保证公平。

这件事听起来简单——"每秒最多放 100 个请求进来"——但"怎么数这 100 个",背后是几种各有脾气的算法。

破除误解:限流不只是"计数器加一"

最朴素的想法是:维护一个计数器,每来一个请求加一,到 100 就拒绝,每秒清零。这就是固定窗口(Fixed Window)算法,它简单,但有一个尴尬的边界漏洞:

假设限制是"每分钟 100 次"。如果用户在 12:00:59 打了 100 次、又在 12:01:00 打了 100 次,这两批请求其实集中在一秒内,总共 200 次——但因为它们落在两个不同的"分钟窗口"里,固定窗口算法全部放行。窗口边界处的瞬时流量,可能达到限额的两倍。

这说明:限流的难点不在"数数",而在如何定义"一段时间"。下面几种算法,本质上是对这个问题的不同回答。

令牌桶:宽容突发的主流方案

令牌桶(Token Bucket) 是工业界最常用的算法,思路像一个漏水的水龙头往桶里滴水:

  • 系统以固定速率(比如每秒 100 个)往桶里放"令牌",桶有最大容量上限。
  • 每来一个请求,就从桶里取走一个令牌;取得到就放行,取不到(桶空了)就拒绝。

它的妙处在于:当一段时间没有请求时,令牌会在桶里(直到桶满)。于是当突发流量到来时,桶里囤积的令牌能让这一波请求一次性通过——只要不超过桶容量。这恰好契合真实世界的流量特征:API 调用天然是"一阵一阵"的,平时空闲、偶尔猛冲。令牌桶在"平均速率严格"和"容忍短时突发"之间取得了平衡。

正因如此,Stripe、GitHub、AWS API Gateway 等大量公开 API 都用令牌桶或其变体。例如 AWS API Gateway 在主要区域提供"稳定 10000 RPS + 一个 5000 请求的突发额度",这正是令牌桶"稳定补充速率 + 桶容量"的直接体现。

漏桶:强制平滑的输出

漏桶(Leaky Bucket) 看起来和令牌桶相似,方向却相反。请求像水一样倒进一个桶,桶以恒定速率从底部漏出(被处理);桶满了,新来的请求就溢出(被丢弃)。

关键区别:令牌桶允许突发通过(攒下的令牌可一次用掉),漏桶则强制输出永远平滑——无论入口多猛,出口速率恒定。所以漏桶更适合需要保护下游、要求平稳输出的场景(如流量整形 traffic shaping),而令牌桶更适合面向用户、希望对偶发高峰友好的 API 限流。

把两种桶放在一起看,语义差异可以精确到一个问题:攒下来的额度,允许多快用掉?

  • 令牌桶的回答:任意快。桶里攒了 $B$ 个令牌,就允许一瞬间放行 $B$ 个请求。它约束的只是长期平均速率 $r$,突发上限由桶容量 $B$ 决定——两个参数,各管一件事。
  • 漏桶的回答:永远只能以速率 $r$。攒下的不是"额度"而是"排队的请求",出口被钉死在恒定速率上,突发被吸收进队列(以及排队延迟),而不是被放行。

所以这不是同一个东西的两种实现,而是两种策略:令牌桶把突发的成本留给下游承担,换取用户侧的响应性;漏桶把成本转成队列里的等待,换取下游的确定性。选择哪个,取决于你想保护谁。顺带一提,GCRA 的两个参数与令牌桶严丝合缝:$T = 1/r$ 管平均速率,τ\tau 管突发容忍——它正是令牌桶的无状态记账法。

滑动窗口:消除边界漏洞

为了解决固定窗口的边界突刺,有两种"滑动窗口"思路:

  • 滑动窗口日志(Sliding Window Log):记录每个请求的精确时间戳,每次判断时只数"过去 60 秒内"的请求数。完全精确,但要存下每个时间戳,内存开销大。
  • 滑动窗口计数(Sliding Window Counter):一种近似折中。它结合当前窗口与上一个窗口的计数,按当前时间在窗口中的位置加权估算。内存小、精度够用,是很多生产系统的默认选择。

还有一个优雅的现代变体 GCRA(通用信元速率算法,Generic Cell Rate Algorithm),出自 1996 年 ATM 论坛的流量管理规范。它只为每个用户存一个时间戳,却等价实现了漏桶。

设允许的间隔为 $T$(速率的倒数),容忍度为 τ\tau。GCRA 维护一个"理论到达时间"(TAT,Theoretical Arrival Time)——按当前速率,下一个请求"应该"到达的时刻。新请求在时刻 $t$ 到达时:

  • t<TATτt < \text{TAT} - \tau:来得太早,连容忍度都兜不住,拒绝。
  • 否则放行,并更新 TAT=max(t,TAT)+T\text{TAT} = \max(t, \text{TAT}) + T

直觉是:TAT 就是漏桶中"水位排空的时刻"。请求提前到达相当于水位上涨,TAT 相应后推;空闲时 TAT 停在原地,容忍度 τ\tau 就是允许提前的那部分额度。无需后台滴水进程、无需请求日志,一次比较加一次更新,$O(1)$ 时间与 $O(1)$ 空间——Redis 生态的限流模块、nginx 的限流实现大量采用它。

难点:分布式限流

上面的算法在单台机器上都好办,难的是当你有几十台服务器、它们共同为同一个用户服务时——"这个用户每秒最多 100 次"这个限制,必须在所有机器之间共享

如果每台机器各自计数、互不通信,那么 10 台机器就会把限额放大 10 倍(每台都允许 100)。解决方案通常是把计数状态集中存在一个共享存储里(如 Redis),用原子操作增减。这里有一个容易踩的坑:"先读计数、判断、再加一"这三步必须打包成一个原子操作(Redis 里常用一段 Lua 脚本完成),否则两台机器可能同时读到 99/100、各自放行,实际放进第 101 个请求——竞态条件下,限额被静默突破。但中心化又带来新的取舍:每个请求都去查一次中心存储,增加了延迟,且中心存储本身成了瓶颈和单点。于是出现各种折中:本地预扣一小批配额、定期与中心对账,用一点点精度换取性能。绝对精确的分布式限流,本身就和"低延迟、高可用"相冲突——这又是分布式系统里反复出现的根本权衡。

协议层:429、Retry-After 与客户端礼仪

限流只有一半发生在服务器,另一半发生在协议与客户端行为里。

HTTP 在 2012 年的 RFC 6585 中正式定义了 429 Too Many Requests 状态码,并建议配合 Retry-After 响应头告诉客户端"多久之后再来"。在此之前,被限流的客户端只能收到语义含糊的 403 或 503,无法区分"你被限流了"和"你没权限/服务挂了"。

客户端一侧的正确姿势是指数退避加抖动(exponential backoff with jitter):第一次被拒等 1 秒,然后 2 秒、4 秒……逐次翻倍,但每次的实际等待再乘上一个随机因子。抖动不是装饰——如果所有被限流的客户端掐着同一时刻重试,会形成"惊群"式的同步重试波峰,把刚缓过来的服务器再次打垮。随机化把重试打散到时间上,是分布式系统里消解同步震荡的通用手段。

从限流到负载卸载:并发数才是容量

上面的算法都在回答"单位时间放进来多少"。但系统真正的容量约束往往不是速率,而是同时在处理多少请求——内存、线程、数据库连接都被在途请求占用。排队论里的 Little 定律(John Little,1961)给出了两者的换算:在途请求数 = 到达速率 × 平均处理延迟。速率不变而延迟翻倍,并发占用就翻倍。这就是"慢"比"多"更危险的原因:依赖方变慢时,固定速率的限流器依然照常放行,系统却在被不断堆积的在途请求拖垮。

由此发展出自适应并发限制:不预设固定阈值,而是像 TCP 拥塞控制那样实时探测。Netflix 开源的 concurrency-limits 库(2017 年发布)借鉴了 TCP Vegas(1994)的思路——Vegas 用"实测吞吐与预期吞吐之差"推断网络排队深度,并发限制器则跟踪当前延迟相对历史最低延迟的膨胀程度:延迟开始膨胀,说明队列在堆积,就收紧并发上限;延迟回落,再逐步放开。限流的目标从"执行一个配额"变成"实时寻找系统的拐点"。

再退一步是负载卸载(load shedding):系统逼近崩溃时,不再精细区分谁超额,而是按优先级成批丢弃请求——先丢低优先级的批处理,保住核心交互——用可控的拒绝换取整体的存活。限流、并发限制、负载卸载,构成过载保护由细到粗的三层防线。

代价与争议

  • 拒绝 vs. 排队:超额请求是直接拒绝(返回 HTTP 429 "Too Many Requests"),还是排队等待?拒绝快但用户体验差,排队友好但可能让队列堆积、延迟失控。
  • 限谁:按 IP 限流会误伤共享同一出口 IP 的整个公司或校园;按 API key 限流更精准,但匿名接口没有 key 可用。没有放之四海皆准的"限流维度"。
  • 限流也可能是攻击面:如果攻击者能伪造他人的身份标识,就能借限流机制把正常用户"限"到无法访问——限流本意是防护,用错了反而成了拒绝服务的帮凶。

跨域连接

  • 负载均衡:两者管的是相邻两个问题——放多少进来,和放进来的分给谁。顺序不能颠倒:先分发再限流,坏流量已经占掉了连接与内存;先限流再分发,被拒的请求几乎不消耗下游资源。放置位置决定了它保护的是哪一层。
  • 流域水文:漏桶就是水库调蓄的算法版:入流任意、出流受控、库容有限、超出即溢流,效果是把洪峰削平。令牌桶恰好把这个模型反过来用——闲时攒下额度,突发时一次放出,允许尖峰而只约束长期平均。两种阀门对应两种目标:保护下游,还是照顾用户。
  • 稳态:生理调节多是负反馈,测到偏离再纠正,因而天然带延迟。令牌桶用的是库存而非反馈:额度是预先积累的,响应在请求到达那一刻完成,不需要先观测再调整。这解释了它对突发友好,也解释了它无法应对"下游其实已经不行了"这种反馈才能捕捉的状态。
  • 公地悲剧:容量是共享的稀缺资源,限流是配给规则。用价格配给还是用规则配给,决定了谁承担拥挤的成本:付费换更高额度把成本转成价格,统一阈值则让重度用户与轻度用户共同承担。按 IP 限流会误伤共用出口的整个机构,这是配给维度选错的典型后果。
  • 分布式计算理论:多台机器要共同执行一个全局额度,就需要共享计数。严格精确要求每个请求都访问同一处状态,与低延迟、高可用直接冲突;本地预扣一小批配额再定期对账,是拿精度换性能的标准折中。

参考文献

  • Tanenbaum, A. S. & Wetherall, D. J. Computer Networks. 5th ed. Pearson (2011). (漏桶与令牌桶的网络流量整形章节)
  • The ATM Forum. Traffic Management Specification Version 4.0. (1996). (GCRA / 通用信元速率算法的原始规范来源)
  • Little, J. D. C. A Proof for the Queuing Formula: L = λW. Operations Research 9(3) (1961): 383–387.
  • Brakmo, L. S., O'Malley, S. W. & Peterson, L. L. TCP Vegas: New Techniques for Congestion Detection and Avoidance. ACM SIGCOMM (1994).
  • Nottingham, M. & Fielding, R. RFC 6585: Additional HTTP Status Codes. IETF (2012). (429 状态码与 Retry-After 的正式定义)
  • Stripe Engineering. Scaling Your API with Rate Limiters. Stripe Blog (2017).

延伸阅读

  • Nygard, M. T. Release It! Design and Deploy Production-Ready Software. 2nd ed. Pragmatic Bookshelf (2018). (限流、熔断与稳定性模式)
  • Kleppmann, M. Designing Data-Intensive Applications. O'Reilly (2017). (分布式系统中的协调与权衡)