常见限流算法
限流背景
在代码开发中,限流一般指的是为了保证系统的稳定性,需要拒绝一些请求或者延迟处理一些请求来限制对系统的并发请求数量
比如说,某个系统中使用了MQ来通信,对于系统中一些重要操作,例如数据删除、拷贝等,当调用此类操作的接口的时候,相关服务端会发送对应的消息到MQ通道中来记录相关操作时间、操作人等等关键信息。
同时会有一个消费者来消费这些消息,将其记录在数据库中。若某个时刻消息量突增,则很可能该消费者处理不过来而导致系统崩溃。
因此,需要对消费者端进行限流,来防止消息突增,以保护系统的正常工作。
限流算法
常见的限流算法有以下三种:
- 计数器限流算法
- 令牌桶算法
- 漏桶算法
计数器限流算法
基本计数器限流算法
1.处理逻辑
- 初始化计数器的上限阈值limit
- 新接收到一个请求时,若计数值不小于limit,此时拒绝请求;反之,则计数器加1,正常处理请求
- 每完成一个请求处理,计数器减一
2.伪代码
public class Counter {
private int limit;
private int count;
public Counter(int limit) {
this.limit = limit;
}
public boolean tryAcquire() {
if (count >= limit) {
return false;
}
count++;
return true;
}
public boolean tryRelease() {
if (count > 0) {
count--;
return true;
}
return false;
}
}
3.缺陷
无法处理短时间内的突发请求,也就是说,若在一秒内突发limit个请求,无法处理该突发流量
固定窗口计数器限流算法
1.处理逻辑
- 初始化计数器的上限阈值limit及时间窗口window
- 对该窗口内的请求进行计数,若计数值不小于limit,此时拒绝请求;反之,则计数器加1,正常处理请求
- 当时间窗口结束时,重置计数器为0
2.伪代码
public class Counter {
private int limit;
private int window;
private AtomicInteger count;
public Counter(int limit, int window) {
this.limit = limit;
this.window = window;
count = new AtomicInteger(0);
new Thread(() -> {
while(true) {
count.set(0);
try {
Thread.sleep(window);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
public boolean tryAcquire() {
if (count.get() >= limit){
return false;
}
count.incrementAndGet();
return true;
}
}
或者
public class Counter {
private long timeStamp;
private int count;
private int limit;
private int window;
public Counter(int limit, int window) {
this.limit = limit;
this.window = window;
this.timeStamp = System.currentTimeMillis();
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - timeStamp < window) {
count++;
return count <= limit;
} else {
timeStamp = now;
count = 1;
return true;
}
}
}
3.缺陷
- 假设系统limit为200,window为1s,也就是说阈值是200/s,若在第一个窗口的后0.1秒突发200个请求,在第二个窗口内的前0.1秒突发200个请求,
也就是说在此连续的0.2秒内突发了400个请求,此时速率为2000/s,系统也是承受不住的。 - 假设系统limit为200,window为1s,若在第一个窗口的后0.1秒突发200个请求,然后第0.2-1s内的的请求就都会被拒绝。
滑动窗口计数器限流算法
1.处理逻辑
- 初始化计数器的上限阈值limit及时间窗口window
- 对该窗口内的请求进行计数,若计数值不小于limit,此时拒绝请求;反之,则计数器加1,正常处理请求
- 当时间窗口结束时,重置计数器为0
2.伪代码
public class Counter {
private int window;
private int limit;
private int split;
private int[] counters;
private int indexCur;
private long startTime;
public Counter(int window, int limit, int split) {
this.limit = limit;
this.window = window;
this.split = split; // 切分精度,退化为1则为固定窗口限流
this.counters = new int[split]; // split个小窗口
this.indexCur = 0;
this.startTime = System.currentTimeMillis();
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
int minWindow = window / split;
long windowsNum = Math.max(now - window - startTime, 0) / minWindow;
if (windowsNum != 0) {
long slideNum = Math.min(windowsNum, split);
for (int i = 0; i < slideNum; ++i) {
indexCur = (indexCur + 1) % split;
counters[index] = 0;
}
startTime = startTime + windowsNum * (window / split);
}
int count = 0;
for (int i = 0; i < split; i ++) {
count += counters[i];
}
if (count >= limit) {
return false;
}
counters[indexCur]++;
return true;
}
}
或者采用队列实现,将时间记录放到队列中
- 若队列未满,表示请求未达到流控上限,则可以正常请求
- 若队列已满,获取队首记录,比较队首记录时间
- 若队首记录时间距当前已超过设定周期,表明周期内未达到流控上限,则可以正常请求
- 若队首记录时间距当前不足设定周期,表明周期内已达流控,拒绝该请求
public class Counter {
private CircularQueue<Time> queue;
private int limit;
private int window;
public Counter(int limit, int window) {
this.limit = limit;
this.window = window;
this.queue = new CircularQueue<Time>(limit);
}
public boolean tryAcquire() {
if (!this.queue.isFull()) {
queue.add(new Record());
return true;
}
Time earliestTime = this.queue.peek();
if (System.currentTimeMillis() - earliestTime.getTime() > window) {
queue.add(new Time());
return true;
}
return false;
}
private static class Time {
private long time;
public long getTime() {
return time;
}
Time() {
this.time = System.currentTimeMillis();
}
}
}
3.缺陷
滑动窗口计数器限流解决了固定窗口限流的缺陷1,能保证在任一时间窗内都不会超过限制,但仍然有同样的缺陷2
漏桶算法
1.处理逻辑
请求先进入到桶里,桶以一定的响应速率响应请求,当请求速率过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求
2.伪代码
public class Counter {
private double rate; // 桶流出速率
private double capacity; // 桶容量
long refreshTime; // 上次水桶刷新时间
private double water; // 当前桶内水量
public Counter(int capacity, double rate) {
this.rate = rate;
this.capacity = capacity;
water = Math.max(0, water - (System.currentTimeMillis() - refreshTime) * rate);
}
public boolean tryAcquire() {
if (water < capacity) {
water++;
refreshTime = System.currentTimeMillis();
return true;
}
return false;
}
}
3.缺陷
响应速率固定,不能解决流量突发的问题
令牌桶算法
1.处理逻辑
请求先进入到桶里,桶以一定的响应速率响应请求,当请求速率过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求
初始化令牌桶容量,以恒定的速率向令牌桶中放入令牌,,若令牌桶满则无法继续放令牌。
当接收到请求时,需先到令牌桶中去拿令牌,如果拿到了令牌,则该请求会被处理,并消耗掉拿到的令牌;如果未拿到令牌,则拒绝请求。
2.伪代码
public class Counter {
private int rate; // 加令牌的速率
private int capacity; // 令牌桶容量
private int tokenNum; // 当前可用令牌数量
public Counter(int capacity, int rate) {
this.rate = rate;
this.capacity = capacity;
this.tokenNum = capacity;
new Thread(new Runnable() {
@Override
public void run() {
while (true) {
synchronized (this) {
tokenNum++;
if (tokenNum > capacity) {
tokenNum = capacity;
}
}
try {
Thread.sleep(1000 / rate);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
public boolean tryAcquire() {
if (tokenNum > 0) {
tokenNum--;
return true;
}
return false;
}
}
3.Guava实现
RateLimiter 采用的是令牌桶算法,使用时添加如下依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
简单调用:
public class AccessLimitService {
RateLimiter rateLimiter = RateLimiter.create(Double.MAX_VALUE);
public boolean tryAcquire(){
rateLimiter.setRate(perSecond); // 每perSecond添加一个令牌
return rateLimiter.tryAcquire();
}
}