3. 限流器原理:常见限流算法(令牌桶、漏桶、滑动窗口)原理对比与适用场景
限流(Rate Limiting)是保护系统不被突发流量冲垮的第一道防线。与熔断侧重“失败后的快速止损”不同,限流侧重“在流量到达服务前进行主动控制”。理解不同限流算法的数学原理与资源开销,是后续压测分析 QPS 拐点的基础。
3.1 三种核心限流算法概述
业界主流限流算法可归纳为三类:令牌桶( Bucket)、漏桶(Leaky Bucket)和滑动窗口(Sliding Window)。下表从核心机制、突发处理能力、内存开销和典型实现四个维度进行对比:
| 对比维度 | 令牌桶 | 漏桶 | 滑动窗口 |
|---|---|---|---|
| 核心机制 | 以固定速率生成令牌,请求需获取令牌才能通过 | 请求以固定速率流出,超出桶容量的请求被丢弃 | 将时间划分为小格子,统计滑动时间窗口内的请求总数 |
| 突发处理 | 允许一定程度的突发(桶内令牌累积) | 严格平滑,不允许突发 | 取决于窗口粒度,粒度越细突发容忍度越低 |
| 内存开销 | 低(仅维护令牌数和上次填充时间戳) | 低(仅维护队列长度和流出速率) | 中到高(需维护窗口内所有时间片的计数器) |
| 典型实现 | Guava RateLimiter、Redis-Cell | Nginx ngx_http_limit_req_module | Sentinel 滑动窗口、Go 的 time/rate |
压测视角提示:在性能压测中,令牌桶的“突发容忍”特性容易导致短时 QPS 超过设定阈值,而漏桶则会将流量强制削平。选择哪种算法,直接决定了压测曲线是“尖刺型”还是“平缓型”。
3.2 令牌桶算法( Bucket)
令牌桶是最灵活的限流算法,允许一定程度的突发流量,同时保证长期平均速率可控。
- 工作原理:系统以恒定速率
r向桶中放入令牌,桶容量上限为b。每个请求到达时,从桶中取走一个令牌;若桶空,则请求被拒绝或等待。 - 关键参数:令牌生成速率
r(决定平均 QPS)、桶容量b(决定突发大小)。 - 适用场景:API 网关、微服务间调用、需要容忍短时流量突刺的业务(如秒杀开始瞬间)。
// 伪代码:令牌桶核心逻辑
class TokenBucket {
long capacity; // 桶容量
long tokens; // 当前令牌数
long lastRefill; // 上次填充时间戳
double refillRate; // 令牌生成速率(个/秒)
boolean tryAcquire() {
refill(); // 先补充令牌
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
void refill() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefill;
long newTokens = (long)(elapsed * refillRate / 1000);
tokens = Math.min(capacity, tokens + newTokens);
lastRefill = now;
}
}
压测陷阱:令牌桶的“预消费”模式(如 Guava RateLimiter 的 warmup 阶段)会导致压测初期 QPS 偏低,后期突然升高。压测时必须预热足够时间,否则拐点数据会严重失真。
3.3 漏桶算法(Leaky Bucket)
漏桶算法将请求视为水滴,以恒定速率从桶底漏出,桶满则溢出丢弃。它强制输出流量完全平滑,没有任何突发。
- 工作原理:请求先进入一个 FIFO 队列(桶),后台以固定速率
r从队列头部取出请求进行处理。队列长度上限为b,超出部分直接丢弃。 - 关键参数:流出速率
r(决定最大处理 QPS)、队列深度b(决定最大等待请求数)。 - 适用场景:数据库写入限流、日志收集、消息队列消费端限流——这些场景要求输出速率稳定。
// 伪代码:漏桶核心逻辑
class LeakyBucket {
long capacity; // 桶容量(队列最大长度)
long water; // 当前桶中水量(队列长度)
long lastLeak; // 上次漏水时间戳
double leakRate; // 漏水速率(请求/秒)
boolean tryAcquire() {
leak(); // 先漏水
if (water < capacity) {
water++;
return true;
}
return false;
}
void leak() {
long now = System.currentTimeMillis();
long elapsed = now - lastLeak;
long leaked = (long)(elapsed * leakRate / 1000);
water = Math.max(0, water - leaked);
lastLeak = now;
}
}
性能开销对比:漏桶的队列实现通常需要加锁保护
water 计数器的原子性,在高并发下锁竞争比令牌桶的 CAS 操作更重。压测数据显示,漏桶在 10 万 QPS 级别时,CPU 开销比令牌桶高约 15%-20%。
3.4 滑动窗口算法(Sliding Window)
滑动窗口是对固定时间窗口(如 1 秒)的改进,通过细粒度时间片来避免“窗口边界流量突刺”问题。
- 工作原理:将时间窗口划分为
n个等长的小格子(如 1 秒窗口分为 10 个 100ms 格子)。每个格子维护一个计数器。滑动窗口向前移动时,丢弃最旧的格子,加入最新的格子,统计所有格子计数之和。 - 关键参数:窗口总时长
W、格子数量n(决定精度)、阈值limit。 - 适用场景:需要精确控制单位时间内的请求总数,且不能容忍突发(如登录接口防刷、短信验证码限流)。
// 伪代码:滑动窗口核心逻辑
class SlidingWindow {
int windowSize; // 窗口总时长(毫秒)
int slotCount; // 格子数量
int[] slotCounters; // 每个格子的计数
long[] slotTimestamps; // 每个格子的起始时间戳
int currentSlot; // 当前格子索引
int totalCount; // 窗口内总请求数
boolean tryAcquire() {
long now = System.currentTimeMillis();
advanceWindow(now); // 滑动窗口
if (totalCount < limit) {
slotCounters[currentSlot]++;
totalCount++;
return true;
}
return false;
}
void advanceWindow(long now) {
// 计算当前时间对应的格子索引
int newSlot = (int)((now / slotDuration) % slotCount);
while (currentSlot != newSlot) {
// 移动到下一个格子,丢弃最旧格子数据
currentSlot = (currentSlot + 1) % slotCount;
totalCount -= slotCounters[currentSlot];
slotCounters[currentSlot] = 0;
slotTimestamps[currentSlot] = now;
}
}
}
3.5 算法对比与选型决策树
以下 SVG 图展示了三种算法的核心流程对比,以及基于业务需求的选型决策路径:
3.6 压测视角的关键差异总结
从性能压测和资源开销角度,三种算法在以下三个维度存在显著差异:
- QPS 拐点形态:
- 令牌桶:拐点呈“缓升-陡升”形态,突发令牌耗尽后 QPS 迅速回落至设定阈值。
- 漏桶:拐点呈“平顶”形态,一旦队列满,QPS 立即被削平,无任何尖刺。
- 滑动窗口:拐点呈“锯齿”形态,窗口边界处可能出现小幅波动,格子越细锯齿越小。
- CPU 开销:
- 令牌桶:每次请求仅需一次 CAS 操作更新令牌数,CPU 开销最低。
- 漏桶:需要维护队列状态,加锁开销略高于令牌桶。
- 滑动窗口:每次请求需更新当前格子计数器并可能滑动窗口,格子数越多 CPU 开销越大。
- 内存开销:
- 令牌桶和漏桶:仅需 2 个变量(当前计数 + 时间戳),内存占用可忽略。
- 滑动窗口:需要维护
n个格子的计数器和时间戳数组,n通常为 10~100,内存开销随精度线性增长。
压测实验设计建议:在后续章节的压测实验中,建议对三种算法分别设置相同的 QPS 阈值(如 1000 QPS),然后使用阶梯递增负载(从 500 QPS 到 5000 QPS),观察并记录每种算法的实际通过 QPS 曲线、CPU 使用率和内存占用。这将直观展示“QPS 拐点”与“资源开销”之间的 trade-off。