技术实战——本地限流器
本地限流最常见的四种算法(固定窗口、滑动窗口、漏桶、令牌桶)的直觉、优缺点以及实现思路。
1. 为什么需要本地限流器?
想象一下,你开了一家极度火爆的网红汉堡店。平时店里只有 3 个厨师,每分钟能做 10 个汉堡,生意井井有条。 突然某天,某位大 V 在社交媒体上推荐了你的店,导致门外瞬间涌来 500 个顾客! 如果让这 500 个人同时冲进厨房点单,你的厨师会瞬间崩溃,不仅汉堡做不出来,甚至连厨房都可能被砸了。
在后端系统中,这就是典型的突发流量打穿系统。为了保护我们的服务器(CPU、线程池、数据库连接等)不被瞬间流量压垮,我们需要一个“门卫”——这就是限流器 (Rate Limiter)。 本文主要讨论的是本地限流器:即不依赖 Redis,只在当前应用进程内存中生效的限流算法。
2. 常见限流算法解析
2.1 固定窗口 (Fixed Window)
最直觉的限流方式。就像汉堡店门口的保安,按小时发号:每小时最多只放 100 个人进去。它的实现极度简单:维护一个计数器,到了下一个时间窗口就把计数器清零。
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 秒内,进去了多少人?”
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):
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)
令牌桶
Generator
2/sec
4. 总结与选型建议
- 追求绝对简单,且不在乎短时间的流量突刺:固定窗口
- 需要非常严格且平滑的速率控制,且内存预算充足:滑动窗口
- 需要强行将输出速率变为绝对匀速(保护老旧下游):漏桶
- 最推荐的通用方案,既能限制平均速率,又允许一定的流量突发:令牌桶