4. 限流算法:从计数器到令牌桶
限流算法,说白了就是决定「谁先过、谁等着、谁走人」的那套规则。我刚开始做采集系统时,觉得限流嘛,搞个计数器不就行了?结果线上被教做人——计数器在边界上的毛刺,能把整个系统打穿。
这一章,我把四种主流算法掰开揉碎讲。每种我都会说清楚原理、适用场景,以及我踩过的坑。
4.1 计数器算法:简单但危险
计数器是最直观的限流方式。你设定一个时间窗口,比如1秒,窗口内最多处理100个请求。来了请求就加1,超过100就拒绝。
// 伪代码:计数器限流
class CounterLimiter {
int maxRequests = 100;
int currentCount = 0;
long windowStart = System.currentTimeMillis();
long windowSize = 1000; // 1秒
boolean allow() {
long now = System.currentTimeMillis();
if (now - windowStart > windowSize) {
// 新窗口,重置
windowStart = now;
currentCount = 0;
}
if (currentCount < maxRequests) {
currentCount++;
return true;
}
return false;
}
}
代码看着没问题吧?但实际跑起来,你会遇到一个经典问题——临界突变。
所以计数器只适合对精度要求不高的场景,比如后台报表、非实时监控。你要是做支付接口的限流,千万别用这个。
4.2 滑动窗口算法:把窗口切细
滑动窗口解决了计数器的边界问题。思路很简单:把一个大窗口切成多个小格子,每个格子独立计数。窗口滑动时,只保留最近N个格子的数据。
举个例子。1秒的窗口,切成10个100ms的格子。每个格子维护自己的计数器。当时间前进时,最老的格子被丢弃,新格子加入。
// 伪代码:滑动窗口限流
class SlidingWindowLimiter {
int windowSize = 10; // 10个格子
long slotSize = 100; // 每个格子100ms
int[] slots = new int[windowSize];
int maxPerSlot = 10; // 每个格子最多10个请求
boolean allow() {
int currentSlot = (int)(System.currentTimeMillis() / slotSize % windowSize);
// 检查当前窗口内所有格子的总和
int total = 0;
for (int count : slots) {
total += count;
}
if (total < maxPerSlot * windowSize) {
slots[currentSlot]++;
return true;
}
return false;
}
}
滑动窗口的精度取决于格子数量。格子越多,精度越高,但内存开销也越大。我个人习惯是切成10-20个格子,既能平滑流量,又不至于太耗资源。
4.3 漏桶算法:匀速流出
漏桶算法的思路很形象。想象一个桶,上面进水,下面漏水。不管上面倒得多猛,下面始终以固定速率滴水。桶满了,水就溢出去(请求被拒绝)。
这个算法的核心是强制匀速。它不管请求到达的节奏,只管处理速率是恒定的。
// 伪代码:漏桶限流
class LeakyBucketLimiter {
int capacity = 100; // 桶容量
int water = 0; // 当前水量
long leakRate = 10; // 每秒漏10滴
long lastLeakTime = System.currentTimeMillis();
boolean allow() {
long now = System.currentTimeMillis();
// 先漏水
long elapsed = now - lastLeakTime;
int leaked = (int)(elapsed * leakRate / 1000);
water = Math.max(0, water - leaked);
lastLeakTime = now;
if (water < capacity) {
water++;
return true;
}
return false;
}
}
漏桶适合需要平滑的场景。比如你对接第三方API,对方要求每秒不超过50次,而且必须均匀分布。这时候漏桶就是最佳选择。
但漏桶也有缺点。它不能应对突发流量——即使系统有空闲资源,漏桶也不会加快处理。说白了,它有点「死板」。
4.4 令牌桶算法:允许突发
令牌桶是我最常用的算法。它跟漏桶相反——不是控制流出,而是控制流入。系统以固定速率往桶里放令牌,请求来了就拿令牌。有令牌就处理,没有就等或拒绝。
关键区别在于:令牌桶允许一定程度的突发。如果桶里攒了100个令牌,突然来100个请求,可以一次性全部处理。
// 伪代码:令牌桶限流
class TokenBucketLimiter {
int capacity = 100; // 桶容量
int tokens = 100; // 当前令牌数
double refillRate = 10; // 每秒补充10个令牌
long lastRefillTime = System.currentTimeMillis();
boolean allow() {
long now = System.currentTimeMillis();
// 补充令牌
long elapsed = now - lastRefillTime;
int newTokens = (int)(elapsed * refillRate / 1000);
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
}
4.5 四种算法对比
我把四种算法的核心差异整理成了一张表。你选型时可以直接参考。
| 算法 | 核心思想 | 是否允许突发 | 精度 | 适用场景 |
|---|---|---|---|---|
| 计数器 | 固定窗口计数 | 是(有边界毛刺) | 低 | 非关键监控、后台报表 |
| 滑动窗口 | 多格子滑动统计 | 是(可控) | 中 | 通用场景,精度要求一般 |
| 漏桶 | 固定速率流出 | 否 | 高 | 第三方API对接、流量整形 |
| 令牌桶 | 固定速率补充令牌 | 是(可控) | 高 | 高并发采集、突发流量场景 |
4.6 算法选型决策流程
你可能会问:那我到底该用哪个?别急,我画了一张决策流程图,你跟着走一遍就知道答案了。
跟着图走一遍,你会发现大部分高并发采集场景都会落到令牌桶上。这也是为什么Guava的RateLimiter、Redis的令牌桶实现,在实际项目中用得最多。
嗯,这一章的内容就到这里。四种算法各有千秋,但记住一点:没有银弹。选型时多想想你的业务场景,别盲目追求「高级」算法。