速率限制算法:令牌桶、漏桶与滑动窗口比较
速率限制为何重要
速率限制(Rate Limiting)是控制客户端在单位时间内向服务器发起请求次数的防御机制。它的核心目标是在高并发场景下保护后端服务不被突发流量冲垮,同时确保资源能在多租户间公平分配。没有限流的系统就像没有匝道的公路,几个节点的无节制调用就可能耗尽整个系统的数据库连接池或计算资源。
常见的应用场景包括:API网关对用户调用次数的配额控制、登录接口防止暴力破解、消息队列削弱生产者过快的灌入、以及Web服务缓解DDoS攻击。选择一种合适的限流算法,既要考虑准确性,也要衡量实现复杂度和内存开销。
三大核心算法原理
令牌桶(Token Bucket)
令牌桶算法以“恒定速率生成令牌、请求消费令牌”的模型工作。你可以把它理解成一个带有最大容量的水桶,系统以固定的速度向桶中放入令牌,当桶满时多余的令牌会被丢弃。每个请求到达时,必须从桶中取走一个令牌才能被处理;若桶空,请求将被拒绝或排队等待。
这种设计的精妙之处在于它天然允许一定程度的突发流量。当桶中积累了足够多的令牌,短时间内到达的大量请求可以一次性消耗掉所有令牌。突发容量等于桶的当前大小,而持续速率则由令牌的生成速度决定。参数配置通常包含 rate(令牌生成速率,如每秒100个)和 burst(桶的最大容量,如300个)。
实现时需要注意每个请求的时间戳和上次补充令牌的时间间隔,而不是真正启动一个后台线程去“放令牌”。通常采用懒计算:记录上一次访问时间,当新请求到来时先计算这段时间内应生成的令牌数,再执行扣减逻辑。
漏桶(Leaky Bucket)
漏桶算法将请求队列抽象成一个底部有漏洞的水桶。请求以任意速率流入桶中,如果桶已满则溢出(请求被丢弃)。桶的底部以恒定速率“漏出”请求,交由服务处理。这一过程模拟了先进先出的流量整形。
与令牌桶形成鲜明对比的是,漏桶的输出速率是严格恒定的,完全抹平了突发。无论输入多么汹涌,后端看到的处理速率永远平稳。它极其适合需要保护下游系统“心率”的场景,比如给数据库写入降速,但对突发的利用不够友好,无法在资源空闲时快速消化积压请求。
漏桶的核心参数是桶的容量(队列最大长度)和出水速率(请求处理速率)。实现上通常结合一个内存队列和定时器,或者通过记录“下次允许通过时间”来避免真实线程阻塞。
滑动窗口(Sliding Window)
滑动窗口算法追求统计精度的提升。早期的固定窗口计数器会在窗口边界处出现“临界突增”问题:例如限制每分钟100次请求,客户端可在第59秒和第61秒各发100次,导致两秒内通过200次。滑动窗口通过细分窗口让统计更平滑。
**滑动日志(Sliding Log)**会记录每个请求的时间戳,当新请求到来时,删除窗口之前的所有记录,然后检查剩余记录数是否达到阈值。这种方式精确到毫秒级,但占用大量内存,不适合高吞吐场景。
**滑动窗口计数器(Sliding Window Counter)**则是一种折中方案。它将单位时间窗口划分为多个小格子(比如每分钟限制100请求,划分为6个10秒格子),并基于当前时间戳所在格子的已用次数与前一窗口中对应比例部分加权求和。例如,当前时间位于整个窗口的30%位置,则估算前一个窗口仍有70%的流量可能产生影响,计算公式为:前窗口请求数 × (1 - 当前位置占比) + 当前窗口请求数。如果结果大于阈值则限流。
这种算法在内存开销和精度之间取得了优秀平衡,Redis的实现中使用有序集合更是将滑动日志的复杂度降得很低。
算法对比与选择指南
| 特性 | 令牌桶 | 漏桶 | 滑动窗口 |
|---|---|---|---|
| 突发处理 | 支持突发,允许短时超过匀速 | 严格禁止突发,强制匀速 | 取决于具体实现,计数器模式下近似平滑 |
| 流量整形能力 | 强,输出曲线可带有峰值 | 极强,输出绝对平稳 | 较弱,侧重于计数统计而非整形 |
| 实现复杂度 | 中等,需处理时间差计算 | 中等,需额外队列或计时机制 | 滑动日志高,滑动窗口计数器中 |
| 内存占用 | 极小,仅存几条状态 | 小,取决于队列长度 | 日志模式大,计数器模式适中 |
| 适用场景 | API网关、允许毛刺的公共接口 | 数据库写入保护、消息消费控制 | 精确配额管理、防刷接口 |
令牌桶在绝大多数API限流中胜出,因为它既保证了平滑的平均速率,又不会扼杀合理的短时突增,比如用户连续快速翻页时不应被掐断。主流网关如Nginx的limit_req、Guava的RateLimiter都默认采用此算法(Guava为预热型令牌桶)。
漏桶则应出现在下游无法承受任何波动的场景。比如将数据写入磁盘,如果一次刷入大量请求会导致IO Wait飙升,此时漏桶以“恒定压力”输送事件,使处理线程永远工作在安全水位。
滑动窗口的精确计数特性使其在反欺诈系统中占据主导。当你说“一个用户每小时只能提现3次”,固定窗口会在整点边缘产生两个用户同时达到限额的误解,滑动窗口则能精确反映过去60分钟的真实操作次数。若使用Redis的有序集合实现滑动日志,可以轻松获得毫秒级精度且内存可通过过期策略控制。
生产落地的关键考量
无论选用哪种算法,分布式环境下的准确性都是最大挑战。单机限流可以被轻易突破,必须靠集中式存储(Redis、中央缓存)实现全局限流。但每次请求都调用Redis会引入网络开销,通常的做法是本地预取一批令牌或配额,本地扣减消耗完毕后再次向Redis请求,兼顾性能与一致性。
监控也应配套到位,将限流拒绝次数、令牌消耗速率、各客户端限流命中率接入可观测平台,利于动态调整阈值。对用户体验而言,返回429 Too Many Requests状态码并携带Retry-After头信息,比直接断开连接友好得多。
总结
令牌桶、漏桶与滑动窗口并不是相互排斥的流派,而是面向不同矛盾侧重点的工具。如果你需要“平均速率限制 + 容忍突发”,令牌桶是你的默认选择;如果下游极其脆弱、必须强制匀速,漏桶是可靠保障;当精确计数成为硬性要求时,滑动窗口计数器或日志方案能为你守住公平线。理解它们各自的核心模型与代价,你就能在系统架构中灵活地构建出既强韧又优雅的限流层。