服务限流详解:固定窗口、滑动窗口、令牌桶、漏桶与分布式限流
当流量超过系统处理能力时,排队和拒绝远比雪崩体面。限流是高可用的最后一道防线:缓存、异步、扩容层层削峰之后,仍然扛不住的部分必须在入口拦掉。本文把四种经典限流算法的原理与突发容忍度讲透,再讨论单机与分布式限流的实现和取舍。
它解决什么问题
任何系统的处理能力都有硬上限:CPU 核数、线程池大小、数据库连接数、下游第三方接口的配额。流量一旦越过上限,响应时间不是线性变差而是崩溃式恶化——线程排队、连接耗尽、GC 频繁,最终全员超时,一个请求都服务不好。
为什么叫最后一道防线?因为正常的流量治理顺序是:能缓存的别查库,能异步的别同步,能扩容的先扩容。这些手段全部用完仍然超出承载的部分,才轮到限流把多余请求挡在门外。限流的本质是承认”服务不了所有人”,用主动拒绝一小部分请求换取剩余请求的正常服务,避免大家一起死。
限流还有一层防御含义:防恶意刷接口、防爬虫、防上游故障时的重试风暴。没有限流的接口,等于把系统生死交给了调用方的自觉。
核心原理:四种算法
固定窗口计数器
把时钟切成等长窗口(如 1 分钟),每个窗口维护一个计数器,请求进来加一,超过阈值拒绝,窗口结束清零。实现只要一个变量加一个过期时间,成本极低。
致命缺陷在边界:限制每分钟 100 次,如果第 1 分钟最后 1 秒进入 100 个请求,第 2 分钟头 1 秒又进入 100 个,短短 2 秒就放行了 200 个请求,瞬时压力是限流目标的两倍。另外窗口前半段打满配额后,后半段只能全部拒绝,流量曲线呈锯齿状,体验很差。
滑动窗口计数器
把大窗口切成若干小格子。1 分钟限 60 次可以切成 60 个 1 秒格子,统计时累加最近一个完整周期内的所有格子,随时间平滑滑动。格子切得越细,统计越精确,边界突刺越不明显——刚才那种双倍突刺在 60 格窗口下会被摊薄到可接受范围。
代价是存储与计算开销随格子数上升,且它只能”统计过去”,无法主动给流量整形。
漏桶
请求以任意速率流入桶(队列),桶以恒定速率漏出(处理),桶满则新请求直接丢弃。漏桶的核心价值是强行把输出整形成匀速:无论上游怎么突发,下游收到的永远是固定速率的流量。
这让它特别适合保护有严格速率约束的下游,比如对方明确限制每秒 50 次的第三方 API。代价是无法利用系统空闲容量应对突发:突发流量要么排队(延迟上升),要么溢出(被丢弃),哪怕此刻系统资源很闲。
令牌桶
以恒定速率往桶里放令牌,桶有容量上限;请求处理前必须取走一枚令牌,无令牌则拒绝或等待。平时流量低时令牌会攒下来,突发到来时只要桶里有存量就能一次性放行——既限制平均速率,又容忍桶容量范围内的突发。
令牌桶回答”能不能做”,漏桶回答”以什么节奏做”。面向用户的入口限流几乎都选令牌桶,因为现实世界几乎不存在完全匀速的流量。
深入机制
单机实现:Guava RateLimiter
RateLimiter limiter = RateLimiter.create(5.0); // 每秒 5 个令牌 |
RateLimiter 并不真的开后台线程定时放令牌,而是记录”下一次可发放令牌的时间点”,请求到来时算出需要等待多久并睡眠,把突发成本推迟给后续请求——这正是它能平滑突发的原因。另有 RateLimiter.create(qps, warmupPeriod, unit) 预热模式,冷启动阶段从低速率逐步爬升到目标值,适合保护刚重启、缓存未热的服务。
分布式实现:Redis + Lua
多实例共享全局配额时,计数状态必须放到外部存储,Redis 是事实标准。关键在于”读计数、判断、写计数”三步必须原子,否则并发下必然超发。Lua 脚本在 Redis 中原子执行,天然解决这个问题:
-- 固定窗口计数伪代码 |
注意 Lua 执行期间会阻塞 Redis 的其他命令,脚本必须保持轻量,并结合 lua-time-limit 防止执行失控。不想手写脚本,可以直接用 Redisson 的 RRateLimiter(令牌桶 + Lua 实现),或 Spring Cloud Gateway 内置的 RedisRateLimiter。
Sentinel 集群流控是另一条路:引入独立的 Token Server 统一发放令牌,各实例向它申领配额,避免每个请求都穿透 Redis,精度与性能更好,代价是多了一个需要保证高可用的组件。
限流维度
- QPS / 吞吐量:最常见的入口维度,保护接口整体速率。
- 并发线程数:限制同时在处理的请求量,保护的是线程、连接这类存量资源;下游变慢时 QPS 可能不高但并发数飙升,此时它比 QPS 限流更有效。
- 热点参数:按参数值统计限流,例如限制单个用户 ID 的调用频率,防止单一热点打满全局配额,Sentinel 的热点参数限流即此类。
- 调用方与调用链:按来源系统分配配额,核心调用方给足、边缘调用方收紧。
工程实践
- 阈值从压测得来:先压出系统稳定吞吐的拐点(吞吐不再上涨、RT 开始恶化的位置),限流值取其七八成留余量。
- 被拒请求要给明确反馈:HTTP 429、排队页或”稍后再试”的提示,并让客户端带退避重试;静默丢弃只会让客户端疯狂重发。
- 多层布防:网关做粗粒度全局限流,服务内做细粒度的接口级、参数级限流,两层阈值要协调,避免网关放行的流量到服务内全被拦掉。
- Redis 故障要有预案:分布式限流依赖的中间件不可用时,回退单机限流、直接放行还是全部拒绝,必须按业务提前决策,不能现场抓瞎。
对比与权衡
| 算法 | 突发容忍 | 平滑度 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 差(边界双倍突刺) | 差 | 极低 | 精度要求低的简单场景 |
| 滑动窗口 | 中 | 较好 | 中 | 通用入口限流 |
| 漏桶 | 无,强行匀速输出 | 极好(输出侧) | 中 | 保护有严格速率约束的下游 |
| 令牌桶 | 好(桶容量内放行) | 好 | 中 | 面向用户的通用限流 |
| 方案 | 精度 | 性能开销 | 运维成本 | 适用 |
|---|---|---|---|---|
| 单机限流(Guava / Bucket4j) | 仅单实例 | 极低 | 无 | 配额可按实例均分,或只需单点保护 |
| Redis + Lua | 全局精确 | 每请求一次网络往返 | 依赖 Redis 可用性 | 中小规模集群 |
| Sentinel 集群流控 | 全局精确 | Token Server 集中申领 | 多一个待运维组件 | 大规模、精度要求高的集群 |
面试视角
固定窗口和滑动窗口的区别在哪? 固定窗口在切换点清零,窗口交界处可能通过两倍阈值的流量;滑动窗口把时间切成小格子,只累加最近一个完整周期内的格子,边界突刺被摊薄。格子越细越精确,代价是存储与计算开销上升。
漏桶和令牌桶怎么选? 漏桶强制匀速输出,适合保护有硬速率限制的下游(如第三方 API),但无法利用空闲容量应对突发;令牌桶平时攒令牌、突发时按存量放行,限制平均速率的同时容忍突发,通用入口限流几乎都选令牌桶。
Guava RateLimiter 是怎么实现平滑的? 没有后台线程发令牌,而是维护下次可发令牌的时间点,acquire 时计算需等待的时长并睡眠,把突发成本推迟给后续请求。预热模式则在冷启动期从低速率逐步爬到目标速率,保护缓存未热的服务。
分布式限流为什么用 Redis + Lua? 多实例共享配额需要外部存储承载计数,而”读-判-写”必须原子,否则并发超发;Lua 脚本在 Redis 中原子执行,一次网络往返完成全部判断。缺点是每个请求都要访问 Redis,且要提前定好 Redis 故障时的降级策略。
QPS 限流和并发数限流有何不同? QPS 控制单位时间进入的流量,管速率;并发数控制同时在处理的请求量,管资源占用。下游变慢时 QPS 不高但并发数飙升,此时并发数限流比 QPS 限流更能保护系统。
小结
- 限流是兜底手段:缓存、异步、扩容在前,实在拦不住的流量才在入口拒绝,用局部牺牲换整体存活。
- 四种算法记一句话:固定窗口有边界突刺,滑动窗口摊薄突刺,漏桶强行匀速,令牌桶容忍突发。
- 单机用 Guava 或 Bucket4j,分布式用 Redis+Lua 或 Sentinel 集群流控;前者轻,后者准。
- 阈值要压测得出并留余量,被拒请求要返回明确信号,中间件故障要提前定好降级策略。

