2.2 限流算法选型


文档摘要

2.2 限流算法选型:令牌桶、漏桶还是滑动窗口 接入层把连接接住了,接下来要回答一个更尖锐的问题:当流量真的超过系统容量时,谁先被拒、以什么规则拒?这就是限流(Rate Limiting)。读者读完这一节,应该能一句话说清楚:网关层用滑动窗口做全局精准限流,业务层用令牌桶做用户级突发允许,漏桶用于需要平滑输出保护下游的场景——按层级、按目标选算法,而不是只认一种。 2.2.1 先想清楚:限流到底在保护谁 很多人把限流当成「挡用户的工具」,这是本末倒置。限流的真正目的只有两个:保护下游不被冲垮,以及在资源有限时保证公平。 保护下游:模型推理集群是稀缺且昂贵的资源。如果放任洪峰直冲,队列堆积、首字延迟飙升、实例 OOM,最后整个集群雪崩,所有用户都拿不到服务——这比拒掉 10% 用户更糟。

2.2 限流算法选型:令牌桶、漏桶还是滑动窗口

接入层把连接接住了,接下来要回答一个更尖锐的问题:当流量真的超过系统容量时,谁先被拒、以什么规则拒?这就是限流(Rate Limiting)。读者读完这一节,应该能一句话说清楚:网关层用滑动窗口做全局精准限流,业务层用令牌桶做用户级突发允许,漏桶用于需要平滑输出保护下游的场景——按层级、按目标选算法,而不是只认一种。

2.2.1 先想清楚:限流到底在保护谁

很多人把限流当成「挡用户的工具」,这是本末倒置。限流的真正目的只有两个:保护下游不被冲垮,以及在资源有限时保证公平

  • 保护下游:模型推理集群是稀缺且昂贵的资源。如果放任洪峰直冲,队列堆积、首字延迟飙升、实例 OOM,最后整个集群雪崩,所有用户都拿不到服务——这比拒掉 10% 用户更糟。
  • 保证公平:没有限流,一个刷量的客户端可以占满全部容量,把正常用户全部挤掉。限流让「每人都有份」成为默认。

所以限流不是「对用户的敌意」,而是「对系统的善意」,也是「对其他用户的善意」。带着这个认知,我们再看三种算法的取舍。

```mermaid flowchart TD IN[流量洪峰] --> RL[限流层] RL --> P1[保护下游
防雪崩] RL --> P2[保证公平
防挤占] RL --> ALG{算法选择} ALG --> T[令牌桶
允许突发] ALG --> L[漏桶
平滑输出] ALG --> W[滑动窗口
精准限流] ```

2.2.2 令牌桶:允许突发,适合对话潮汐

令牌桶(Token Bucket)是最常用的限流算法,它的直觉非常贴合对话流量。

原理:系统以固定速率往桶里放令牌(比如每秒 1000 个),桶有容量上限。每个请求来了要取走一个令牌,没令牌就拒。桶满时多余令牌丢弃(即允许短时突发:桶里攒的令牌可以一次被取空,应对瞬时高峰)。

为什么适合对话流量:对话流量是典型的「潮汐 + 突发」——白天晚高峰暴涨,平时相对平缓。令牌桶的「攒令牌」机制天然允许这种突发:低谷时攒、高峰时放。用户侧体验是「偶尔一波请求能立刻得到服务」,而不是「到点就被卡」。

典型用法:用户级限流。比如「单用户每秒最多 20 次对话、但允许短时攒到 50 次突发」。这样正常用户偶尔连发几条不被误杀,刷子却也无法持续高并发。

代价:令牌桶对「总量」的控制是统计性的,不够精确——它允许突发,也就意味着在突发那一下,下游瞬时压力可能超过你的「期望均值」。如果你的下游连这点突发都扛不住,就需要和漏桶配合。

```mermaid flowchart LR subgraph 令牌桶 TICK[定时放令牌 r/s] --> BUCKET[(桶容量 B)] BUCKET -->|取走 1| REQ[请求] end REQ -->|有令牌| PASS[放行] REQ -->|无令牌| REJ[拒绝/排队] BUCKET -.溢出丢弃.-> DROP[突发额度] ```

2.2.3 漏桶:平滑输出,保护下游

漏桶(Leaky Bucket)是令牌桶的「镜像」思路,它更关注输出的平滑度

原理:请求像水一样进桶,桶以固定速率往外漏(处理)。如果进水快于漏水,桶满则溢出(拒绝)。注意这里「进水不限速、出水限速」——无论进水多猛,下游看到的都是恒定速率。

为什么能保护下游:因为漏桶强制把「下游处理速率」钉死在固定值。哪怕上游瞬间打进来 10 万请求,下游感受到的也始终是那个恒定漏速。这对「下游是脆弱单点、绝不能超并发」的场景极有价值。

典型用法:保护特定的脆弱下游,或做全局出口限速。比如你有一个第三方付费 API,合同限制每秒最多 50 次调用——漏桶能确保无论如何都不会超,因为它就是恒定 50/s 往外漏。

代价:漏桶不区分用户,对正常用户不够友好——突发的好请求也会被「压成恒定速率」,导致不必要的排队延迟。所以纯漏桶不适合直接做用户级限流,更适合做「下游护栏」。

```mermaid flowchart LR subgraph 漏桶 IN[请求进水] --> LB[(桶容量)] LB -->|恒定速率漏出| OUT[下游] end IN -->|桶满| OVER[溢出拒绝] ```

2.2.4 滑动窗口:精准限流,配合用户级配额

滑动窗口(Sliding Window)关注的是「时间窗内总量」的精确性,是对「固定窗口」缺陷的修正。

原理:把时间切成窗口(如 1 秒),统计窗口内通过的请求数。固定窗口的问题是「窗口边界双倍计数」——比如在 00:00:59 放 100 个、00:01:00 又放 100 个,实际 2 秒 200 个看似合规,但跨边界的 1 秒内实际过了 200 个。滑动窗口用「按请求时间戳滚动」或「结合前一窗口加权平均」来消除这个毛刺,让任意时刻的「近 N 秒」计数都准确。

为什么适合做全局限流:当你要承诺「全站峰值不超过 X QPS」这种硬指标时,滑动窗口的精度最可靠,不会因为窗口边界而被钻空子。

典型用法:网关层全局限流 + 用户级配额。比如「全站每秒最多放行 80 万请求」用滑动窗口卡住总闸门;「单用户每分钟最多 100 次」用滑动窗口做配额。它和令牌桶的区别在于:滑动窗口更「硬」(严格按时间窗总量),令牌桶更「软」(允许攒、允许突发)。

```mermaid flowchart LR NOW[当前时刻] --> W1[窗口 T-1
权重衰减] NOW --> W2[窗口 T
当前计数] W1 --> SW[滑动窗口计数
= W2 + W1×剩余比例] W2 --> SW SW -->|超阈值| BLOCK[拒绝] SW -->|未超| ALLOW[放行] ```

2.2.5 组合打法:分层限流才是正解

三种算法没有绝对的「谁更好」,只有「放在哪一层更合适」。我的建议是分层组合,这也是生产环境的通行做法:

  • 第一层(网关全局):滑动窗口,卡住全站总闸门(如全站 80 万 QPS)。这是硬上限,防止整个集群被冲垮。
  • 第二层(租户/用户级):令牌桶,給每个用户/租户独立的突发额度(如单用户 20/s,可攒到 50)。这是公平性 + 友好度。
  • 第三层(脆弱下游护栏):漏桶,钉死特定下游的调用速率(如某第三方 API 50/s)。这是保护最脆弱的环节。

三层各司其职:滑动窗口管「总量别超」,令牌桶管「人人有份且能突发」,漏桶管「下游别被冲」。三者叠加,才是工业级限流。

```mermaid flowchart TD REQ[请求] --> G[第一层 网关
滑动窗口·全局 80万QPS] G -->|通过| U[第二层 用户
令牌桶·20/s可突发] U -->|通过| D[第三层 下游护栏
漏桶·恒定速率] D --> SV[后端服务] G -->|超限| R1[拒/排队] U -->|超限| R2[拒/排队] D -->|溢出| R3[降级] ```

2.2.6 限流的两个工程陷阱

讲了算法,必须提醒两个真实会咬人的坑:

陷阱一:限流计数器的集中化成为新瓶颈。 很多团队把限流计数放在一个 Redis 里集中统计,结果百万 QPS 下 Redis 自己成了瓶颈,甚至因为限流逻辑卡顿把整个请求链路拖死。正确做法是把计数做得「尽量本地化 + 最终一致」:边缘节点用本地近似计数(如令牌桶在网关节点本地维护),全局只在必要时同步。接受「限流是近似的」这一现实——99% 的限流场景不需要绝对精确,宁可近似快,不要精确慢。

陷阱二:被限流时用 500 而非 429。 429(Too Many Requests)和 503(Service Unavailable)是「可重试」信号,客户端应当退避重试;而 500 是「服务端错误」,客户端可能直接报错给用户。限流一定要返回 429/503 并带 Retry-After 头,让客户端优雅退避,而不是把「被限流」伪装成「系统崩了」。

我建议你在限流配置旁边写一句注释:「我们限流是为了让 90% 的人拿到服务,不是为了让 100% 的人拿到 0 服务。」这能帮你在「要不要再收紧一点」的时刻做对取舍。

2.2.8 限流阈值的「黄金参数」怎么定

算法选好了,阈值定多少?这是最容易被「拍脑袋」的环节。我的方法论是:阈值必须来自下游真实容量,而非业务愿望。

第一步,压测出下游(模型集群)在「可接受的延迟与错误率下」能稳定扛住的 QPS,记为 C。比如实测大模型集群稳定 100 万 QPS。第二步,全局闸门留 20%~30% 余量,设 80 万——余量是为了吸收抖动、给故障转移留空间,别把闸门卡到 100 万极限。第三步,用户级令牌桶要基于「正常用户行为」设:统计发现正常用户每分钟对话少于 30 次,那么单用户令牌桶设「20/s、可攒到 50 突发」足够覆盖连点,又不会给刷子留空间。阈值定完后必须压测验证:用 1.2 倍的预期峰值打进去,看降级是否按设计触发,而不是等线上真洪水来检验。

2.2.9 分布式限流:百万 QPS 下不能每请求访问中心存储

2.2.6 提到「计数本地化」,这里展开怎么做。核心矛盾是:限流需要全局视图,但百万 QPS 下每请求都查一次中心计数器(如 Redis)会把自己压垮。工业界的做法是配额预分发加本地近似

  • 本地令牌桶加周期同步:每个网关节点维护自己的本地令牌桶,节点间周期性(如每秒)同步一次全局水位,做到最终一致。单请求只碰本地内存,不增加额外延迟。
  • 配额预分发模式:中心先把「本时段允许的总令牌数」切分给各节点,节点在本地扣除;节点快用尽时再向中心申领。中心只处理「申领」这件低频事件,不处理每请求。
  • 滑动窗口的本地近似:用本地 LRU 记录近期请求时间戳做近似窗口计数,全局只做粗粒度对账,接受「限流是近似的」这一现实。

记住:宁可限流近似快,不要精确慢。 99% 的限流场景不需要绝对精确,需要的是在洪峰来时及时把闸门拉下来。

2.2.10 别忽略一个维度:按「租户」而非「用户」限流

很多系统按「单个用户」限流,但在 ToB 场景会踩坑:企业客户用一个 API Key,背后是上千名员工同时对话,按单用户限流会把这家客户整体误伤,而真正该限的是「这个租户的总量」。正确做法是基于 API Key 或租户维度聚合限流,再在租户内部自行分配额度。这样既能防单个租户把容量吃光,又不会把合法的企业流量错杀。租户级限流通常配合「租户 SLA 套餐」——不同套餐给不同闸门,这也是商业化对话 API 的标准做法。

2.2.11 限流上线要灰度:先观测、后拦截

新限流规则千万别「一把闸拉满」。正确路径是:先在「只观测不拦截」模式跑一段时间,统计若按新阈值会拒绝多少请求、误伤多少正常用户;确认无误后,再灰度到 5%、25%、100% 节点逐步生效。这样即便阈值算偏了,影响面也可控,不至于上线即故障。

2.2.7 本节要点回顾

读完 2.2,请带走这几条:

  1. 限流保护的是下游和公平,不是用来挡用户。它是系统善意。
  2. 令牌桶允许突发,适合对话潮汐和用户级限流;漏桶恒定输出,适合保护脆弱下游;滑动窗口精确控总量,适合全局闸门和用户配额。
  3. 分层组合是正解:网关滑动窗口管总量、用户令牌桶管公平突发、下游漏桶管护栏。
  4. 两个陷阱:限流计数别集中成瓶颈(本地近似优先);被限流要返 429/503 带 Retry-After,别返 500。

下一节(2.3),我们讨论限流的「后手」:当限流也挡不住、下游真的过载时,如何用降级与排队保住核心体验——而不是让所有用户一起看到 5xx。


发布者: 作者: 转发
评论区 (0)
U