← Back to Blog

技术实战——本地限流器

后端限流并发

本地限流最常见的四种算法(固定窗口、滑动窗口、漏桶、令牌桶)的直觉、优缺点以及实现思路。

1. 为什么需要本地限流器?

想象一下,你开了一家极度火爆的网红汉堡店。平时店里只有 3 个厨师,每分钟能做 10 个汉堡,生意井井有条。 突然某天,某位大 V 在社交媒体上推荐了你的店,导致门外瞬间涌来 500 个顾客! 如果让这 500 个人同时冲进厨房点单,你的厨师会瞬间崩溃,不仅汉堡做不出来,甚至连厨房都可能被砸了。

在后端系统中,这就是典型的突发流量打穿系统。为了保护我们的服务器(CPU、线程池、数据库连接等)不被瞬间流量压垮,我们需要一个“门卫”——这就是限流器 (Rate Limiter)。 本文主要讨论的是本地限流器:即不依赖 Redis,只在当前应用进程内存中生效的限流算法。

2. 常见限流算法解析

2.1 固定窗口 (Fixed Window)

最直觉的限流方式。就像汉堡店门口的保安,按小时发号:每小时最多只放 100 个人进去。它的实现极度简单:维护一个计数器,到了下一个时间窗口就把计数器清零。

FixedWindowRateLimiter.java
123456789101112131415161718192021
class FixedWindowRateLimiter {
long windowStart = System.currentTimeMillis();
int counter = 0;
final int capacity = 100;
final long windowSizeMs = 1000;
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 进入下一个窗口,重置计数器
if (now - windowStart > windowSizeMs) {
windowStart = now;
counter = 0;
}
// 尝试放行
if (counter < capacity) {
counter++;
return true;
}
return false;
}
}
致命缺陷:边界突刺效应。
假设限制是每分钟 100 次。在 00:59 的时候,瞬间来了 100 个请求(全部放行);紧接着 01:00 的时候,窗口刷新,又瞬间来了 100 个请求(也全部放行)。 结果就是在短短两秒内系统承受了 200 个请求的冲击,限流效果大打折扣!

2.2 滑动窗口 (Sliding Window)

为了解决固定窗口的“突刺”问题,滑动窗口应运而生。它不再切分固定的时间块,而是随时往回看过去一分钟的请求量。就像保安手里拿着一个精准的秒表,每次有人要进门,他都会翻阅记录本:“过去 60 秒内,进去了多少人?”

SlidingWindowRateLimiter.java
12345678910111213141516171819
class SlidingWindowRateLimiter {
Queue<Long> requests = new LinkedList<>();
final int capacity = 100;
final long windowSizeMs = 1000;
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 丢弃掉窗口之外的旧记录
while (!requests.isEmpty() && now - requests.peek() > windowSizeMs) {
requests.poll();
}
// 检查当前窗口内的请求数
if (requests.size() < capacity) {
requests.add(now);
return true;
}
return false;
}
}

滑动窗口非常平滑,但代价是内存开销很大,因为你需要记录每一个请求的时间戳。对于高并发场景,这个队列会变得非常长,并且清理旧数据的操作也会带来性能损耗。

2.3 漏桶 (Leaky Bucket)

想象一个底部有小孔的桶,水(请求)以任意速度倒进去,但桶底漏水的速度是绝对匀速的。如果倒水的速度太快,桶满了,多余的水就会溢出(请求被拒绝)。

漏桶算法的核心目的是平滑流量。无论外部流量多么狂暴,系统处理请求的速率始终是一条直线。它非常适合用来保护极其脆弱、一点突发都承受不了的下游系统。

2.4 令牌桶 (Token Bucket)

漏桶虽然平滑,但有时候我们希望系统能处理一定程度的突发流量(只要系统当时有空闲)。这时候,工程界最受欢迎的算法——令牌桶就登场了。

  • 系统以固定的速率向桶里放入“令牌”(Token)。
  • 桶的容量是有限的(Capacity)。
  • 每个请求过来时,必须从桶里拿走一个令牌才能被处理。
  • 如果没有令牌了,请求就被拒绝。

为什么它能应对突发? 因为如果之前有一段时间没有请求,桶里就会攒满令牌。这时如果瞬间来了一大波请求,它们可以瞬间把桶里的令牌拿空,从而实现了一次“合法”的突发处理。

在真实工程中(如 Guava 的 RateLimiter),令牌桶并不是真的启动一个后台线程去定时发令牌(那样太浪费资源了)。而是采用了惰性计算 (Lazy Computation)

TokenBucket.java
123456789101112131415161718192021
class TokenBucket {
long lastRefillTime = System.currentTimeMillis();
double currentTokens = 0;
final double capacity = 10;
final double refillRatePerMs = 0.01; // 10 tokens / second
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 惰性计算:算出距离上次请求,这段时间生成了多少新令牌
double generatedTokens = (now - lastRefillTime) * refillRatePerMs;
currentTokens = Math.min(capacity, currentTokens + generatedTokens);
lastRefillTime = now;
// 尝试消耗令牌
if (currentTokens >= 1) {
currentTokens -= 1;
return true;
}
return false;
}
}

3. 令牌桶的交互演示

为了更直观地理解令牌桶是如何“攒令牌”并在瞬间应对突发流量的,你可以尝试点击下方的按钮进行模拟。

交互式演示:令牌桶 (Token Bucket)

令牌桶

0 / 8
⚙️

Generator
2/sec

请求网关 (Gateway)
等待流量接入...

4. 总结与选型建议

  • 追求绝对简单,且不在乎短时间的流量突刺:固定窗口
  • 需要非常严格且平滑的速率控制,且内存预算充足:滑动窗口
  • 需要强行将输出速率变为绝对匀速(保护老旧下游):漏桶
  • 最推荐的通用方案,既能限制平均速率,又允许一定的流量突发:令牌桶