目录
一、简介
限流是一种通过控制请求的速率或数量来保护系统免受过载的技术。流控的精髓是限制单位时间内的请求量,最大程度保障系统的可靠性及可用性。
二、作用
限流是在高并发环境下,为了保护系统的稳定性和可用性而引入的一种策略。通过限制并发请求的数量或频率,可以防止系统被过多的请求压垮或耗尽资源。
三、限流算法
常见的流控算法包括:固定窗口、滑动窗口、漏桶、令牌桶、滑动日志等算法。
3.1、固定窗口算法(计数器)
3.1.1、简介
固定窗口限流算法(Fixed Window Rate Limiting Algorithm)是一种最简单的限流算法,其原理是在固定时间窗口(单位时间)内限制请求的数量。
3.1.2、原理
固定窗口是最简单的流控算法。即,给定时间窗口,维护一个计数器用于统计访问次数,并实现以下规则:
1.如果访问次数小于阈值,则允许访问,访问次数+1;
2.如果访问次数超出阈值,则限制访问,访问次数不增;
3.如果超过了时间窗口,计数器清零,并重置清零后的首次成功访问时间为当前时间。
3.1.3、适用场景
- 保护后端服务免受大流量冲击,避免服务崩溃;
- 对 API 调用进行限制,保证公平使用;
- 防止恶意用户对服务进行洪水攻击;
3.1.4、代码实现
/**
* @ClassName: FixedWindowRateLimiter
* @projectName: cat
* @description: 限流算法:固定窗口算法。
* @author: yangwenxue
* @date: 2024/1/25 15:40
* @Version: 1.0
*/
public class FixedWindowRateLimiter {
private static int counter = 0; // 统计请求数
private static long lastAcquireTime = 0L;
private static final long windowUnit = 1000L; // 假设固定时间窗口是1000ms
private static final int threshold = 10; // 窗口阀值是10
/**
* 获取令牌
*
* @return
*/
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis(); // 获取系统当前时间
if (currentTime - lastAcquireTime > windowUnit) { // 检查是否在时间窗口内
counter = 0; // 计数器清零
lastAcquireTime = currentTime; // 开启新的时间窗口
}
if (counter < threshold) { // 小于阀值
counter++; // 计数器加1
return true; // 获取请求成功
}
return false; // 超过阀值,无法获取请求
}
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
boolean acquire = new FixedWindowRateLimiter().tryAcquire();
System.out.println("获取令牌:" + acquire);
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
3.1.5、优劣分析
优点
- 固定窗口算法非常简单,易于实现和理解。
- 性能高
缺点
- 存在明显的临界问题。比如: 假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s内的,则并发数高达10,已经超过单位时间1s不超过5阀值的定义了。
3.2、滑动窗口算法
3.2.1、简介
为了解决临界突变问题,可以引入滑动窗口。即:把大的时间窗口拆分成若干粒度更细的子窗口,每个子窗口独立统计,按子窗口时间滑动,统一限流。
当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
3.2.2、原理
将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。它可以解决固定窗口临界值的问题。
3.2.3、适用场景
同固定窗口的场景,且对流量限制要求较高的场景,需要更好地应对突发流量。
3.2.4、代码实现
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @ClassName: SlidingWindowLimiter
* @projectName: cat
* @description: 限流算法:滑动窗口算法:实现方式一
* @author: yangwenxue
* @date: 2023/10/16 15:23
* @Version: 1.0
*/
public class SlidingWindowLimiter {
/**
* 每个窗口的最大请求数量
*/
public static long threshold = 10;
/**
* 窗口大小,1000ms
*/
public static long windowUnit = 1000;
/**
* 请求集合,用来存储窗口内的请求数量
*/
public static List<Long> requestList = new ArrayList<>();
/**
* 限流方法,返回true表示通过
*/
public boolean limit() {
System.out.println("requestList=" + requestList.size());
// 获取系统当前时间
long currentTime = System.currentTimeMillis();
// 统计当前窗口内,有效的请求数量
int sizeOfValid = this.sizeOfValid(currentTime);
// 判断是否超过最大请求数量
if (sizeOfValid < threshold) {
// 把当前请求添加到请求集合里
requestList.add(currentTime);
return true;
}
return false;
}
/**
* 统计当前窗口内,有效的请求数量
*/
private int sizeOfValid(long currentTime) {
int sizeOfValid = 0;
for (Long requestTime : requestList) {
// 判断是否在当前时间窗口内
if (currentTime - requestTime <= windowUnit) {
sizeOfValid++;
}
}
return sizeOfValid;
}
/**
* 清理过期的请求(单独启动一个线程处理)
*/
private void clean() {
// 判断是否超出当前时间窗口内
requestList.removeIf(requestTime -> System.currentTimeMillis() - requestTime > windowUnit);
}
// 测试
public static void main(String[] args) {
SlidingWindowLimiter slidingWindowLimiter = new SlidingWindowLimiter();
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 100; i++) {
executorService.execute(() -> {
boolean limit = slidingWindowLimiter.limit();
System.out.println(Thread.currentThread().getName() + "==>" + limit);
});
}
executorService.shutdown();
}
}
3.2.5、优劣分析
优势
- 简单易懂
- 精度高(通过调整时间窗口的大小来实现不同的限流效果)
- 可扩展性强(可以非常容易地与其他限流算法结合使用)
劣质
- 突发流量无法处理(无法应对短时间内的大量请求,但是一旦到达限流后,请求都会直接暴力被拒绝。这样我们会损失一部分请求,这其实对于产品来说,并不太友好),需要合理调整时间窗口大小。
3.3、漏桶算法
3.3.1、简介
基于(出口)流速来做流控。在网络通信中常用于流量整形,可以很好地解决平滑度问题。
3.3.2、特点
- 可以以任意速率流入水滴到漏桶(流入请求)
- 漏桶具有固定容量,出水速率是固定常量(流出请求)
- 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)
3.3.3、原理
- 思想
将数据包看作是水滴,漏桶看作是一个固定容量的水桶,数据包像水滴一样从桶的顶部流入桶中,并通过桶底的一个小孔以一定的速度流出,从而限制了数据包的流量
- 工作原理
对于每个到来的数据包,都将其加入到漏桶中,并检查漏桶中当前的水量是否超过了漏桶的容量。如果超过了容量,就将多余的数据包丢弃。如果漏桶中还有水,就以一定的速率从桶底输出数据包,保证输出的速率不超过预设的速率,从而达到限流的目的。
3.3.4、适用场景
一般用于保护第三方的系统,比如自身的系统需要调用第三方的接口,为了保护第三方的系统不被自身的调用打垮,便可以通过漏斗算法进行限流,保证自身的流量平稳的打到第三方的接口上。
3.3.5、代码实现
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @ClassName: LeakyBucketLimiter
* @projectName: cat
* @description: 限流算法:漏桶算法
* @author: yangwenxue
* @date: 2023/10/16 10:48
* @Version: 1.0
*/
public class LeakyBucketLimiter {
/**
* 水的流出速率(每秒允许的次数)
*/
private double rate;
/**
* 桶的大小
*/
private double burst;
/**
* 最后更新时间
*/
private long refreshTime;
/**
* 漏桶当前水量
*/
private int water;
/**
* @param rate 水的流出速率
* @param burst 桶的大小
*/
public LeakyBucketLimiter(double rate, double burst) {
this.rate = rate;
this.burst = burst;
}
/**
* 刷新桶的水量
*/
public void refreshWate() {
long now = System.currentTimeMillis();
water = (int) Math.max(0, water - (now - refreshTime) / 1000 * rate);
refreshTime = now;
System.out.println("当前桶余量:" + (burst - water));
}
/**
* 获取令牌
*
* @return
*/
public synchronized boolean tryAcquire() {
refreshWate();
if (water < burst) {
water++;
return true;
} else {
return false;
}
}
// 测试:用一个线程池模拟多个请求,看看是否到达限流的效果
private static LeakyBucketLimiter leakBucket = new LeakyBucketLimiter(80, 200);
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
executorService.execute(() -> {
System.out.println(Thread.currentThread().getName() + ":" + leakBucket.tryAcquire());
});
// try {
// Thread.sleep(10);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
executorService.shutdown();
}
}
3.3.6、优劣分析
优势
- 可以平滑限制请求的处理速度,避免瞬间请求过多导致系统崩溃或者雪崩。
- 可以控制请求的处理速度,使得系统可以适应不同的流量需求,避免过载或者过度闲置。
- 可以通过调整桶的大小和漏出速率来满足不同的限流需求,可以灵活地适应不同的场景。
劣质
- 需要对请求进行缓存,会增加服务器的内存消耗。
- 对于流量波动比较大的场景,需要较为灵活的参数配置才能达到较好的效果。
- 但是面对突发流量的时候,漏桶算法还是循规蹈矩地处理请求,这不是我们想看到的啦。流量变突发时,我们肯定希望系统尽量快点处理请求,提升用户体验嘛。
3.4、令牌桶算法
3.4.1、简介
基于(入口)流速来做流控的一种限流算法。
3.4.2、原理
该算法维护一个固定容量的令牌桶,每秒钟会向令牌桶中放入一定数量的令牌。当有请求到来时,如果令牌桶中有足够的令牌,则请求被允许通过并从令牌桶中消耗一个令牌,否则请求被拒绝。
3.4.3、适用场景
一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。
3.4.4、代码实现
import java.math.BigDecimal;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* @ClassName: TokenLimiter
* @projectName: cat
* @description: 限流算法:令牌桶算法(推荐)
* @author: yangwenxue
* @date: 2023/9/26 15:05
* @Version: 1.0
*/
public class TokenLimiter {
/**
* 令牌
*/
public static final String TOKEN = "lp";
/**
* 阻塞队列,用于存放令牌
*/
private ArrayBlockingQueue<String> blockingQueue;
/**
* 令牌桶容量
*/
private int limit;
/**
* 生产令牌的间隔时间,单位:毫秒
*/
private int period;
/**
* 每次生产令牌的个数
*/
private int amount;
public TokenLimiter(int limit, int period, int amount) {
this.limit = limit;
this.period = period;
this.amount = amount;
blockingQueue = new ArrayBlockingQueue<>(limit);
init();
start();
}
/**
* 创建初始化令牌
*/
private void init() {
for (int i = 0; i < limit; i++) {
blockingQueue.add(TOKEN);
}
}
/**
* 添加令牌
*/
private void addToken() {
for (int i = 0; i < limit; i++) {
// 溢出则返回false
blockingQueue.offer(TOKEN);
}
}
/**
* 获取令牌,如果令牌桶为空则返回false
*
* @return
*/
public synchronized boolean tryAcquire() {
int size = blockingQueue.size();
System.out.println("token剩余数量====>" + size);
BigDecimal total = new BigDecimal(limit);
BigDecimal use = new BigDecimal(size);
BigDecimal ss = use.divide(total, 2, BigDecimal.ROUND_HALF_UP);
System.out.println("使用率:"+ ss.multiply(new BigDecimal(100)) + "%");
//队首元素出队
return blockingQueue.poll() == null ? false : true;
}
/**
* 生产令牌
*/
public void start() {
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
addToken();
}, 500, //第一次执行任务延迟时间
this.period, //连续执行任务的周期,也就是间隔时间
TimeUnit.MILLISECONDS
);
}
// 测试
public static void main(String[] args) throws InterruptedException {
int period = 500;
//先生产3个令牌,减少4个令牌,再每500ms生产3个令牌
TokenLimiter tokenLimiter = new TokenLimiter(3, period, 3);
for (int i = 0; i < 4; i++) {
new Thread(() -> {
while (true) {
String name = "线程[" + Thread.currentThread().getName() + "]";
if (tokenLimiter.tryAcquire()) {
System.out.println(name + "拿到令牌");
} else {
System.out.println(name + "没有拿到令牌");
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
3.4.5、优劣分析
优势
- 稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定。
- 精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流。
- 弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量。
劣质
- 实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法。
- 时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想。
3.5、滑动日志算法(比较冷门)
3.5.1、简介
滑动日志限速算法需要记录请求的时间戳,通常使用有序集合来存储,我们可以在单个有序集合中跟踪用户在一个时间段内所有的请求。
3.5.2、原理
滑动日志算法可以用于实现限流功能,即控制系统在单位时间内处理请求的数量,以保护系统免受过载的影响。以下是滑动日志算法用于限流的原理:
- 划分时间窗口:将时间划分为固定的时间窗口,例如每秒、每分钟或每小时等。
- 维护滑动窗口:使用一个滑动窗口来记录每个时间窗口内的请求次数。这个滑动窗口可以是一个固定长度的队列或数组。
- 请求计数:当一个请求到达时,将其计数加一并放入当前时间窗口中。
- 滑动:随着时间的流逝,滑动窗口会根据当前时间窗口的长度,移除最旧的请求计数,并将新的请求计数添加到最新的时间窗口中。
- 限流判断:在每个时间窗口结束时,统计滑动窗口中的请求计数总和,并与预设的阈值进行比较。如果总请求数超过阈值,则触发限流处理。
- 限流处理:一旦触发限流,可以采取不同的处理策略,如拒绝请求、延迟处理、返回错误信息等。具体的限流策略可以根据实际情况进行选择。
通过滑动日志算法进行限流,可以实现对单位时间内的请求进行精确控制。它基于实时统计的方式,能够动态地适应请求流量的变化,并且在内存使用上比较高效。同时,通过调整时间窗口的长度和阈值的设置,可以灵活地控制限流的精度和灵敏度。
3.5.3、适用场景
对实时性要求高,且需要精确控制请求速率的高级限流场景。
3.5.4、代码实现
import java.util.LinkedList;
import java.util.List;
/**
* @ClassName: SlidingLogRateLimiter
* @projectName: cat
* @description: 限流算法:滑动日志算法(比较冷门)
* @author: yangwenxue
* @date: 2024/1/25 16:35
* @Version: 1.0
*/
public class SlidingLogRateLimiter {
private int requests; // 请求总数
private List<Long> timestamps; // 存储请求的时间戳列表
private long windowDuration; // 窗口持续时间,单位:毫秒
private int threshold; // 窗口内的请求数阀值
/**
* 构造函数
*
* @param threshold 窗口内的请求数阀值
* @param windowDuration 窗口持续时间,单位:毫秒
*/
public SlidingLogRateLimiter(int threshold, long windowDuration) {
this.requests = 0;
this.timestamps = new LinkedList<>();
this.windowDuration = windowDuration;
this.threshold = threshold;
}
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis(); // 获取当前时间戳
// 删除超过窗口持续时间的时间戳
while (!timestamps.isEmpty() && currentTime - timestamps.get(0) > windowDuration) {
timestamps.remove(0);
requests--;
}
if (requests < threshold) { // 判断当前窗口内请求数是否小于阀值
timestamps.add(currentTime); // 将当前时间戳添加到列表
requests++; // 请求总数增加
System.out.println("当前请求总量:" + requests);
return true; // 获取请求成功
}
return false; // 超过阀值,无法获取请求
}
/**
* 测试
* @param args
*/
public static void main(String[] args) {
SlidingLogRateLimiter slidingLogRateLimiter = new SlidingLogRateLimiter(10, 1000);
for (int i = 0; i < 200; i++) {
boolean acquire = slidingLogRateLimiter.tryAcquire();
System.out.println(acquire);
try {
Thread.sleep(60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
3.5.5、优劣分析
优势
- 滑动日志能够避免突发流量,实现较为精准的限流;
- 更加灵活,能够支持更加复杂的限流策略 如多级限流,每分钟不超过100次,每小时不超过300次,每天不超过1000次,我们只需要保存最近24小时所有的请求日志即可实现。
劣质
- 占用存储空间要高于其他限流算法。
四、总结
以上就是常见的五种限流的原理、特点、算法、适用场景介绍,其中比较推荐令牌桶算法。除了上述实现,还有常用工具Guava的RateLimiter(单机),以及阿里巴巴的限流和熔断降级组件sentinel(单机或者分布式),这里不做过多赘述。