【数据结构和算法】令牌桶和漏桶的算法区别和实现 以及 固定窗口算法和滑动窗口算法
转载:https://blog.csdn.net/m0_37477061/article/details/95313062
一、令牌桶和漏桶算法区别
漏桶算法与令牌桶算法在表面看起来类似,很容易将两者混淆。但事实上,这两者具有截然不同的特性,且为不同的目的而使用。
需要说明的是:在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的,所以即使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法结合起来为网络流量提供更高效的控制。
1、漏桶算法的示意

2、令牌桶的算法示意
========令牌桶的基本原理===============
【令牌的生成】:令牌以恒定的速率生成,放入令牌桶中,待令牌使用
【令牌的消费】:请求通过令牌桶,要先获取令牌,获取则请求通过,未获取的请求拒绝或阻塞到有令牌生成为至
========令牌生成的原理=================
令牌时间片( slot )= 1S / QPS
令牌生成斜率(rate) = QPS / 1s
当前桶中的令牌数 = Min ( max_token_num , last_token_num + (current_time - last_access_time ) * rate )
当前桶中的令牌数 = Min ( max_token_num , last_token_num + (current_time - last_access_time ) / slot )

令牌桶算法具有两个很重要的特性:流量整形和方便处理突发流量。
流量整形是指令牌桶算法通过阻塞、拒绝等手段使请求以稳定的速度通过限流器,原本不规则的流量在经过限流器后变得平滑且均匀。流量整形效果非常有利于服务端稳定运行,类似我们在高并发系统中常用的基于消息队列实现的“削峰填谷”手段,经过整形后,服务端能够以稳定的状态接收并处理请求。
突发流量是指随机出现的、短时间的流量突刺。如果严格遵循流量整形的限制,那么服务端在遇到突发流量时会突然拒绝一大波请求,在客户端有重试机制的情况下还可能导致情况进一步恶化。因此,在服务端资源充足的条件下,限流器应该具有一些“弹性”,允许服务端临时超频处理一些突发请求。
在令牌桶算法模型中,“弹性”处理突发流量是非常容易实现的,只需要给桶中生成的令牌设置一个有效期即可。有突发流量时,限流器可以使用有效期内的剩余令牌来通过更多请求,从而临时提高服务端处理效率,避免大量请求被拒绝。

二、常用的限流算法

常用的限流算法有两种:漏桶算法和令牌桶算法。
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。

图1 漏桶算法示意图
对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。如图2所示,令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

图2 令牌桶算法示意图
并不能说明令牌桶一定比漏洞好,她们使用场景不一样。
- 令牌桶:可以用来保护自己,主要用来对调用者频率进行限流,为的是让自己不被打垮。所以如果自己本身有处理能力的时候,如果流量突发(实际消费能力强于配置的流量限制),那么实际处理速率可以超过配置的限制
- 漏桶算法:用来保护他人,也就是保护他所调用的系统。主要场景是,当调用的第三方系统本身没有保护机制,或者有流量限制的时候,我们的调用速度不能超过他的限制,由于我们不能更改第三方系统,所以只有在主调方控制。这个时候,即使流量突发,也必须舍弃。因为消费能力是第三方决定的。
- 总结起来:如果要让自己的系统不被打垮,用令牌桶。如果保证被别人的系统不被打垮,用漏桶算法。
二、算法实现
1、漏桶算法实现
/** * 漏桶限流 **/ public class LeakyBucketLimit { //桶的最大水量 private Double capacity; //当前桶中的水量 private Double currentWater; //当前时间 private Double nextLeakyWaterTime; //流水速率 private Double rate; //每滴水的时间片 private Double waterSlot; private Object lock = new Object(); public LeakyBucketLimit(Integer maxBucket) { if (maxBucket == null || maxBucket <= 0) { throw new IllegalArgumentException("参数异常"); } this.capacity = maxBucket.doubleValue(); this.currentWater = 0D; this.nextLeakyWaterTime = Double.valueOf(System.currentTimeMillis()); this.rate = this.capacity / 1000; this.waterSlot = 1000/this.capacity; } public boolean acquireLimit() { long time=0; synchronized (lock) { Long currentTime = System.currentTimeMillis(); //放水 leakyWater(currentTime); //注入水 Double swapWater = this.currentWater + 1; if (swapWater > this.capacity) { return false; } else { this.currentWater = swapWater; //更新一下下次放水时间 this.nextLeakyWaterTime += waterSlot; //这个是计算当前这次请求需要睡眠的时长,保障漏水的速度是均衡的 time = Double.valueOf(this.nextLeakyWaterTime - currentTime).longValue(); } } if(time>0){ //延迟到指定的时间进行漏水 try { Thread.sleep(time); } catch (InterruptedException e) { e.printStackTrace(); } } return true; } /** * 放水 * @param currentTime */ private void leakyWater(Long currentTime) { if (currentTime > nextLeakyWaterTime) { Double leakyWater = (currentTime - nextLeakyWaterTime) * rate; this.currentWater = Math.max(0D, this.currentWater - leakyWater); this.nextLeakyWaterTime = currentWater; } } }
(1)未满加水:通过代码 water +=1进行不停加水的动作。
(2)漏水:通过时间差来计算漏水量。
(3)剩余水量:总水量-漏水量。
(4)下次漏水时间= 下次漏水时间 + 获取入桶的水滴时间片 或者 = 当前时间(当前时间>下次漏水时间)
2、令牌桶算法实现
可以参考guava实现的令牌桶限流器:https://ifeve.com/guava-ratelimiter/
/** * 令牌桶限流 **/ public class TokenBucketLimit { //令牌桶最大令牌 private Double maxToken; //当前令牌桶的令牌数 private Double currentToken; //下一次产生令牌的时间 private Long nextFireTokenTime; //每个令牌的时间片 private Double tokenSlot; //并发锁 private Object lock = new Object(); /** * 初始化令牌桶 * @param maxToken */ public TokenBucketLimit(Integer maxToken){ if(maxToken == null || maxToken<=0){ throw new IllegalArgumentException("参数异常"); } this.maxToken= maxToken.doubleValue(); this.tokenSlot = Double.valueOf(1000 / maxToken); this.currentToken = 0D; this.nextFireTokenTime = System.currentTimeMillis(); } /** * 令牌桶 * 【令牌桶属性】 * 1、最大令牌数 * 2、每个令牌时间片 * 3、下次发放令牌时间 * 4、当前令牌数 * <p> * 【公式】 * 1、每个令牌时间片= 1000 ms / 最大令牌数 * 2、刷新令牌公式:当前令牌 = min( max令牌数 , (当前时间-下次发放时间)/每个令牌时间片 + 当前令牌 ) * 3、扣减令牌公式: * * @return */ public boolean acquireLimit() { //并发锁控制 synchronized (lock){ Long time = System.currentTimeMillis(); //发放令牌 fireBucketForToken(time); //消耗令牌 return consumerBucketToken(time,1); } } /** * 基于时间控制进行令牌发放 * @param currentTime */ private void fireBucketForToken(Long currentTime) { if (currentTime > nextFireTokenTime) { Double addToken = (currentTime - nextFireTokenTime) / tokenSlot; //当前最新令牌 this.currentToken = Math.min(this.maxToken, addToken + this.currentToken); //更新下次发放令牌的时间 this.nextFireTokenTime = currentTime; } } /** * 消费令牌 * * @param currentTime 当前时间 * @param consumerToken 消费令牌数 * @return */ private boolean consumerBucketToken(Long currentTime, int consumerToken) { //找到交换令牌数 Double swapToken = Math.min(this.currentToken,consumerToken); //计算需要超发的令牌数 Double waitFireToken = consumerToken - swapToken; //判定是否在超发令牌范围内: 下次发放令牌时间 + 超发令牌数* 令牌时间片 - 当前时间 > 1s double waiteTime = this.nextFireTokenTime + waitFireToken*tokenSlot - currentTime; if( waiteTime > 1000){ //不允许超发令牌,没有获得限流 return false; }else{ //更新当前令牌数 if(waitFireToken > 0){ //超发令牌,则当前令牌数=0 this.currentToken = 0D; }else{ //未超发令牌,则进行正常的令牌扣减 this.currentToken -= consumerToken; } //更新下一次令牌发放时间 = 当前下一次令牌发送时间 + 超发令牌耗时 this.nextFireTokenTime = this.nextFireTokenTime + Double.valueOf(waitFireToken * tokenSlot).longValue(); return true; } } }
3、固定窗口算法实现
2.1.1 实现原理
固定窗口又称固定窗口(又称计数器算法,Fixed Window)限流算法,是最简单的限流算法。实现原理:在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时进行访问次数的清零。如图所示,我们要求3秒内的请求不要超过150次:

2.1.2、代码实现
/** * https://developer.aliyun.com/article/1403803 * 固定窗口限流算法 */ public class FixedWindowRateLimiter { //时间窗口大小,单位毫秒 long windowSize; //允许通过的请求数 int maxRequestCount; //当前窗口通过的请求数 AtomicInteger counter = new AtomicInteger(0); //窗口右边界 long windowBorder; public FixedWindowRateLimiter(long windowSize, int maxRequestCount) { this.windowSize = windowSize; this.maxRequestCount = maxRequestCount; this.windowBorder = System.currentTimeMillis() + windowSize; } public synchronized boolean tryAcquire() { long currentTime = System.currentTimeMillis(); if (windowBorder < currentTime) { do { windowBorder += windowSize; } while (windowBorder < currentTime); counter = new AtomicInteger(0); } if (counter.intValue() < maxRequestCount) { counter.incrementAndGet(); return true; } else { return false; } } }
2.1.3 优缺点
优点:实现简单,容易理解
缺点:
1.限流不够平滑。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,窗口剩余时间的请求都将会被拒绝,体验不好。
2.无法处理窗口边界问题。因为是在某个时间窗口内进行流量控制,所以可能会出现窗口边界效应,即在时间窗口的边界处可能会有大量的请求被允许通过,从而导致突发流量。即:如果第2到3秒内产生了150次请求,而第3到4秒内产生了150次请求,那么其实在第2秒到第4秒这两秒内,就已经发生了300次请求了,远远大于我们要求的3秒内的请求不要超过150次这个限制,如下图所示:

4、滑动窗口算法实现
2.2.1 实现原理
滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求。在滑动窗口算法中,窗口的起止时间是动态的,窗口的大小固定。这种算法能够较好地处理窗口边界问题,但是实现相对复杂,需要记录每个请求的时间戳。实现原理:滑动窗口在固定窗口的基础上,将时间窗口进行了更精细的分片,将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel就是采用滑动窗口算法来实现限流的。如图所示:

核心步骤:
1.把3秒钟划分为3个小窗,每个小窗限制请求不能超过50秒。
2.比如我们设置,3秒内不能超过150个请求,那么这个窗口就可以容纳3个小窗,并且随着时间推移,往前滑动。每次请求过来后,都要统计滑动窗口内所有小窗的请求总量。
2.2.2 代码实现
package obj.sf; public class SlidingWindowRateLimiter { //时间窗口大小,单位毫秒 long windowSize; //分片窗口数 int shardNum; //允许通过的请求数 int maxRequestCount; //各个窗口内请求计数 int[] shardRequestCount; //请求总数 int totalCount; //当前窗口下标 int shardId; //每个小窗口大小,毫秒 long tinyWindowSize; //窗口右边界 long windowBorder; public SlidingWindowRateLimiter(long windowSize, int shardNum, int maxRequestCount) { this.windowSize = windowSize; this.shardNum = shardNum; this.maxRequestCount = maxRequestCount; this.shardRequestCount = new int[shardNum]; this.tinyWindowSize = windowSize / shardNum; this.windowBorder = System.currentTimeMillis(); } public synchronized boolean tryAcquire() { long currentTime = System.currentTimeMillis(); if (windowBorder < currentTime) { do { shardId = (++shardId) % shardNum; totalCount -= shardRequestCount[shardId]; shardRequestCount[shardId] = 0; windowBorder += tinyWindowSize; } while (windowBorder < currentTime); } if (totalCount < maxRequestCount) { shardRequestCount[shardId]++; totalCount++; return true; } else { return false; } } }
2.2.3 优缺点
优点:解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。缺点:还是存在限流不够平滑的问题。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。
浙公网安备 33010602011771号