限流模式:令牌桶、漏桶、滑动窗口与计数器
限流这个话题,说白了就是「别让流量把系统冲垮」。我做了十几年高并发系统,见过太多因为没做限流而翻车的案例。有一次线上大促,某个接口突然被刷了上千万次请求,数据库连接池瞬间打满,整个服务挂了半小时。嗯,从那以后,限流就成了我架构设计里的标配。
今天咱们聊聊四种经典的限流算法,以及两个工业级的限流工具——Guava RateLimiter 和 Sentinel。我会结合自己的实战经验,把每个方案的优缺点、适用场景都讲透。
一、计数器限流:最简单,但最粗糙
计数器限流,思路很直白:在固定时间窗口内,统计请求次数,超过阈值就拒绝。
// Java 示例:简单计数器限流
public class CounterLimiter {
private final int maxRequests; // 最大请求数
private final long windowSize; // 窗口大小(毫秒)
private long windowStart; // 窗口开始时间
private int counter; // 当前计数
public CounterLimiter(int maxRequests, long windowSize) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
this.windowStart = System.currentTimeMillis();
this.counter = 0;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart > windowSize) {
// 新窗口
windowStart = now;
counter = 0;
}
if (counter < maxRequests) {
counter++;
return true;
}
return false;
}
}
二、滑动窗口:解决临界突变
滑动窗口把时间窗口切分成更小的格子,每个格子独立计数。窗口滑动时,只保留最近的N个格子。
// Java 示例:滑动窗口限流(简化版)
public class SlidingWindowLimiter {
private final int maxRequests;
private final long windowSize;
private final int subWindows; // 子窗口数量
private final long subWindowSize; // 每个子窗口大小
private final AtomicInteger[] counters;
private volatile int currentIndex;
public SlidingWindowLimiter(int maxRequests, long windowSize, int subWindows) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
this.subWindows = subWindows;
this.subWindowSize = windowSize / subWindows;
this.counters = new AtomicInteger[subWindows];
for (int i = 0; i < subWindows; i++) {
counters[i] = new AtomicInteger(0);
}
this.currentIndex = 0;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
int index = (int) ((now % windowSize) / subWindowSize);
// 如果跨窗口,重置旧窗口计数
if (index != currentIndex) {
counters[index].set(0);
currentIndex = index;
}
int total = 0;
for (AtomicInteger c : counters) {
total += c.get();
}
if (total < maxRequests) {
counters[index].incrementAndGet();
return true;
}
return false;
}
}
你想想看,滑动窗口相当于把「粗粒度」的统计变成了「细粒度」的统计。子窗口越多,精度越高,但内存开销也越大。我个人习惯用10个子窗口,精度和性能比较均衡。
三、漏桶算法:削峰填谷,稳如老狗
漏桶的核心思想是「匀速处理」。请求先进入桶里,然后以固定速率漏出。桶满了就丢弃请求。
// C++ 示例:漏桶限流
class LeakyBucket {
private:
int capacity; // 桶容量
int water; // 当前水量
int leakRate; // 漏水速率(请求/秒)
std::chrono::steady_clock::time_point lastLeakTime;
public:
LeakyBucket(int cap, int rate)
: capacity(cap), water(0), leakRate(rate) {
lastLeakTime = std::chrono::steady_clock::now();
}
bool tryAcquire() {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - lastLeakTime).count();
// 先漏水
int leaked = static_cast<int>(elapsed * leakRate / 1000.0);
water = std::max(0, water - leaked);
lastLeakTime = now;
if (water < capacity) {
water++;
return true;
}
return false;
}
};
四、令牌桶:允许突发,更灵活
令牌桶和漏桶正好相反。它以固定速率生成令牌,请求需要拿到令牌才能通过。桶里可以积累令牌,所以允许一定程度的突发流量。
// Java 示例:令牌桶限流
public class TokenBucketLimiter {
private final int capacity; // 桶容量
private final double refillRate; // 令牌生成速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefillTime;
public TokenBucketLimiter(int capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire(int permits) {
refill();
if (tokens >= permits) {
tokens -= permits;
return true;
}
return false;
}
private void refill() {
long now = System.nanoTime();
double elapsed = (now - lastRefillTime) / 1_000_000_000.0;
tokens = Math.min(capacity, tokens + elapsed * refillRate);
lastRefillTime = now;
}
}
令牌桶是我个人最常用的限流算法。为什么?因为它既能保证平均速率,又能应对突发。比如接口平时QPS是1000,突然来了2000的流量,只要桶里有足够的令牌,就能全部放行。但长期来看,平均速率还是1000。
五、四种算法对比
| 算法 | 核心思想 | 突发处理 | 平滑度 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 计数器 | 固定窗口计数 | 差(临界突变) | 差 | 低 | 简单限流,不要求精度 |
| 滑动窗口 | 细粒度窗口统计 | 较好 | 中等 | 中 | API网关、接口限流 |
| 漏桶 | 匀速处理 | 差(丢弃突发) | 最好 | 中 | 数据库写入、消息队列 |
| 令牌桶 | 积累令牌,允许突发 | 好 | 较好 | 中 | 通用限流、微服务 |
六、Guava RateLimiter:开箱即用的令牌桶
Guava 的 RateLimiter 实现了令牌桶算法,而且做了很多优化。比如它支持「预热」模式——刚启动时速率较低,慢慢提升到目标速率。这在应对冷启动场景时特别有用。
// Java 示例:Guava RateLimiter
import com.google.common.util.concurrent.RateLimiter;
public class GuavaRateLimiterDemo {
public static void main(String[] args) {
// 每秒生成10个令牌
RateLimiter limiter = RateLimiter.create(10.0);
// 带预热:前5秒从1/3速率逐渐提升到10
// RateLimiter limiter = RateLimiter.create(10.0, 5, TimeUnit.SECONDS);
for (int i = 0; i < 20; i++) {
// 获取1个令牌,阻塞直到获取成功
double waitTime = limiter.acquire();
System.out.println("请求 " + i + " 等待了 " + waitTime + " 秒");
}
}
}
七、Sentinel:企业级限流降级方案
Sentinel 是阿里开源的流量控制组件,它不只是限流,还支持熔断降级、系统保护等。我参与过的一个电商项目,就是用 Sentinel 做全链路限流,效果非常好。
// Java 示例:Sentinel 限流
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import java.util.ArrayList;
import java.util.List;
public class SentinelDemo {
public static void initFlowRules() {
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource("orderService");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100); // QPS 限制为 100
rules.add(rule);
FlowRuleManager.loadRules(rules);
}
public static void main(String[] args) {
initFlowRules();
while (true) {
try (Entry entry = SphU.entry("orderService")) {
// 业务逻辑
System.out.println("请求通过");
} catch (BlockException e) {
// 限流了,执行降级逻辑
System.out.println("请求被限流,返回降级结果");
}
}
}
}
Sentinel 支持多种限流模式:直接拒绝、Warm Up(预热)、排队等待。它还支持调用链路限流,比如限制某个接口的调用深度。我记得有一次排查线上问题,发现是某个服务循环调用自己,Sentinel 的链路限流直接把它掐断了。
八、避坑指南
我曾经在限流配置上犯过一个低级错误:把令牌桶的容量设得太大,结果突发流量直接打满了后端连接池。后来我总结了几条经验:
- 容量不要超过后端处理能力的2倍。比如后端能扛1000 QPS,令牌桶容量设2000就够了。
- 限流一定要配合降级。被限流的请求不能直接抛异常,要返回友好的提示或走缓存。
- 分布式限流要考虑时钟同步。如果多台机器各自限流,总流量可能超标。可以用 Redis 做分布式计数器。
- 监控限流效果。我习惯在限流日志里记录被拒绝的请求数,如果这个数字突然飙升,说明系统有异常流量。
九、知识体系总览
下面这张图总结了限流模式的核心知识点,方便你快速回顾: