2.2 限流算法全解:令牌桶、漏桶、滑动窗口的取舍


文档摘要

2.2 限流算法全解:令牌桶、漏桶、滑动窗口的取舍 限流算法是网关层的核心。但很多团队对限流的理解停留在"挂个令牌桶就行",结果要么误伤正常用户,要么该挡的没挡住。这一节要把三种主流限流算法的本质差异讲透——它们各自擅长什么、不擅长什么,以及为什么生产环境几乎都用"分层组合"而非单一算法。 我之前帮一个团队排查过限流误伤问题。他们的网关挂了一个全局令牌桶,按 10 万 QPS 限流。某天有个大客户做活动,瞬时把免费用户的流量顶到了 12 万,令牌桶开始 429。问题在于这个 429 是无差别的——大客户自己的付费用户也被拒绝了,因为令牌桶不懂"谁是谁"。那天他们损失了几十万的续约。复盘后才发现,单一令牌桶根本不是"限流",是"无差别屠杀"。

2.2 限流算法全解:令牌桶、漏桶、滑动窗口的取舍

限流算法是网关层的核心。但很多团队对限流的理解停留在"挂个令牌桶就行",结果要么误伤正常用户,要么该挡的没挡住。这一节要把三种主流限流算法的本质差异讲透——它们各自擅长什么、不擅长什么,以及为什么生产环境几乎都用"分层组合"而非单一算法。

我之前帮一个团队排查过限流误伤问题。他们的网关挂了一个全局令牌桶,按 10 万 QPS 限流。某天有个大客户做活动,瞬时把免费用户的流量顶到了 12 万,令牌桶开始 429。问题在于这个 429 是无差别的——大客户自己的付费用户也被拒绝了,因为令牌桶不懂"谁是谁"。那天他们损失了几十万的续约。复盘后才发现,单一令牌桶根本不是"限流",是"无差别屠杀"。正确的限流要分层、要分优先级、要能降级而不是只会拒绝。这些都是这一节要解决的。

三种主流限流算法的本质

漏桶(Leaky Bucket)

漏桶的模型很直观:想象一个固定容量的桶,请求像水一样倒入桶里,桶以恒定速率漏出(被处理),水满了(超出容量)就丢弃。它的核心特性是输出速率恒定——不管请求来得多猛,处理速率永远平滑。突发流量被桶"削峰",下游永远看到稳定流速。

漏桶最适合保护对突发敏感的下游。推理集群就是典型——GPU 不希望被脉冲流量冲击,稳定的输入速率对调度和显存管理最友好。但漏桶的局限也很明显:它无法容忍任何突发,合法的短时突发也会被拒绝。比如一个用户偶尔连续发几条消息,漏桶可能把这视为"超速"而拒绝,体验不好。

举个具体参数。我们给推理集群前置的漏桶配过这样的参数:rate = 8000 req/s(集群稳定吞吐的 80%,留余量),burst(桶容量)= 200。这个 burst 设得很小是有意的——漏桶的 burst 越大就越不像漏桶,越像令牌桶,就失去了"强制平滑"的意义。Nginx 的 limit_req 模块 zone=api:10m rate=8000r/s burst=200 nodelay 就是这种配置,去掉 nodelay 就是标准漏桶行为(突发请求排队等待,而不是立即拒绝)。我们有一次把 burst 调到 2000 想"缓解限流",结果脉冲流量直接打到推理集群,prefill 排队,TTFT 飙升——这就是把漏桶用成了令牌桶的代价。

令牌桶(Token Bucket)

令牌桶的模型反过来:桶里以恒定速率放入令牌,请求来了必须拿到一个令牌才能处理,桶满则令牌溢出不再积累。桶容量决定了"允许的突发量"。

令牌桶和漏桶的关键差异在于:令牌桶允许突发。只要桶里有令牌,瞬间可以处理多个请求(突发量等于桶容量);但长期平均速率受控(令牌发放速率等于平均处理速率)。这更符合真实业务——大部分场景既需要控制长期速率,又需要容忍合理的短时突发(比如用户快速连续发消息)。

令牌桶是生产里用得最多的算法,原因就是这种"控制平均、容忍突发"的特性,最贴合业务需求。但它的风险也在这里——如果桶容量设得太大,突发会压垮下游;设得太小,又退化为漏桶的僵硬。

令牌桶的 capacity 怎么设有个经验公式:capacity = rate × 允许突发时长。比如 rate=1000 req/s,允许 2 秒的突发,capacity 就设 2000。我给租户级令牌桶定过一个分级参数表:免费租户 rate=50/s、capacity=100(容忍 2 秒突发);标准付费 rate=500/s、capacity=1500;企业付费 rate=2000/s、capacity=8000(企业客户有批量调用场景,突发要给足)。这个分级让免费用户的爬虫脚本被挡住,而正常的企业批量调用不会被误伤。

滑动窗口(Sliding Window)

滑动窗口把时间切成窗口(比如每秒一个),统计当前窗口内的请求数,超阈值则拒绝。"滑动"指窗口随时间连续移动,而不是固定在整秒边界。

滑动窗口的优势是精确统计单位时间内的量,适合"每用户每分钟最多 60 次"这类配额控制。它解决了固定窗口的"边界突发"问题——固定窗口在边界附近(比如第 0.9 秒和第 1.1 秒各来 100 个请求)实际通过了双倍流量,而滑动窗口能识别并拦截。

滑动窗口的工程实现有个轻量技巧叫"滑动日志"——把每个请求的时间戳记下来,判断时数一下"过去 N 秒内有多少个请求"。但这个方案内存和 CPU 开销大(O(N)),不适合高 QPS。生产里常用的是"滑动计数"近似——把窗口切成小格子(比如 1 分钟窗口切成 6 个 10 秒格子),统计时用当前格子 + 还在窗口内的历史格子求和,格子过 expire 就丢弃。Redis 实现上可以用 INCR + EXPIRE 配合时间戳 key,比如 rate:user123:2024010112305(精确到 10 秒),每个 key 设 60 秒过期,统计时把 6 个相关 key 的值加起来。

生产环境为什么用"分层组合"

三种算法各有取舍,单一算法总有不擅长的场景。生产环境的最佳实践是分层组合,每一层用最适合它的算法。

我推荐的标准分层是:用户级用滑动窗口,精确控制"每用户每分钟 N 次",防同一用户刷接口;租户级用令牌桶,容忍租户的合理突发,长期受控于配额;全局级用漏桶,强制平滑输出,保护推理集群不被脉冲冲击。一个请求的限流判定会依次经过这三层——先看用户级滑动窗口,再看租户级令牌桶,最后看全局漏桶,任一层限流即拒绝或降级。

这就是 1.3 节反模式二"单一令牌桶"的正确解法。回到开头那个误伤案例,正确的做法是:付费用户的请求在用户级滑动窗口里就有更高的配额,在租户级令牌桶里有更大的桶容量,在全局过载时还有优先级保障——三层叠加后,付费用户被误伤的概率接近于零,而免费用户的洪流仍然能被挡住。

给一组完整的实战参数。用户级(滑动窗口,1 分钟窗口):免费用户 30 次/分钟、付费用户 300 次/分钟、企业付费 3000 次/分钟。租户级(令牌桶):免费租户 rate=50/s capacity=100、标准付费 rate=500/s capacity=1500、企业付费 rate=2000/s capacity=8000。全局级(漏桶):rate=集群稳定吞吐的 80%,burst=200。三层判定顺序我建议是"由细到粗"——先用户级(最便宜,命中就拒),再租户级(中等开销),最后全局级(开销最大,因为要看全局状态)。这样大部分恶意流量在最便宜的那层就被挡住了。

分布式限流:百万级绕不开的问题

百万级场景下,限流不能只靠单机计数,因为流量分散在多台网关上,单机计数器只看到自己那份,全局视角是缺失的。必须做分布式限流。

主流有两种实现思路。集中式计数用 Redis 等共享存储,所有网关实例共享计数器,准确但有延迟和单点风险——Redis 挂了限流就失效,而且每次计数都要一次网络往返,高并发下 Redis 本身可能成瓶颈。本地估算加定期同步让每个网关实例本地维护计数,定期与中心同步,降低中心压力,但有误差——某个实例可能短暂超发。

生产权衡的核心是精确性 vs 性能。对"付费用户配额"这种要求精确的,用集中式,宁可慢一点也不能错;对"粗粒度全局保护",用本地估算即可,误差 5% 到 10% 完全可接受。我见过有团队为了追求精确,把所有限流都走 Redis,结果 Redis 集群先于推理集群被打挂了,这是典型的过度设计。

Redis 令牌桶的标准实现是用 Lua 脚本保证"取令牌"操作的原子性(读取剩余令牌、判断、扣减这几步必须在一个脚本里完成,否则并发下会超发)。核心逻辑大致是这样的思路(伪代码):

-- KEYS[1] = 桶的 key, ARGV[1] = capacity, ARGV[2] = rate(令牌/秒), ARGV[3] = now(秒), ARGV[4] = 申请的令牌数 local last_tokens = tonumber(redis.call('hget', KEYS[1], 'tokens') or ARGV[1]) local last_refresh = tonumber(redis.call('hget', KEYS[1], 'ts') or ARGV[3]) -- 按经过的时间补充令牌 local delta = math.max(0, now - last_refresh) * rate local tokens = math.min(capacity, last_tokens + delta) local allowed = tokens >= requested if allowed then tokens = tokens - requested end redis.call('hmset', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('expire', KEYS[1], 600) -- 10 分钟没访问就回收,避免无界增长 return allowed

几个工程细节要注意。第一,key 必须设过期时间,否则每个用户的计数器永远不释放,Redis 内存会爆。第二,now 时间戳要从服务端拿(用 Redis 的 TIME 命令),不能用客户端时间——多台网关时间不同步会让令牌补充算错。第三,Lua 脚本里不要有任何网络调用和慢操作,否则会阻塞 Redis 单线程。第四,高并发下单个 Redis 扛不住,要么用 Redis Cluster 分片(按 user_id hash 分片),要么用"本地令牌桶 + 周期性从 Redis 同步配额"的混合方案(每个网关实例每秒从 Redis 拉一批令牌到本地,本地消耗完再去拉,这样 Redis 的 QPS 降几个数量级)。

限流的"软"与"硬"

最后说一个常被忽视但极其重要的理念:限流不只有"拒绝"一种处置。

硬限流是超阈值直接拒绝(429),简单粗暴。软限流是超阈值不拒绝,而是降级——转小模型、转缓存、降低响应质量。百万级平台应该多用软限流——过载时优先降级保可用,而非直接拒绝。一个过载时仍然能返回"虽然不够好但有用"响应的平台,远比一个过载就直接 429 的平台体验好。

软限流和硬限流的边界在哪?我的经验是:核心交互(用户发消息、收响应)尽量用软限流,宁可降级也要让用户"有东西可看";非核心功能(后台统计、批量导出)可以用硬限流,过载时直接拒绝不影响主体验。这个区分需要在网关层做精细的策略配置,但收益是巨大的——它把"过载"从用户感知的"服务不可用",变成"服务降级但可用"。

具体在网关层怎么落地?我们在 Envoy/网关里给每个路由配了一个"降级链":过载时第一步把请求从旗舰模型 redirect 到小模型池(响应仍正常,只是质量略降);小模型池也满了,第二步转语义缓存查相似历史响应(可能不够精准,但有响应);缓存也没命中,第三步才返回一个友好的 429(带 Retry-After,提示用户稍后重试)。这三步通过网关的 retry policy 和外部 fallback 编排起来。从监控数据看,过载期 90% 的请求在前两步就消化了,真正吃到 429 的不到 10%——这就是软限流把"过载"变得对用户无感的价值。

这一节的关键结论

三种算法的本质区别要记牢:漏桶强制平滑输出(保护下游),令牌桶允许突发(贴合业务),滑动窗口精确计量(适合配额)。生产用分层组合,每层用最适合的算法,互为兜底。分布式限流要权衡精确性和性能,不同场景用不同方案。最重要的理念转变是——限流的目的不是拒绝流量,而是在过载时交付最大可能的可用性,所以多用软限流(降级),少用硬限流(拒绝)。

下一节 2.3 讲过载时的降级、熔断与恢复风暴——那是限流之后的下一道防线,也是过载时能不能守住核心 SLO 的关键。


作者与出处
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 会发光的石头的小龙虾 转发
评论区 (0)
U