解决什么问题
- 保障核心业务
瞬时流量过高,服务被压垮
- 防刷与防攻击
恶意用户高频光顾,导致服务器宕机(网络爬虫抓取数据、撞库登录尝试、DDoS攻击)
- 防止雪崩
消息消费过快,导致数据库压力过大,性能下降甚至崩溃
服务器CPU飙升、响应变慢、请求积压。
慢导致客户端重试,重试导致更多请求,最终耗尽资源,服务彻底不可用。
更危险的是,故障会像多米诺骨牌一样,
从一个服务蔓延到依赖它的上游服务,导致整个链路崩溃(即雪崩效应
有什么方案
四种限流算法:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法
固定窗口算法
请求到达时,计数器加1。如果计数器超过阈值,则丢弃请求。下一个时间窗口开始时,计数器重置
它将时间划分为固定的时间窗口(例如每分钟、每小时),在每个窗口内限制请求的总数
当请求到达时,检查当前时间窗口内的请求计数是否超过阈值,如果未超过则允许请求并计数加一,
否则拒绝请求,窗口结束时,计数器清零
import java.util.concurrent.atomic.AtomicLong;
public class FixedWindowRateLimiter {
// 时间窗口大小,单位毫秒
private final long windowSizeMs;
// 窗口内允许的最大请求数
private final int maxRequests;
// 当前窗口的开始时间戳
private final AtomicLong windowStart;
// 当前窗口的请求计数器
private final AtomicLong counter;
public FixedWindowRateLimiter(long windowSizeMs, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
this.windowStart = new AtomicLong(System.currentTimeMillis());
this.counter = new AtomicLong(0);
}
public boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
long currentWindowStart = windowStart.get();
// 如果当前时间已经超过当前窗口的结束时间,则重置窗口
if (currentTime - currentWindowStart >= windowSizeMs) {
// 尝试将窗口开始时间更新为当前时间,并重置计数器
// 使用 CAS 保证线程安全,避免多个线程同时重置
if (windowStart.compareAndSet(currentWindowStart, currentTime)) {
counter.set(0);
}
// 如果 CAS 失败,说明其他线程已经重置了窗口,重新获取当前窗口开始时间
currentWindowStart = windowStart.get();
}
// 获取当前窗口的请求计数
long currentCount = counter.get();
if (currentCount < maxRequests) {
// 尝试递增计数器,使用 CAS 避免并发问题
return counter.compareAndSet(currentCount, currentCount + 1);
}
return false;
}
}
public class Demo {
public static void main(String[] args) throws InterruptedException {
// 创建一个窗口大小为 1 秒,最大请求数为 5 的限流器
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1000, 5);
// 模拟 10 个请求
for (int i = 0; i < 10; i++) {
if (limiter.tryAcquire()) {
System.out.println("请求 " + i + " 通过");
} else {
System.out.println("请求 " + i + " 被限流");
}
Thread.sleep(200); // 每隔 200ms 发送一次请求
}
}
}
优点
- 实现简单,易于理解。
- 内存占用小,只需记录窗口开始时间和计数器。
- 在窗口内能平滑地限制流量。
缺点
- 临界问题(边界突刺):在两个窗口的交界处,可能出现瞬间流量超过阈值的情况。
- 例如窗口大小为 1 分钟,阈值为 100,第一个窗口的最后 1 秒内来了 100 个请求,第二个窗口的开始 1 秒内又来了 100 个请求, 那么在 2 秒内就处理了 200 个请求,超过了平均速率。 无法平滑处理突发流量,只能在窗口粒度上限制总量
滑动窗口算法
滑动窗口算法(Sliding Window)是对固定窗口算法的改进,旨在解决固定窗口在边界处的流量突刺问题。它通过将时间窗口进一步细分为多个小格子,并持续滑动窗口来更平滑地统计请求量
两种常见实现方式
- 基于滑动日志
记录每个请求的时间戳,每次请求时统计当前时间往前一个窗口内的请求数。优点是精确,缺点是存储开销大。
- 基于滑动窗口计数器
将时间窗口划分为固定数量的桶(比如5个桶,每个桶代表窗口的一部分),每个桶独立计数。随着时间推进,桶被循环使用。这是一种近似但高效的实现。
-
基于滑动日志的实现(使用队列)
import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger;
public class SlidingWindowLogRateLimiter { private final long windowSizeMs; // 窗口大小(毫秒) private final int maxRequests; // 最大请求数 private final Queue timestamps; // 存储请求时间戳
public SlidingWindowLogRateLimiter(long windowSizeMs, int maxRequests) { this.windowSizeMs = windowSizeMs; this.maxRequests = maxRequests; this.timestamps = new ConcurrentLinkedQueue<>(); } public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); long boundary = now - windowSizeMs; // 移除窗口之前的时间戳 while (!timestamps.isEmpty() && timestamps.peek() < boundary) { timestamps.poll(); } // 检查当前窗口请求数 if (timestamps.size() < maxRequests) { timestamps.offer(now); return true; } return false; }}
-
基于滑动窗口计数器的实现(循环数组)
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong;
public class SlidingWindowCounterRateLimiter { private final int windowSizeSeconds; // 窗口大小(秒) private final int maxRequests; // 最大请求数 private final int bucketCount; // 桶数量 private final AtomicLong[] bucketTimestamps; // 每个桶的时间戳(毫秒) private final AtomicInteger[] bucketCounters; // 每个桶的计数 private final AtomicLong currentBucketIndex; // 当前桶索引
public SlidingWindowCounterRateLimiter(int windowSizeSeconds, int maxRequests, int bucketCount) { this.windowSizeSeconds = windowSizeSeconds; this.maxRequests = maxRequests; this.bucketCount = bucketCount; this.bucketTimestamps = new AtomicLong[bucketCount]; this.bucketCounters = new AtomicInteger[bucketCount]; for (int i = 0; i < bucketCount; i++) { bucketTimestamps[i] = new AtomicLong(0); bucketCounters[i] = new AtomicInteger(0); } this.currentBucketIndex = new AtomicLong(0); } public boolean tryAcquire() { long now = System.currentTimeMillis(); long bucketTimeInterval = windowSizeSeconds * 1000L / bucketCount; // 每个桶的时间跨度 // 计算当前时间应该落在哪个桶 long currentBucket = (now / bucketTimeInterval) % bucketCount; long bucketStartTime = (now / bucketTimeInterval) * bucketTimeInterval; // 如果当前桶的时间戳不是当前时间周期,说明需要重置该桶 if (bucketTimestamps[(int) currentBucket].get() != bucketStartTime) { // 使用 CAS 或 synchronized 保证重置的原子性,这里简化用 synchronized synchronized (this) { if (bucketTimestamps[(int) currentBucket].get() != bucketStartTime) { bucketTimestamps[(int) currentBucket].set(bucketStartTime); bucketCounters[(int) currentBucket].set(0); } } } // 统计整个窗口内的总请求数 long windowStartTime = now - windowSizeSeconds * 1000L; int totalRequests = 0; for (int i = 0; i < bucketCount; i++) { if (bucketTimestamps[i].get() >= windowStartTime) { totalRequests += bucketCounters[i].get(); } } if (totalRequests < maxRequests) { bucketCounters[(int) currentBucket].incrementAndGet(); return true; } return false; }}
优点
内存占用固定(与桶数相关),性能高,适合高并发。
缺点
存在一定近似误差(因为统计的是每个桶的累积值,而不是精确到每个请求)。
漏桶算法
介绍
桶有一个固定容量(capacity),用来存放待处理的请求。 请求到达时,如果桶未满,则放入桶中;如果桶已满,则拒绝请求。 桶以一个恒定的速率(leakRate)漏水,即每隔一定时间从桶中取出一个请求进行处理。 与令牌桶相比,漏桶的输出速率是恒定的,可以很好地平滑突发流量,而令牌桶则允许一定程度的突发
基于队列的漏桶
import java.util.concurrent.*;
public class LeakyBucketQueueBased {
private final BlockingQueue<Runnable> bucket; // 桶队列
private final ScheduledExecutorService scheduler; // 定时漏水线程池
public LeakyBucketQueueBased(int capacity, long leakRatePerSecond) {
this.bucket = new LinkedBlockingQueue<>(capacity);
this.scheduler = Executors.newScheduledThreadPool(1);
// 以固定速率漏水:每隔 (1000 / leakRatePerSecond) 毫秒处理一个请求
long periodMs = 1000 / leakRatePerSecond;
scheduler.scheduleAtFixedRate(() -> {
Runnable task = bucket.poll(); // 非阻塞取任务
if (task != null) {
task.run(); // 执行任务
}
}, 0, periodMs, TimeUnit.MILLISECONDS);
}
// 尝试放入请求
public boolean tryAcquire(Runnable task) {
return bucket.offer(task);
}
// 关闭资源
public void shutdown() {
scheduler.shutdown();
}
// 示例
public static void main(String[] args) throws InterruptedException {
LeakyBucketQueueBased limiter = new LeakyBucketQueueBased(5, 2); // 容量5,每秒处理2个请求
for (int i = 0; i < 10; i++) {
int id = i;
boolean accepted = limiter.tryAcquire(() -> {
System.out.println("处理请求 " + id + " 时间: " + System.currentTimeMillis());
});
if (accepted) {
System.out.println("请求 " + i + " 放入桶中");
} else {
System.out.println("请求 " + i + " 被限流(桶满)");
}
Thread.sleep(200); // 每200ms发送一个请求
}
Thread.sleep(5000);
limiter.shutdown();
}
}
基于计数器的漏桶
import java.util.concurrent.atomic.AtomicLong;
public class LeakyBucketCounterBased {
private final int capacity; // 桶容量
private final double leakRatePerMs; // 漏水速率(每毫秒漏多少水)
private final AtomicLong water; // 当前桶中的水量(剩余未处理请求数)
private final AtomicLong lastLeakTimestamp; // 上次漏水时间戳
public LeakyBucketCounterBased(int capacity, double leakRatePerSecond) {
this.capacity = capacity;
this.leakRatePerMs = leakRatePerSecond / 1000.0; // 转换为每毫秒速率
this.water = new AtomicLong(0);
this.lastLeakTimestamp = new AtomicLong(System.currentTimeMillis());
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
long last = lastLeakTimestamp.get();
// 计算从上次漏水到现在应该漏掉的水量
long elapsed = now - last;
long leaked = (long) (elapsed * leakRatePerMs);
// 更新水量:先减少漏掉的水,然后加1(如果没满)
while (true) {
long currentWater = water.get();
long newWater = Math.max(0, currentWater - leaked); // 漏掉后剩余水量
if (newWater < capacity) {
// 桶未满,可以放入新水
if (water.compareAndSet(currentWater, newWater + 1)) {
// 更新漏水时间(需要同步考虑多线程)
lastLeakTimestamp.compareAndSet(last, now);
return true;
}
// CAS失败,重试
} else {
// 桶已满,拒绝
return false;
}
}
}
// 示例
public static void main(String[] args) throws InterruptedException {
LeakyBucketCounterBased limiter = new LeakyBucketCounterBased(5, 2.0); // 容量5,每秒漏2个
for (int i = 0; i < 10; i++) {
boolean accepted = limiter.tryAcquire();
System.out.println("请求 " + i + " 结果: " + (accepted ? "通过" : "限流"));
Thread.sleep(200);
}
}
}
优点
输出速率恒定,能很好地平滑突发流量,避免下游系统被冲垮。 实现简单,特别是基于队列的实现很直观。 内存占用可控(取决于桶容量)。
缺点
对于突发流量,虽然可以暂时缓存,但处理速度是恒定的,可能导致请求等待时间变长(如果桶容量较大且一直满)。 如果桶容量设置过小,会丢弃大量请求;设置过大,可能导致积压请求处理延迟过大。 不适用于需要允许瞬时高并发的场景(除非结合其他算法)
令牌桶算法
介绍
令牌桶算法(Token Bucket)是一种常用的限流算法,特别适合允许一定突发流量同时限制平均速率的场景。 它通过一个固定容量的桶来存放令牌,以恒定速率向桶中添加令牌, 请求到达时需要从桶中获取令牌才能被处理。如果没有令牌,请求要么被拒绝,要么等待
令牌桶与漏桶的对比
- 令牌桶: 允许一定程度的突发,输出速率可能瞬时超过平均限流速率,但长期平均速率受限于令牌生成速率。
- 漏桶: 输出速率恒定,无论输入如何突发,输出都被平滑为恒定速率。
基于定时器(ScheduledExecutorService)的实现
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public class TokenBucketScheduled {
private final int capacity; // 桶容量
private final double refillTokensPerSecond; // 每秒添加的令牌数
private final AtomicLong tokens; // 当前令牌数
private final ScheduledExecutorService scheduler;
public TokenBucketScheduled(int capacity, double refillTokensPerSecond) {
this.capacity = capacity;
this.refillTokensPerSecond = refillTokensPerSecond;
this.tokens = new AtomicLong(capacity); // 初始时桶是满的
this.scheduler = Executors.newScheduledThreadPool(1);
// 每隔 1/refillTokensPerSecond 秒添加一个令牌
long periodMicros = (long) (1_000_000 / refillTokensPerSecond);
scheduler.scheduleAtFixedRate(this::refill, periodMicros, periodMicros, TimeUnit.MICROSECONDS);
}
private void refill() {
// 添加令牌,但不超过容量
tokens.updateAndGet(current -> Math.min(current + 1, capacity));
}
public boolean tryAcquire() {
// 尝试消耗一个令牌
while (true) {
long current = tokens.get();
if (current > 0) {
if (tokens.compareAndSet(current, current - 1)) {
return true;
}
// CAS 失败,重试
} else {
return false;
}
}
}
public void shutdown() {
scheduler.shutdown();
}
// 示例
public static void main(String[] args) throws InterruptedException {
TokenBucketScheduled limiter = new TokenBucketScheduled(5, 2.0); // 容量5,每秒生成2个令牌
for (int i = 0; i < 20; i++) {
boolean allowed = limiter.tryAcquire();
System.out.println("请求 " + i + ": " + (allowed ? "通过" : "限流"));
Thread.sleep(200); // 每200ms发一个请求
}
limiter.shutdown();
}
}
基于时间戳计算的实现(无额外线程)
import java.util.concurrent.atomic.AtomicLong;
public class TokenBucket {
private final long capacity; // 桶容量
private final double refillTokensPerMillis; // 每毫秒添加的令牌数
private final AtomicLong tokens; // 当前令牌数
private final AtomicLong lastRefillTimestamp; // 上次添加令牌的时间戳(毫秒)
public TokenBucket(long capacity, double refillTokensPerSecond) {
this.capacity = capacity;
this.refillTokensPerMillis = refillTokensPerSecond / 1000.0;
this.tokens = new AtomicLong(capacity);
this.lastRefillTimestamp = new AtomicLong(System.currentTimeMillis());
}
public boolean tryAcquire() {
return tryAcquire(1);
}
public boolean tryAcquire(int numTokens) {
long now = System.currentTimeMillis();
long last = lastRefillTimestamp.get();
// 计算从上次更新到现在应该添加的令牌数
long elapsed = now - last;
long tokensToAdd = (long) (elapsed * refillTokensPerMillis);
// 更新令牌数
while (true) {
long currentTokens = tokens.get();
long newTokens = Math.min(capacity, currentTokens + tokensToAdd);
if (newTokens >= numTokens) {
// 有足够令牌,尝试消耗
if (tokens.compareAndSet(currentTokens, newTokens - numTokens)) {
// 更新最后更新时间(使用CAS避免并发问题)
lastRefillTimestamp.compareAndSet(last, now);
return true;
}
// CAS失败,重试
} else {
// 令牌不足
return false;
}
}
}
// 示例
public static void main(String[] args) throws InterruptedException {
TokenBucket limiter = new TokenBucket(5, 2.0); // 容量5,每秒生成2个令牌
for (int i = 0; i < 20; i++) {
boolean allowed = limiter.tryAcquire();
System.out.println("请求 " + i + ": " + (allowed ? "通过" : "限流"));
Thread.sleep(200);
}
}
}
主要框架
单机应用限流
如果你的应用是单体架构,或者只需要在单个JVM进程内进行限流,那么 Guava RateLimiter 是最简单直接的选择。它基于令牌桶算法,API非常简洁,只需一行代码就能创建限流器:
java
RateLimiter rateLimiter = RateLimiter.create(2.0); // 每秒生成2个令牌
它还提供了acquire()(阻塞获取)和tryAcquire()(非阻塞尝试)两种方式,可以灵活应对不同场景。
分布式系统限流
当你的应用部署在多个节点上,需要一个全局统一的限流阈值时,就需要用到分布式限流方案了。
Bucket4j 是一个基于令牌桶算法的Java库,它可以很好地与Redis等中间件集成,实现跨多个节点的分布式限流。如果你的技术栈是纯Java,希望以代码配置的方式实现限流,Bucket4j是个不错的选择。
Sentinel 则是更重量级的分布式系统流量防卫兵。它不仅支持限流,还提供熔断降级、系统负载保护、实时监控和控制台动态配置规则等强大功能,非常适合复杂的微服务架构
微服务与API网关限流
Spring Cloud Gateway 结合 Redis 可以实现基于令牌桶的分布式限流,这是Spring Cloud体系下的标准实践
总结
如果你在写一个小工具或单体应用,Guava 最顺手。
如果系统是分布式的,需要强有力的流量治理,Sentinel 是最佳拍档。
如果你偏爱函数式编程,或者需要灵活组合限流、熔断等多种能力,Resilience4j 值得一试