一篇读懂限流器:令牌桶、漏桶与滑动窗口的取舍

限流器大概是分布式系统里「实现最简单、做对最不容易」的组件:业务逻辑一句话就能说完(每分钟最多 N 个请求),可一旦真要落地,问题接踵而至——窗口边界上为什么能突刺两倍流量?令牌桶和漏桶到底差在哪?多台机器共享计数怎么保证不串?Redis 官方 2026 年发布的限流教程给出了一个很好的骨架:五种算法、同一套评估接口、全部用 Lua 保证原子性。本文沿这个骨架把限流讲透:每种算法的机制与代价,原子性为什么是生死线,以及一张能直接用的选型表。

一、先立规矩:限流器在防什么,原子性为什么是生死线

限流防三件事:滥用(单客户端打爆接口)、不公平(一个坏邻居挤占所有租户的容量)、雪崩(突发流量压垮本已脆弱的下游)。它通常部署在 API 网关或服务入口,用「允许 / 拒绝」或「延迟」保护后端。

限流器五种算法手绘信息图:原子性生死线、窗口家族、桶家族与选型表

在讲任何算法之前,必须先讲一个所有算法共有的坑:读-判-写三个步骤必须原子。限流的逻辑是「读计数 → 判断是否超限 → 写回」,如果这三步是独立命令,两个并发请求可能同时读到同一个旧计数、各自判断「没超」、各自写入——限流就被绕过去了。这是教科书级的 TOCTOU(检查与使用之间的竞态)问题,而且恰恰在限流最该起作用的高并发时刻最严重。Redis 给了三种原子化方案:MULTI/EXEC 事务(原子但不能「读了再判断」,表达不了限流逻辑)、WATCH 乐观锁(能做但高并发下频繁重试,限流场景下表现最差)、Lua 脚本(服务端原子执行、可分支逻辑、单次往返)——这也是所有正经限流实现殊途同归选 Lua 的原因。理解了这一点,下面每种算法的代码骨架其实都是同一个模式的变奏。

二、窗口家族:从固定到滑动

固定窗口计数是最简单的实现:按时间切成不重叠的窗口(如每 10 秒一格),每格一个计数器(Redis 里一个 STRING 键,INCR 加一、首个请求时 EXPIRE 设过期),超过阈值即拒绝。它内存极省(每客户端每窗口一个键)、一次往返搞定,但有个著名的边界突刺:客户端在第 9 秒发 10 个、第 11 秒再发 10 个——两个请求分属两个窗口,各自都合规,但 2 秒内实际打进来 20 个。对登录限流这类粗粒度场景够用,对严格场景就是漏洞。这段看似平淡的实现里还藏着一个值得细品的原子性细节:EXPIRE 只在 count == 1(本窗口第一个请求)时设置——如果它不在 Lua 脚本里与 INCR 同批执行,而进程恰好在两条命令之间崩溃,这个键将成为永不过期的计数器,对应客户端从此被永久限流。一个小小的「键没有 TTL」故障模式,把「为什么要脚本化」讲得比任何文档都清楚。顺带的工程甜头:脚本同时返回键的 PTTL(剩余毫秒数),调用方直接把它换算成 Retry-After,客户端知道该几秒后再来。

滑动窗口日志用最笨也最准的办法消灭边界问题:把每个请求的时间戳精确记进一个有序集合(Redis SORTED SET,score 是毫秒时间戳),每次请求先 ZREMRANGEBYSCORE 清掉窗口外的旧条目,ZCARD 数剩下多少,没超就 ZADD 记录本次。这是真正的滚动窗口,零突刺,还能给出精确的 Retry-After(最早一条记录滑出窗口的时刻)。代价写在小字里:内存 O(n)——如果限流额度是每小时一万次,每个客户端的有序集合就要存一万条,高基数(百万客户端)场景下内存账直接崩掉。

滑动窗口计数是工程上的甜蜜点:保留当前窗口和上一窗口两个计数器(两个 STRING 键),用加权平均估计真实的滚动窗口计数——估计值 = 上一窗口计数 × (1 - 已流逝比例) + 当前窗口计数。窗口刚开始时上一窗口权重最大,随时间推移线性衰减,边界突刺被平滑掉,而内存仍然只有两个键。它是近似算法(极端情况下可能多放或少放一两个请求),但 Redis 官方的选型建议说得直白:拿不准就用它——准确度、简单度、内存三者平衡最好。实现时有个集群细节值得记:两个键要用 hash tag({base}:窗口号)保证落在同一个集群槽位,否则多键 Lua 脚本会报错。另外两个小设计都有讲究:上一窗口的键要设两倍窗口时长的 TTL——它必须活到下一窗口还能被读取做加权;而脚本采用「先估算、再决定是否自增」的顺序,被拒绝的请求不写计数器——否则一波被拒流量会自己把计数撑高,扭曲后续判断。

三、桶家族:令牌桶与漏桶的镜像关系

令牌桶换个角度管理速率:桶以恒定速率往里放令牌(如每秒 1 个),桶有容量上限(如 100 个),每个请求取走一个令牌,没令牌就拒绝。它的性格由容量决定——允许受控的突发:闲时令牌攒满 100 个,流量尖峰来时可以一口气放行 100 个请求,然后按平均速率收紧。Redis 实现用一个 HASH 存两个状态(当前令牌数 + 上次补充时间戳),每次请求按流逝时间补齐令牌再消耗。适合移动端启动时的批量请求、天然带尖峰的业务流量——你要限的是「平均速率」,但不想惩罚正常的突发。

漏桶是它的数学镜像:桶里进的是请求本身,桶底以恒定速率「漏出」(处理),桶满则溢出(拒绝或延迟)。它有两种模式,语义完全不同:policing(执法)模式下桶是个计数器,满了立即拒绝、立即返回,请求不被延迟——适合同步 API;shaping(整形)模式下桶是个 FIFO 队列,请求排队、按固定速率放行,输出流量被削得绝对平滑——适合保护「完全受不了突刺」的下游,比如带宽固定的网络出口或某个脆弱的老系统。两者的分工一句话说清:令牌桶约束「平均速率、容忍突发」,漏桶约束「输出绝不突发」。实现层面各有两处细节值得记录:policing 模式判断容量用 level + 1 <= capacity 而不是 level < capacity——浮点部分泄漏后的小数余量会让后一种写法在边界上产生误判;shaping 模式则只维护一个「下次可放行时间戳」(next_free),用 排队深度 = 等待时间 × 漏出速率 把时间换算成队列水位,省掉了真实的队列数据结构。所有桶类实现的键都会设 容量 / 速率 + 1 的 TTL——客户端闲置足够久,桶自然漏空,状态自动清理。

四、选型表与工程清单

五种算法摆在一张表里(数据来自 Redis 官方教程):

算法 每客户端内存 精度 突发行为 适用
固定窗口 1 键 近似 边界可 2 倍突刺 登录限流、简单配额
滑动窗口日志 O(n) 条 精确 无 高价值 API、审计
滑动窗口计数 2 键 近精确 平滑 通用首选
令牌桶 1 键(2 字段) 精确 允许受控突发 尖峰型业务
漏桶 1 键(1–2 字段) 精确 严格无突发 整形 / 执法

落地时的其余几条:限流的粒度先想清楚——按 IP、按用户、按 API key 还是全局?同一套算法不同粒度的效果天差地别,恶意流量往往要多层叠加(全局兜底 + 按用户精细化)。拒绝要讲礼貌:被拒请求返回 429 并带上 Retry-After 头(滑动窗口日志能算出最精确的值),让客户端体面退避,而不是让它们立刻重试制造雪崩;业界还常见一组 X-RateLimit-Limit / Remaining / Reset 响应头,把余量同步给客户端——注意这些头只是惯例而没有统一标准,文档里写清自己的语义即可。混合分层是常态:官方建议的组合拳就是「按用户令牌桶 + 全局固定窗口」,不同流量各管各的。别忘了分布式语义:多实例共享 Redis 计数时,Lua 脚本保证单键原子,但如果你的限流决策还要本机缓存兜底,就要接受近似值——精确与可用之间的取舍没有免费午餐。

五、部署形态与监控:算法之外的另一半

算法选完只完成了一半工程,限流器放在哪同样影响效果。中心化方案(所有实例查询同一个 Redis)语义最准——全局限额就是真全局,代价是每次请求多一跳网络与对 Redis 的可用性依赖;边缘近似方案(每个实例本地计数,按实例数切分额度)零延迟、Redis 挂了也能降级工作,但 N 台机器就有 N 份独立计数,全局精度打折。折中做法是本地粗筛 + Redis 精判:明显超量的请求在本地直接拒绝,边界流量才走共享计数——大流量场景下这一刀能省掉绝大多数远程调用。监控侧盯三个信号就够:拒绝率(突增说明流量异常或阈值定错了)、Redis 延迟与脚本耗时(限流脚本阻塞事件循环,长脚本会拖垮整个实例)、键内存水位(高基数场景下滑动窗口日志的 O(n) 内存是最容易爆的地方)。还有一个容易被忽略的联动:限流与熔断降级是一对——限流保护「别人不把你打垮」,熔断保护「你被打垮时不拖累别人」,两者共用同一套「拒绝流量」的语义,配置时要把阈值层次对齐,避免限流阈值高于熔断阈值这种自相矛盾的组合。

结语

限流器的五个算法可以压缩成两句话:窗口家族回答「一段时间内放行几个」,桶家族回答「以什么节奏放行」;前者选内存与精度的组合,后者选对突发的态度。而所有实现共享一条铁律——读、判、写必须原子,Lua 脚本是这套逻辑在 Redis 上的通用解。下次给接口加限流时,先问自己两个问题:我限的是平均速率还是绝对窗口?我的下游怕不怕突发?答案有了,算法自然就选出来了。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?