7、限流模式:令牌桶算法、漏桶算法、计数器算法、滑动窗口、Guava RateLimiter与Sentinel
限流这个话题,说白了就是「别让流量把系统冲垮」。我早年做电商大促时吃过这个亏——流量一上来,数据库连接池直接打满,整个系统雪崩。从那以后,限流就成了我设计高可靠系统时的必备手段。
今天咱们把限流这块掰开揉碎讲清楚。从算法原理到工程落地,我会结合自己的踩坑经历,帮你建立一套完整的限流知识体系。
核心观点:限流不是拒绝用户,而是保护系统。好的限流策略,能让系统在洪峰下依然稳定服务。
7.1 四种经典限流算法
先看算法层面。我习惯把这四种算法分成两类:计数器类和队列类。计数器类简单粗暴,队列类平滑优雅。
7.1.1 计数器算法
这是最朴素的思路。我设定一个时间窗口(比如1秒),窗口内最多处理100个请求。超过就拒绝。
// 伪代码示例
public class CounterLimiter {
private long windowStart = System.currentTimeMillis();
private int count = 0;
private final int limit = 100;
private final long windowSize = 1000; // 1秒
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart > windowSize) {
windowStart = now;
count = 0;
}
if (count < limit) {
count++;
return true;
}
return false;
}
}
嗯,这里要注意。计数器算法有个经典问题——临界突变。举个例子:窗口是1秒100次,如果第0.9秒来了100次,第1.0秒又来了100次,那实际上0.2秒内就处理了200次请求。这显然不合理。
我曾经在生产环境用过纯计数器限流,结果发现流量曲线像锯齿一样剧烈抖动。后来才意识到是临界突变导致的。
7.1.2 滑动窗口算法
滑动窗口就是为了解决临界突变而生的。它把大窗口切成多个小格子,每个格子独立计数。窗口滑动时,只保留最近N个格子的数据。
// 滑动窗口核心逻辑
public class SlidingWindowLimiter {
private final int windowSize = 10; // 10个格子
private final int[] counts = new int[windowSize];
private int currentIndex = 0;
private final int limitPerWindow = 100;
public boolean tryAcquire() {
long now = System.currentTimeMillis();
int index = (int)(now / 100) % windowSize; // 每100ms一个格子
if (index != currentIndex) {
// 切换到新格子,重置旧格子
currentIndex = index;
counts[index] = 0;
}
int total = sum(counts);
if (total < limitPerWindow) {
counts[index]++;
return true;
}
return false;
}
}
你想想看,格子切得越细,流量就越平滑。我一般建议切成10~20个格子,太细了反而增加计算开销。
7.1.3 漏桶算法
漏桶的思路很形象——想象一个桶,底部有个小洞。水(请求)以恒定速率从洞口流出。如果桶满了,新来的水就溢出(被拒绝)。
我个人习惯用漏桶处理突发流量。它能把不规则的请求流,变成平滑的输出流。但缺点也很明显:如果桶容量设置太小,正常流量也会被限。
// 漏桶算法示例
public class LeakyBucket {
private long lastLeakTime = System.currentTimeMillis();
private int water = 0;
private final int capacity = 100;
private final int leakRate = 10; // 每秒漏10个
public synchronized boolean tryAcquire() {
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;
}
}
7.1.4 令牌桶算法
令牌桶是我最常用的算法。它跟漏桶相反——不是控制流出速率,而是控制流入速率。系统以固定速率往桶里放令牌,请求来了就拿令牌,拿不到就等或拒绝。
令牌桶的好处是允许一定程度的突发。比如桶里有100个令牌,瞬间来100个请求都能通过。这在处理秒杀场景时特别有用。
// 令牌桶算法示例
public class TokenBucket {
private long lastRefillTime = System.currentTimeMillis();
private int tokens = 0;
private final int capacity = 100;
private final int refillRate = 10; // 每秒放10个令牌
public synchronized boolean tryAcquire() {
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;
}
}
我的建议:如果业务允许突发流量,优先选令牌桶。如果要求绝对平滑,选漏桶。滑动窗口适合对精度要求高的场景。
7.2 工程实践:Guava RateLimiter
聊完算法,咱们看看工程落地。Guava的RateLimiter是我用得最多的限流组件。它基于令牌桶实现,但做了不少优化。
// Guava RateLimiter 使用示例
import com.google.common.util.concurrent.RateLimiter;
public class GuavaDemo {
// 每秒放10个令牌
private final RateLimiter limiter = RateLimiter.create(10.0);
public void handleRequest() {
// 等待获取令牌(阻塞)
double waitTime = limiter.acquire();
System.out.println("等待了 " + waitTime + " 秒");
// 处理业务逻辑
}
public boolean tryHandle() {
// 非阻塞尝试
return limiter.tryAcquire(100, TimeUnit.MILLISECONDS);
}
}
我记得有一次线上问题,就是RateLimiter的acquire()方法导致接口响应变慢。后来改成tryAcquire()加超时,问题就解决了。说白了,阻塞等待在某些场景下并不合适。
关键点:RateLimiter支持预热模式(WarmUp)。刚启动时令牌生成速率慢,逐渐加速到目标速率。这能避免冷启动时流量冲击。
7.3 企业级方案:Sentinel
如果系统规模大了,Guava就有点不够用了。这时候我推荐阿里开源的Sentinel。它不光做限流,还集成了熔断、降级、系统保护等功能。
Sentinel的限流策略更丰富:
- QPS限流:按每秒请求数限流
- 线程数限流:限制并发线程数
- 关联限流:当关联资源达到阈值时,限流当前资源
- 链路限流:针对调用链路限流
// Sentinel 限流配置示例
public class SentinelDemo {
public static void main(String[] args) {
// 定义限流规则
FlowRule rule = new FlowRule();
rule.setResource("orderService");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100); // QPS限制为100
FlowRuleManager.loadRules(Collections.singletonList(rule));
// 业务代码中埋点
try (Entry entry = SphU.entry("orderService")) {
// 处理订单
} catch (BlockException e) {
// 被限流了,执行降级逻辑
System.out.println("请求被限流,请稍后重试");
}
}
}
我曾经在微服务架构中只用单机限流,结果某个节点挂了,流量全打到另一个节点上,直接打穿。后来改用Sentinel的集群限流,才真正稳下来。
7.4 算法对比与选型
我整理了一张对比表,方便你快速决策:
| 算法 | 允许突发 | 平滑度 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 计数器 | 否 | 低 | 低 | 简单场景,精度要求不高 |
| 滑动窗口 | 否 | 中 | 中 | 需要精确控制流量 |
| 漏桶 | 否 | 高 | 中 | 需要绝对平滑输出 |
| 令牌桶 | 是 | 高 | 中 | 允许突发,通用场景 |
7.5 知识体系总览
下面这张图,是我梳理的限流模式知识体系。你可以把它当作一个速查地图:
7.6 避坑指南
最后,分享几个我踩过的坑:
- 不要全局限流:不同接口的流量特征不同,应该按接口粒度配置限流规则
- 注意分布式一致性:多节点部署时,单机限流可能不准确,考虑用Redis实现分布式限流
- 限流不等于降级:被限流后要有合理的降级策略,比如返回友好提示、走缓存等
- 监控要跟上:限流触发时一定要有日志和告警,否则出了问题都不知道
我的习惯:每次上线限流策略,我都会先在灰度环境压测一周,观察流量曲线和响应时间。没问题了再全量发布。
限流这件事,说起来简单,做起来全是细节。希望今天的内容能帮你少走弯路。记住,限流的最终目的不是限制用户,而是让系统在极端情况下依然能提供稳定的服务。