常见限流算法详细解析
分布式系统中,由于接口API无法控制上游调用方的行为,因此当瞬时请求量突增时,会导致服务器占用过多资源,发生响应速度降低、超时、乃至宕机,甚至引发雪崩造成整个系统不可用。
限流,Rate Limiter,就是对API的请求量进行限制,对于超出限制部分的请求作出快速拒绝、快速失败、丢弃处理,以保证本服务以及下游资源系统的稳定。
常见的限流算法有:固定时间窗、滑动时间窗算法、漏桶算法、令牌桶算法,以下分别详解介绍。
固定时间窗口算法
生活示例
一个游戏规则:
- 每小时只能玩5次游戏
- 时间一到整点,计数器就会重置
举个例子:
- 上午9:55,你已经玩了3次游戏
- 上午10:05,你又可以玩完整的5次
这就像是一个"准点重置"的游戏规则。优点是简单直接!
算法原理
- 将时间划分为固定的窗口大小,例如10s
- 在窗口时间段内,每来一个请求,对计数器加1。
- 当计数器达到设定限制后,该窗口时间内的之后的请求都被丢弃处理。
- 该窗口时间结束后,计数器清零,从新开始计数。如上图所示,10s内限制100个请求,在第11s的时候计数器会从0重新开始计数。
如下图所示:
代码示例
public class FixedWindowRateLimiter {// 窗口最大请求数private final int maxRequests;// 窗口时间大小(毫秒)private final long windowSizeMillis;// 当前窗口的请求计数private int currentRequests = 0;// 窗口开始时间private long windowStartTime;public FixedWindowRateLimiter(int maxRequests, long windowSizeMillis) {this.maxRequests = maxRequests;this.windowSizeMillis = windowSizeMillis;this.windowStartTime = System.currentTimeMillis();}public synchronized boolean tryAcquire() {long currentTime = System.currentTimeMillis();// 如果当前时间已超过窗口,重置窗口if (currentTime - windowStartTime >= windowSizeMillis) {currentRequests = 0;windowStartTime = currentTime;}// 判断是否允许新请求if (currentRequests < maxRequests) {currentRequests++;return true;}return false;}
}// 短信发送限制器:每小时最多发送5条短信
FixedWindowRateLimiter smsSender = new FixedWindowRateLimiter(5, 60 * 60 * 1000);public boolean sendSMS(String phoneNumber, String message) {if (smsSender.tryAcquire()) {// 执行短信发送逻辑return true;} else {System.out.println("超过短信发送限制");return false;}
}
存在问题
边界问题
- 在窗口切换时可能会出现流量突发
- 例如,在一个小时的最后一秒和下一秒分别允许100个请求,实际上可能瞬间允许200个请求,所谓的2N问题,如下图所示:
不够平滑
- 流量控制不够精细
- 容易在窗口边界产生流量不均匀的情况
跨两个固定时间窗统计不准确:比如6t到16t之间也是10t大小的一个时间窗,但跨了两个固定的时间窗,问题是请求总数为110,超过阈值,这种固定时间窗无法处理这部分超出的请求,所以解决办法就是使用滑动时间窗。
滑动时间窗算法
生活示例
计数器滑动时间窗口算法是计数器固定窗口算法的改进,解决了固定窗口切换时可能会产生两倍于阈值流量请求的缺点。TCP协议中数据包的传输,同样也是采用滑动窗口来进行流量控制。
想象你有一个会"移动"的时间窗口。
- 不再是整点重置
- 而是始终保持最近1小时内只能玩5次游戏
- 举个栗子:
- 9:30玩了2次
- 9:45玩了2次
- 10:15想玩,系统会检查9:15到10:15的记录
再举个例子:
再想象你是一个售票员,需要控制游乐场的人流量。你的规则是"任意10分钟内最多允许50人进入"。如果你每2分钟清点一次人数(这就是滑动步长),就比每1秒钟都清点要轻松得多,而且准确性也足够。
原理
滑动窗口算法在固定窗口的基础上,将一个计时窗口分成了若干个小窗口,然后每个小窗口维护一个独立的计数器。
当请求的时间大于当前窗口的最大时间时,则将计时窗口向前平移一个小窗口。平移时,将第一个小窗口的数据丢弃,然后将第二个小窗口设置为第一个小窗口,同时在最后面新增一个小窗口,将新的请求放在新增的小窗口中。同时要保证整个窗口中所有小窗口的请求数目之后不能超过设定的阈值。
滑动时间窗限流算法解决了固定时间窗限流算法的问题。其没有划分固定的时间窗起点与终点,而是将每一次请求的到来时间点作为统计时间窗的终点,起点则是终点向前推时间窗长度的时间点。这种时间窗称为“滑动时间窗”
图解
假设:
- 时间窗口 = 10秒
- 滑动步长 = 2秒
- 最大请求 = 5个时间轴划分:
0s 2s 4s 6s 8s 10s 12s 14s
|-----|-----|-----|-----|-----|-----|-----|↑ ↑ ↑ ↑ ↑ ↑ ↑
小窗口1 窗口2 窗口3 窗口4 窗口5 窗口6 窗口7完整时间窗口(10秒):
|----------------------------------|包含5个小窗口滑动过程:
第0秒: [窗口1|窗口2|窗口3|窗口4|窗口5]
第2秒: [窗口2|窗口3|窗口4|窗口5|窗口6]
第4秒: [窗口3|窗口4|窗口5|窗口6|窗口7]
代码示例
public class SlidingWindowLimiter {private final int maxRequestsAllowed; // 允许的最大请求数private final int windowSizeInSeconds; // 时间窗口大小(秒)private final int slideTimeInSeconds; // 滑动步长(秒),最小时间窗格// 使用TreeMap记录每个小窗口中的请求数// Key: 窗口的开始时间戳(毫秒)// Value: 该窗口内的请求数private final TreeMap<Long, Integer> windowCounts = new TreeMap<>();public SlidingWindowLimiter(int maxRequests, int windowSize, int slideTime) {this.maxRequestsAllowed = maxRequests;this.windowSizeInSeconds = windowSize;this.slideTimeInSeconds = slideTime;}public synchronized boolean isAllowed() {long currentTime = System.currentTimeMillis();long windowStart = currentTime - (windowSizeInSeconds * 1000L);// 计算当前请求所属的小窗口的开始时间long currentSlideStart = currentTime - (currentTime % (slideTimeInSeconds * 1000L));// 清理过期的小窗口windowCounts.headMap(windowStart).clear();// 统计当前时间窗口内的总请求数int totalRequests = windowCounts.values().stream().mapToInt(Integer::intValue).sum();if (totalRequests < maxRequestsAllowed) {// 更新当前小窗口的计数windowCounts.merge(currentSlideStart, 1, Integer::sum);return true;}return false;}// 用于展示当前所有小窗口的状态public void printWindowStatus() {System.out.println("当前时间窗口状态:");windowCounts.forEach((timestamp, count) -> {String time = new SimpleDateFormat("HH:mm:ss.SSS").format(new Date(timestamp));System.out.printf("窗口开始时间:%s, 请求数:%d\n", time, count);});}
}public class Example {public static void main(String[] args) {// 创建限流器:// - 10秒的时间窗口// - 每个窗口最多允许5个请求// - 每2秒滑动一次SlidingWindowLimiter limiter = new SlidingWindowLimiter(5, 10, 2);// 模拟请求for (int i = 0; i < 8; i++) {boolean allowed = limiter.isAllowed();System.out.printf("请求 %d: %s\n", i + 1, allowed ? "通过" : "拒绝");limiter.printWindowStatus();try {Thread.sleep(1000); // 等待1秒} catch (InterruptedException e) {e.printStackTrace();}}}
}
漏桶算法
生活示例
想象你在一个水桶(桶装水、净水机)底部有个小洞的水龙头下,这个小洞代表了水流出的恒定速率,无论上面的水有多少,出水速度都是固定的。即使你快速地往桶里倒水,水也只会以固定的速度从底部的小洞流出。这就是漏桶算法的生活化比喻。
算法原理
![在这里插入图片描述![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/21ea20c5462d455aaeeee1a67eb82054.png)
漏桶算法的核心思想是:
- 请求(水)进入一个固定容量的"桶"
- 桶内请求以恒定速率被处理
- 如果桶已满,新的请求将被拒绝,桶的容量代表系统能够处理的最大并发请求数
代码示例
public class LeakyBucketRateLimiter {private final int capacity;private final int rate;private int currentWaterLevel = 0;private long lastRequestTime;// 添加拒绝策略枚举public enum LimitPolicy {REJECT, // 直接拒绝WAIT, // 等待DEGRADE // 降级服务}public synchronized LimitResult tryAcquire(LimitPolicy policy) {long currentTime = System.currentTimeMillis();long timePassed = currentTime - lastRequestTime;// 计算流出的请求数int outflow = (int) (timePassed / (1000 / rate));currentWaterLevel = Math.max(0, currentWaterLevel - outflow);lastRequestTime = currentTime;// 根据不同策略处理限流 当桶已满时,拒绝新请求if (currentWaterLevel < capacity) {currentWaterLevel++;return new LimitResult(true, "请求通过");}// 根据不同策略返回结果switch (policy) {case REJECT:return new LimitResult(false, "请求被拒绝");case WAIT:// 模拟等待逻辑return new LimitResult(false, "请求需要等待");case DEGRADE:// 降级服务逻辑return new LimitResult(false, "服务降级");default:return new LimitResult(false, "未知策略");}}// 结果封装类public static class LimitResult {public final boolean allowed;public final String message;public LimitResult(boolean allowed, String message) {this.allowed = allowed;this.message = message;}}public static void main(String[] args) throws InterruptedException {LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(10, 2);// 模拟请求for (int i = 0; i < 15; i++) {if (limiter.tryAcquire()) {System.out.println("请求" + i + ": 通过");} else {System.out.println("请求" + i + ": 被限流");}TimeUnit.MILLISECONDS.sleep(200);}}
}
优点:
- 算法简单,实现容易
- 可以平滑处理突发流量
- 能够有效防止系统过载
缺点:
- 不能充分利用系统资源
- 处理速率固定,不够灵活
- 无法应对短时间的高并发请求
令牌桶算法
生活示例
取号看演出或办理业务,取到号以后才能进场,取不到号就要等待
想象你有一个自动售货机,它每隔一段时间就会往机器里放入一定数量的"通行令牌"。只有拿到令牌的人才能购买商品。如果令牌被用完,就必须等待新的令牌生成。
算法原理
令牌桶算法的核心思想是:
- 有一个固定大小的令牌桶
- 系统以恒定速率向桶中添加令牌
- 每个请求需要获取一个令牌才能执行
- 如果桶中没有令牌,请求被拒绝或等待
代码示例
import java.util.concurrent.TimeUnit;public class TokenBucketRateLimiter {// 令牌桶最大容量private final int maxTokens;// 每秒生成的令牌数private final int tokenGenerateRate;// 当前令牌数private int currentTokens;// 上次令牌生成时间private long lastTokenTime;public TokenBucketRateLimiter(int maxTokens, int tokenGenerateRate) {this.maxTokens = maxTokens;this.tokenGenerateRate = tokenGenerateRate;this.currentTokens = maxTokens;this.lastTokenTime = System.currentTimeMillis();}public synchronized boolean tryAcquire() {long currentTime = System.currentTimeMillis();long timePassed = currentTime - lastTokenTime;// 计算这段时间内生成的令牌数int generatedTokens = (int) (timePassed * tokenGenerateRate / 1000);// 更新当前令牌数,不超过最大容量currentTokens = Math.min(maxTokens, currentTokens + generatedTokens);lastTokenTime = currentTime;// 判断是否有令牌if (currentTokens > 0) {currentTokens--;return true;}return false;}public static void main(String[] args) throws InterruptedException {TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(10, 5);// 模拟请求for (int i = 0; i < 15; i++) {if (limiter.tryAcquire()) {System.out.println("请求" + i + ": 通过");} else {System.out.println("请求" + i + ": 被限流");}TimeUnit.MILLISECONDS.sleep(100);}}
}
优点:
- 可以处理突发流量
- 能更好地利用系统资源
- 灵活控制流量
- 支持预热和动态调整
缺点:
- 实现相对复杂
- 需要额外维护令牌生成和分配逻辑
- 可能会有一定的计算开销
总结:
介绍实现限流的几种方式,主要是窗口算法和桶算法,两者各有优势。
- 窗口算法实现简单,逻辑清晰,可以很直观的得到当前的 QPS 情况,但是会有时间窗口的临界突变问题,而且不像桶一样有队列可以缓冲。
- 桶算法虽然稍微复杂,不好统计 QPS 情况,但是桶算法也有优势所在。
- 漏桶模式消费速率恒定,可以很好的保护自身系统,可以对流量进行整形,但是面对突发流量不能快速响应。
- 令牌桶模式可以面对突发流量,但是启动时会有缓慢加速的过程,不过常见的开源工具中已经对此优化。