2.2 限流算法选型:令牌桶、漏桶还是滑动窗口 接入层把连接接住了,接下来要回答一个更尖锐的问题:当流量真的超过系统容量时,谁先被拒、以什么规则拒?这就是限流(Rate Limiting)。读者读完这一节,应该能一句话说清楚:网关层用滑动窗口做全局精准限流,业务层用令牌桶做用户级突发允许,漏桶用于需要平滑输出保护下游的场景——按层级、按目标选算法,而不是只认一种。 2.2.1 先想清楚:限流到底在保护谁 很多人把限流当成「挡用户的工具」,这是本末倒置。限流的真正目的只有两个:保护下游不被冲垮,以及在资源有限时保证公平。 保护下游:模型推理集群是稀缺且昂贵的资源。如果放任洪峰直冲,队列堆积、首字延迟飙升、实例 OOM,最后整个集群雪崩,所有用户都拿不到服务——这比拒掉 10% 用户更糟。
接入层把连接接住了,接下来要回答一个更尖锐的问题:当流量真的超过系统容量时,谁先被拒、以什么规则拒?这就是限流(Rate Limiting)。读者读完这一节,应该能一句话说清楚:网关层用滑动窗口做全局精准限流,业务层用令牌桶做用户级突发允许,漏桶用于需要平滑输出保护下游的场景——按层级、按目标选算法,而不是只认一种。
很多人把限流当成「挡用户的工具」,这是本末倒置。限流的真正目的只有两个:保护下游不被冲垮,以及在资源有限时保证公平。
所以限流不是「对用户的敌意」,而是「对系统的善意」,也是「对其他用户的善意」。带着这个认知,我们再看三种算法的取舍。
令牌桶(Token Bucket)是最常用的限流算法,它的直觉非常贴合对话流量。
原理:系统以固定速率往桶里放令牌(比如每秒 1000 个),桶有容量上限。每个请求来了要取走一个令牌,没令牌就拒。桶满时多余令牌丢弃(即允许短时突发:桶里攒的令牌可以一次被取空,应对瞬时高峰)。
为什么适合对话流量:对话流量是典型的「潮汐 + 突发」——白天晚高峰暴涨,平时相对平缓。令牌桶的「攒令牌」机制天然允许这种突发:低谷时攒、高峰时放。用户侧体验是「偶尔一波请求能立刻得到服务」,而不是「到点就被卡」。
典型用法:用户级限流。比如「单用户每秒最多 20 次对话、但允许短时攒到 50 次突发」。这样正常用户偶尔连发几条不被误杀,刷子却也无法持续高并发。
代价:令牌桶对「总量」的控制是统计性的,不够精确——它允许突发,也就意味着在突发那一下,下游瞬时压力可能超过你的「期望均值」。如果你的下游连这点突发都扛不住,就需要和漏桶配合。
漏桶(Leaky Bucket)是令牌桶的「镜像」思路,它更关注输出的平滑度。
原理:请求像水一样进桶,桶以固定速率往外漏(处理)。如果进水快于漏水,桶满则溢出(拒绝)。注意这里「进水不限速、出水限速」——无论进水多猛,下游看到的都是恒定速率。
为什么能保护下游:因为漏桶强制把「下游处理速率」钉死在固定值。哪怕上游瞬间打进来 10 万请求,下游感受到的也始终是那个恒定漏速。这对「下游是脆弱单点、绝不能超并发」的场景极有价值。
典型用法:保护特定的脆弱下游,或做全局出口限速。比如你有一个第三方付费 API,合同限制每秒最多 50 次调用——漏桶能确保无论如何都不会超,因为它就是恒定 50/s 往外漏。
代价:漏桶不区分用户,对正常用户不够友好——突发的好请求也会被「压成恒定速率」,导致不必要的排队延迟。所以纯漏桶不适合直接做用户级限流,更适合做「下游护栏」。
滑动窗口(Sliding Window)关注的是「时间窗内总量」的精确性,是对「固定窗口」缺陷的修正。
原理:把时间切成窗口(如 1 秒),统计窗口内通过的请求数。固定窗口的问题是「窗口边界双倍计数」——比如在 00:00:59 放 100 个、00:01:00 又放 100 个,实际 2 秒 200 个看似合规,但跨边界的 1 秒内实际过了 200 个。滑动窗口用「按请求时间戳滚动」或「结合前一窗口加权平均」来消除这个毛刺,让任意时刻的「近 N 秒」计数都准确。
为什么适合做全局限流:当你要承诺「全站峰值不超过 X QPS」这种硬指标时,滑动窗口的精度最可靠,不会因为窗口边界而被钻空子。
典型用法:网关层全局限流 + 用户级配额。比如「全站每秒最多放行 80 万请求」用滑动窗口卡住总闸门;「单用户每分钟最多 100 次」用滑动窗口做配额。它和令牌桶的区别在于:滑动窗口更「硬」(严格按时间窗总量),令牌桶更「软」(允许攒、允许突发)。
三种算法没有绝对的「谁更好」,只有「放在哪一层更合适」。我的建议是分层组合,这也是生产环境的通行做法:
三层各司其职:滑动窗口管「总量别超」,令牌桶管「人人有份且能突发」,漏桶管「下游别被冲」。三者叠加,才是工业级限流。
讲了算法,必须提醒两个真实会咬人的坑:
陷阱一:限流计数器的集中化成为新瓶颈。 很多团队把限流计数放在一个 Redis 里集中统计,结果百万 QPS 下 Redis 自己成了瓶颈,甚至因为限流逻辑卡顿把整个请求链路拖死。正确做法是把计数做得「尽量本地化 + 最终一致」:边缘节点用本地近似计数(如令牌桶在网关节点本地维护),全局只在必要时同步。接受「限流是近似的」这一现实——99% 的限流场景不需要绝对精确,宁可近似快,不要精确慢。
陷阱二:被限流时用 500 而非 429。 429(Too Many Requests)和 503(Service Unavailable)是「可重试」信号,客户端应当退避重试;而 500 是「服务端错误」,客户端可能直接报错给用户。限流一定要返回 429/503 并带 Retry-After 头,让客户端优雅退避,而不是把「被限流」伪装成「系统崩了」。
我建议你在限流配置旁边写一句注释:「我们限流是为了让 90% 的人拿到服务,不是为了让 100% 的人拿到 0 服务。」这能帮你在「要不要再收紧一点」的时刻做对取舍。
算法选好了,阈值定多少?这是最容易被「拍脑袋」的环节。我的方法论是:阈值必须来自下游真实容量,而非业务愿望。
第一步,压测出下游(模型集群)在「可接受的延迟与错误率下」能稳定扛住的 QPS,记为 C。比如实测大模型集群稳定 100 万 QPS。第二步,全局闸门留 20%~30% 余量,设 80 万——余量是为了吸收抖动、给故障转移留空间,别把闸门卡到 100 万极限。第三步,用户级令牌桶要基于「正常用户行为」设:统计发现正常用户每分钟对话少于 30 次,那么单用户令牌桶设「20/s、可攒到 50 突发」足够覆盖连点,又不会给刷子留空间。阈值定完后必须压测验证:用 1.2 倍的预期峰值打进去,看降级是否按设计触发,而不是等线上真洪水来检验。
2.2.6 提到「计数本地化」,这里展开怎么做。核心矛盾是:限流需要全局视图,但百万 QPS 下每请求都查一次中心计数器(如 Redis)会把自己压垮。工业界的做法是配额预分发加本地近似:
记住:宁可限流近似快,不要精确慢。 99% 的限流场景不需要绝对精确,需要的是在洪峰来时及时把闸门拉下来。
很多系统按「单个用户」限流,但在 ToB 场景会踩坑:企业客户用一个 API Key,背后是上千名员工同时对话,按单用户限流会把这家客户整体误伤,而真正该限的是「这个租户的总量」。正确做法是基于 API Key 或租户维度聚合限流,再在租户内部自行分配额度。这样既能防单个租户把容量吃光,又不会把合法的企业流量错杀。租户级限流通常配合「租户 SLA 套餐」——不同套餐给不同闸门,这也是商业化对话 API 的标准做法。
新限流规则千万别「一把闸拉满」。正确路径是:先在「只观测不拦截」模式跑一段时间,统计若按新阈值会拒绝多少请求、误伤多少正常用户;确认无误后,再灰度到 5%、25%、100% 节点逐步生效。这样即便阈值算偏了,影响面也可控,不至于上线即故障。
读完 2.2,请带走这几条:
下一节(2.3),我们讨论限流的「后手」:当限流也挡不住、下游真的过载时,如何用降级与排队保住核心体验——而不是让所有用户一起看到 5xx。