在高并发系统中,限流是保护服务稳定性的重要手段。以下是几种常见限流算法的核心原理、优缺点及适用场景分析:
A. 固定窗口计数器(Fixed Window Counter)
核心原理
- 将时间划分为固定大小的窗口(如 1 秒)。
- 每个窗口内维护一个计数器,记录请求次数。
- 当请求到达时,若计数器超过阈值,则拒绝请求。
- 窗口结束后,计数器重置为 0。
示例代码
public class FixedWindowRateLimiter {
private final long windowSizeMs; // 窗口大小(毫秒)
private final int limit; // 窗口内最大请求数
private long windowStartMs; // 窗口开始时间
private int counter; // 当前窗口内请求计数器
public FixedWindowRateLimiter(long windowSizeMs, int limit) {
this.windowSizeMs = windowSizeMs;
this.limit = limit;
this.windowStartMs = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStartMs > windowSizeMs) { // 检查是否需要重置窗口
windowStartMs = now;
counter = 0;
}
if (counter < limit) { // 检查是否超过限流阈值
counter++;return true;
}
return false;
}
}
优缺点
- 优点:实现简单,内存占用少。
- 缺点:存在临界问题(突刺现象),例如在窗口切换瞬间可能允许双倍请求通过。
- 适用场景:对精度要求不高的场景,如统计类接口限流。
B. 滑动窗口计数器(Sliding Window Counter)
核心原理
- 将固定窗口划分为更小的时间槽(如 1 秒窗口分为 10 个 100ms 的槽)。
- 每个时间槽维护独立计数器。
- 当请求到达时,统计当前时间窗口内所有时间槽的计数器总和,超过阈值则拒绝。
- 时间窗口滑动时,丢弃最早时间槽的数据。
示例代码
public class SlidingWindowRateLimiter {
private final int windowSize; // 窗口大小(槽数量)
private final long slotSizeMs; // 每个槽的大小(毫秒)
private final int limit; // 窗口内最大请求数
private final AtomicInteger[] slots; // 每个槽的计数器
private int currentSlot; // 当前槽索引
public SlidingWindowRateLimiter(int windowSize, long slotSizeMs, int limit) {
this.windowSize = windowSize;
this.slotSizeMs = slotSizeMs;
this.limit = limit;
this.slots = new AtomicInteger[windowSize];
for (int i = 0; i < windowSize; i++) {
slots[i] = new AtomicInteger(0);
}
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
// 计算当前时间对应的槽索引
int slotIndex = (int) ((now / slotSizeMs) % windowSize);
// 重置过期的槽
resetExpiredSlots(now);
// 计算当前窗口内总请求数
int total = getTotalRequests();
// 检查是否超过限流阈值
if (total < limit) {
slots[slotIndex].incrementAndGet();
return true;
}
return false;
}
private void resetExpiredSlots(long now) {
// 计算当前时间对应的槽时间戳
long currentSlotTime = (now / slotSizeMs) * slotSizeMs;
// 重置所有过期的槽
for (int i = 0; i < windowSize; i++) {
long slotTime = currentSlotTime - (windowSize - 1) * slotSizeMs + i * slotSizeMs;
if (slotTime < currentSlotTime - (windowSize - 1) * slotSizeMs) {
slots[i].set(0);
}
}
}
private int getTotalRequests() {
int total = 0;
for (AtomicInteger slot : slots) {
total += slot.get();
}
return total;
}
}
优缺点
- 优点:解决了固定窗口的临界问题,限流更精确。
- 缺点:实现复杂度增加,内存占用变大(需维护多个计数器)。
- 适用场景:对限流精度有一定要求的场景,如 API 网关限流。
C. 令牌桶算法(Token Bucket)
核心原理
- 系统以固定速率向桶中添加令牌(如每秒 100 个)。
- 桶有固定容量,满时令牌不再增加。
- 请求到达时需从桶中获取令牌,成功则处理,失败则拒绝。
- 允许一定程度的突发请求(桶中令牌足够时)。
示例代码
public class TokenBucketRateLimiter {
private final long capacity; // 令牌桶容量
private final double rate; // 令牌生成速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次填充时间(毫秒)
public TokenBucketRateLimiter(long capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity;
this.lastRefillTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
// 填充令牌
refill();
// 尝试获取令牌
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
// 计算从上次填充到现在的时间间隔(秒)
double elapsedSeconds = (now - lastRefillTime) / 1000.0;
if (elapsedSeconds <= 0) {
return;
}
// 计算这段时间应生成的令牌数
double generatedTokens = elapsedSeconds * rate;
// 更新令牌数(不超过容量)
tokens = Math.min(capacity, tokens + generatedTokens);
// 更新上次填充时间
lastRefillTime = now;
}
}
优缺点
- 优点:平滑突发流量,允许一定程度的突发请求,适合应对短暂高并发。
- 缺点:实现较复杂,需要维护令牌生成逻辑。
- 适用场景:需要处理突发流量的场景,如秒杀系统、API 网关。
D. 漏桶算法(Leaky Bucket)
核心原理
- 请求进入漏桶后,以固定速率流出处理(如每秒 100 个)。
- 桶满时,新请求被直接拒绝。
- 无论请求速率如何,处理速率始终保持恒定。
示例代码
public class LeakyBucketRateLimiter {
private final long capacity; // 漏桶容量
private final long rate; // 漏水速率(个/秒)
private long water; // 当前水量
private long lastLeakTime; // 上次漏水时间(毫秒)
public LeakyBucketRateLimiter(long capacity, long rate) {
this.capacity = capacity;
this.rate = rate;
this.water = 0;
this.lastLeakTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
// 漏水
leak();
// 尝试加水
if (water + 1 <= capacity) {
water++;
return true;
}
return false;
}
private void leak() {
long now = System.currentTimeMillis();
// 计算从上次漏水到现在的时间间隔(秒)
double elapsedSeconds = (now - lastLeakTime) / 1000.0;
// 计算这段时间漏出的水量
long leakedWater = (long) (elapsedSeconds * rate);
// 更新当前水量(不能小于0)
water = Math.max(0, water - leakedWater);
// 更新上次漏水时间
lastLeakTime = now;
}
}
优缺点
- 优点:严格控制请求处理速率,平滑流量峰值,适合需要严格限速的场景。
- 缺点:不允许突发流量,即使系统有处理能力。
- 适用场景:对流量稳定性要求极高的场景,如数据库访问限流、流媒体服务。
E. 对比与选择建议
算法 | 固定窗口 | 滑动窗口 | 令牌桶 | 漏桶 |
---|---|---|---|---|
实现复杂度 | 低 | 中 | 中 | 高 |
内存消耗 | 低 | 中 | 中 | 中 |
突发流量处理 | 差(临界问题) | 一般 | 好 | 差 |
流量平滑度 | 差 | 一般 | 较好 | 好 |
处理速率稳定性 | 差 | 一般 | 一般 | 好 |
适用场景 | 统计类接口 | 通用API限流 | 秒杀、网关 | 数据库、流媒体 |
高级应用与优化
- 分布式限流:
- 将令牌桶或漏桶状态存储在 Redis 中,通过 Lua 脚本保证原子性操作。
- 示例:前面提供的基于 Redis 的令牌桶限流实现。
- 自适应限流:
- 根据系统负载(如 CPU、RT)动态调整限流阈值。
- Sentinel 的系统自适应保护就是典型实现。
- 预热限流:
- 系统启动初期逐步提高限流阈值,避免冷启动问题。
- Sentinel 的预热限流算法支持此特性。
- 熔断降级:
- 结合限流与熔断机制,当错误率超过阈值时自动熔断服务。
- Sentinel、Resilience4j 等框架支持此功能。
选择限流算法时,需根据业务场景权衡精度、性能和实现复杂度,必要时可组合多种算法实现多级限流。