本文主要讲解常见的几大限流算法,包括:固定窗口计数器限流算法、滑动窗口计数器限流算法、漏桶限流算法、令牌桶限流算法,此外还会讲解如何使用Sentinel、谷歌提供的Guava工具包中的RateLimiter限流工具类实现限流,如果你觉得本文对你有所帮助,欢迎点赞,您的鼓励将是我持续输出的动力
PS:如果文中有描述不当、错误、侵权的地方还恳请您能告知博主,博主将立即做出修改,同时将送上我真挚的感谢
什么是限流?
限流(Current limiting)是一种控制系统中请求或流量的速率的机制。在计算机系统或网络应用中,通过限制单位时间内的请求数量或数据传输速率,可以有效地平衡系统负载,保护后端资源免受过多的请求或流量冲击。
Web开发中的例子:比如我吗有时候在访问一个网站时,如果我吗刷新太快就会得到一个失败的结果,比如这个接口
https://api.nbhao.org/v1/email/verify
用于判断邮箱是否真实存在,这个接口一秒钟只会处理一次请求,其它请求直接回返回"Frequent requests"的错误信息生活中的例子:我们去看演唱会、景区旅游,这些地方只会卖固定数量的票,目的就算限制人流量过大,造成严重的交通堵塞
限流的作用有哪些?
常见的限流策略有哪些?
这里在限流前,先对http://localhost:10086/hello
接口进行一个压测
可以看到 并发量为 493,结果系统都能够成功接收,也就是当前接口是处于来者不拒的状况(这有点危险啊,如果这样的接口上线,遇到不怀好意的人,同时如果服务器是直接查询数据库的话,那么服务器会被打爆!)
计数器限流算法(Counter current limiting algorithm)是一种简单直观的限流算法,它是采用固定窗口计算策略实现的,它基于一个计数器来统计单位时间内的请求数量,并与预设的阈值进行比较。如果请求数量超过了阈值,则拒绝额外的请求;否则,接受请求并将计数器递增。
计数器限流算法的优缺点
计算器限流算法的适用场景:
温馨提示:如果想要更加灵活和精确的流量控制,不推荐适用这种限流算法
计数器限流算法的常见实现
如果系统很简单,且没有分布式需求1,推荐使用方式一;如果系统有分布式需求的,推荐使用方式二
具体实现逻辑如下:
counter
,用于记录单位时间内的请求数量。package com.ghp.demo.limiter.impl;
import com.ghp.demo.limiter.TrafficLimiter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* @author ghp
* @title
* @description 基于共享变量实现计数器限流器
*/
@Component(value = "CounterLimiterSharedVariable")
public class CounterLimiterSharedVariable implements TrafficLimiter {
// 这个变量,我用来测试的,无关紧要
private static int i = 0;
@Resource
private RedisTemplate redisTemplate;
/**
* 窗口起始时间
*/
private long start = System.currentTimeMillis();
/**
* 请求次数
*/
private int count;
/**
* 每秒限流的最大请求数,一个时间窗口内超过这个阈值就会被限流
*/
private int threshold = 1;
/**
* 时间窗口时长,单位ms。
* 结合 threshold 参数,这两个属性共同决定接口并发访问量
* 此时 threshold 是1,而 interval 是 1s,这就意味着一秒钟接口只能被访问一次
*/
private long interval = 1000L;
/**
* 判断是否限流
*
* @return返回 true代表限流,false代表通过
*/
@Override
public synchronized boolean limit() {
long now = System.currentTimeMillis();
System.out.printf("第%s个请求,当前时间%s时窗口中请求数量为%s,\n", i++, now, count);
// 判断当前请求是否在当前时间窗口
if (now < start + interval) {
// 在当前时间窗口内,判断当前时间窗口请求数加1是否超过每秒限流的最大请求数
if (count + 1 > threshold) {
return true;
}
// 当前时间戳口内的请求数量并未超过阈值
count++;
return false;
} else {
// 不在当前时间窗口内,直接开启新窗口,并且重置请求次数
start = now;
count = 1;
return false;
}
}
}
qps为10时,成功请求数为10,符合我的预期
package com.ghp.demo.limiter.impl;
import com.ghp.demo.limiter.TrafficLimiter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @author ghp
* @title
* @description 基于Redis实现计数器限流器
*/
@Component(value = "CounterLimiterRedis")
public class CounterLimiterRedis implements TrafficLimiter {
// 这个变量,我用来测试的,无关紧要
private static int i = 0;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 一个窗口中能够接收的最大请求数量
*/
private int threshold = 1;
/**
* 时间间隔,单位ms。决定窗口的大小
* 结合 threshold 参数,这两个属性共同决定接口并发访问量
* 此时 threshold 是1,而 interval 是 1s,这就意味着一秒钟接口只能被访问一次
*/
private long interval = 3000L;
/**
* 判断是否限流
*
* @return返回 true代表限流,false代表通过
*/
@Override
public synchronized boolean limit() {
String key = String.valueOf(System.currentTimeMillis() / interval);
Long result = stringRedisTemplate.opsForValue().increment(key);
System.out.printf("第%s个请求,当前时间%s时窗口中请求数量为%s,\n", i++, key, result);
stringRedisTemplate.expire(key, interval, TimeUnit.MILLISECONDS);
// 当前当前时间窗口中的请求数量是否达到阈值
if (result > threshold){
// 达到阈值,直接限流
return true;
}
// 未达到阈值,放行
return false;
}
}
滑动时间窗口算法(Sliding time window algorithm)是一种在时间序列数据中进行实时计算的方法。它通过定义一个固定长度的时间窗口,在该窗口内对数据进行处理和分析。
滑动时间窗口算法优缺点
滑动时间窗口算法适用场景
这个滑动窗口本质是多个固定窗口的集合,相当于化整为零的思想,在原本一个大窗口的基础上再划分一些小窗口(比如:之前在计数器限流算法中,一个窗口是1秒钟,而言在我我们再将这个窗口划分出10个小窗口,每个小窗口为100毫秒),每次计数都是记录当前窗口前的9个窗口,而不是之前一样直接将整个窗口给清零重新计数,这样做的好处很明显,能够让计数更加精确,避免之前计数器限流算法中出现的,两个窗口中间的流量是整个窗口流量的2倍!窗口划分的越细,那么精度就越高,同时维护成本也就越高,性能也就越低。
不可避免的,这个滑动事件窗口也存在一定的精度问题,(当然这个精度是可以由我们自己控制的),这个问题其实就算之前固定窗口存在的问题,只是现在我们将这个问题给细化了,但是仍然存在!
算法的步骤如下:
备注:
注意事项:
package com.ghp.demo.limiter.impl;
import com.ghp.demo.limiter.TrafficLimiter;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
/**
* @author ghp
* @title 滑动时间窗口限流器
* @description
*/
@Component(value = "SlidingTimeWindowLimiter")
public class SlidingTimeWindowLimiter implements TrafficLimiter {
// 这个变量,我用来测试的,无关紧要
private static int i = 0;
/**
* 记录整个窗口中接口请求次数
*/
private int count;
/**
* 使用 LinkedList 来记录滑动时间窗口中每一个格子中请求的数量
*/
private LinkedList<Integer> slots = new LinkedList<>();
/**
* 每秒限流的最大请求数
*/
private int threshold = 1;
/**
* 滑动时间窗口里的每个格子的时间长度,单位ms
*/
private long interval = 100L;
/**
* 滑动时间窗口里的格子数量,划分的格子越多精度越高
* 窗口中格子的数量 和 每个格子的长度 决定了整个滑动时间窗口的大小
* 滑动时间窗口的时间跨度是:part * interval
*/
private int partNum = 10;
public SlidingTimeWindowLimiter() {
// 初始第一个窗口,第一个窗口中接口的请求数量为0
slots.addLast(0);
// 定义线程任务,不断循环执行滑动窗口
// 滑动过程:最开始添加一个格子(初始化时),然后不断添加格子,当发现格子数量超过窗口
// 中最大格子数量,移除窗口中第一个格子,在窗口末尾添加一个格子
new Thread(() -> {
while (true) {
try {
// 休眠 100ms,每一个格子的时间间隔是 100ms
Thread.sleep(interval);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 100ms 后往窗口中添加一个新的块
slots.addLast(0);
// 判断窗口新增块之后是否超过最大块数量
if (slots.size() > partNum) {
// 超过窗口中块数量最大限制,移除窗口最开始的那个块,同时更新 count
count -= slots.peekFirst();
slots.removeFirst();
System.out.printf("移除第%s个格子,此时窗口中接口的请求量为%s\n", i++, count);
}
}
}).start();
}
/**
* 判断是否限流
*
* @return返回 true代表限流,false代表通过
*/
@Override
public synchronized boolean limit() {
// 判断是否限流
if (((count + 1)) > threshold) {
return true;
}
// 未限流,可以进行接口请求,此时需要更新 当前块中的请求数量 和 窗口当前最大接口请求数
slots.set(slots.size() - 1, slots.peekLast() + 1);
count++;
return false;
}
}
备注:关于窗口的滑动,也可以不需要单独开启一个线程(或定时任务)专门去滑动,可以注解选择通过取模的方式进行窗口滑动
两种方式相比较:
使用取模来实现滑动窗口的限流机制的优点是实现简单、轻量级,不需要额外的线程。它适用于单机场景和低并发的情况下,对系统性能的影响较小。同时,取模操作是原子的,不会出现线程竞争的问题。然而,取模操作的缺点是无法做到精确的限流。由于请求的分布不是非常均匀,可能会导致某个时间窗口内的实际请求超过了限流阈值,而在其他时间窗口内则未超过。这意味着在某些情况下,可能会导致请求被错误地拒绝或者允许通过。相比之下,使用单独的线程实现限流可以更精确地控制请求的速率。通过定时任务、令牌桶等机制,可以实现更细粒度的限流控制。然而,这种方式会引入额外的线程开销和系统资源消耗,对系统的性能和可伸缩性有一定的影响。
最后还是那句话"具体场景具体分析",具体来说如果你的系统平均并发量(也就是并发量高的次数特别少,平常都是比较低的并发量)可以选择取模实现滑动窗口;如果你的系统常年处在高并发的状况,并且你需要更加精确、实时地进行流量控制,此时就可以选择单独开一个线程或定时任务实现窗口滑动
漏桶限流算法(Leaky Bucket Algorithm)是一种常用的网络流量控制算法,用于平衡系统的吞吐量和请求的到达速率。它可以有效地限制流量的峰值和平均速率,防止系统过载和拥塞。
漏桶限流算法基本原理:
漏桶限流算法的特点:
漏桶限流算法的优缺点:
漏桶限流算法的适用场景:
下面这张图十分形象地描述了这个算法:
备注:关于桶容量的设置,主要有两种方式
相较于两种策略,方式二可能更加安全
具体的实现步骤:
package com.ghp.demo.limiter.impl;
import com.ghp.demo.limiter.TrafficLimiter;
import org.springframework.stereotype.Component;
/**
* @author ghp
* @title 漏桶限流器
* @description
*/
@Component(value = "LeakyBucketLimiter")
public class LeakyBucketLimiter implements TrafficLimiter {
// 这个变量,我用来测试的,无关紧要
private static int i = 1;
/**
* 桶起始时间
*/
private long start = System.currentTimeMillis();
/**
* 桶的容量,桶最大能够接收的请求数量
*/
private long capacity = 10L;
/**
* 水漏出的速率 10个请求/s(也就是每秒系统能处理的请求数)
*/
private long rate = 1;
/**
* 当前水量(当前桶中累积请求数)
*/
private long water = 0;
/**
* 判断是否限流
*
* @return返回 true代表限流,false代表通过
*/
public synchronized boolean limit() {
// 计算桶漏掉水之后还剩余的水(剩余的水就算还可以接收请求的数量)
long now = System.currentTimeMillis();
water = Math.max(0, water - ((now - start) / 1000) * rate);
// 更新初始位置 start
start = now;
// 判断当前的水有没有超过桶的最大容量
System.out.printf("第%s次请求,当前桶中的水%s\n" , i++, water);
if ((water + 1) <= capacity) {
water++;
return false;
} else {
return true;
}
}
}
我设置qps为10时,压测情况:
令牌桶算法(Token Bucket Algorithm)也是一种常用的网络流量控制算法,用于平滑限制请求的到达速率。与漏桶限流算法类似,令牌桶算法可以有效地控制系统的吞吐量和请求处理能力。
令牌桶算法具体实现步骤:
定义一个令牌桶,其中包含固定数量的令牌,每个令牌表示一个请求的通行权。
以固定的速率产生令牌,并存放到令牌桶中,即不断地往桶里添加令牌。
当有请求到达时,需要从令牌桶中获取一个令牌:
1 如果桶中还有令牌,则可以处理该请求,并从桶中取走一个令牌。
2 如果桶中没有令牌可用,则拒绝该请求或等待直到有令牌可用。
package com.ghp.demo.limiter.impl;
import com.ghp.demo.limiter.TrafficLimiter;
import org.springframework.stereotype.Component;
/**
* @author ghp
* @title 令牌桶限流器
* @description
*/
@Component(value = "TokenBucketLimiter")
public class TokenBucketLimiter implements TrafficLimiter {
// 这个变量,我用来测试的,无关紧要
private static int i = 1;
/**
* 开始时间(用于记录上一次请求的时间)
*/
private long start = System.currentTimeMillis();
/**
* 桶的容量,也就是一次最大可以接收多少请求
*/
private long capacity = 10;
/**
* 令牌放入速度,也就是系统能够处理请求的速率
* 当前速率为 1,也就是每秒钟往令牌桶中放入一个令牌
*/
private long rate = 1;
/**
* 当前令牌数量
*/
private long tokens = 0;
/**
* 判断是否限流
*
* @return返回 true代表限流,false代表通过
*/
@Override
public synchronized boolean limit() {
long now = System.currentTimeMillis();
// 计算当前当前桶中补充后还有多少令牌(这一步和之前的漏桶完全就是反着来的)
tokens = Math.min(capacity, tokens + ((now - start) / 1000) * rate);
System.out.printf("第%s次请求,当前桶中的令牌数量为%s\n", i++, tokens);
// 更新起始时间
start = now;
// 判断
if (tokens < 1) {
// 桶中令牌数量不足1,需要限流
return true;
} else {
// 还有令牌,领取令牌
tokens--;
return false;
}
}
}
我设置qps为10时,压测情况:
成功抗住了压测,qps设置10,压测成功请求也是10,符合我的预期
Sentinel 是阿里巴巴开源的一个流量控制和熔断框架,用于解决分布式系统中的限流、熔断和降级等问题,是一个十分成熟的限流解决方案,同时Sentinel 底层集成了四种常见的算法,可以做到限流策略的灵活切换。
当然本文重点是讲限流,关于Sentinel相关详情可以参考下方链接
推荐阅读:
介绍 · alibaba/Sentinel Wiki (github.com)
quick-start | Sentinel (sentinelguard.io)
Sentinel 介绍 - 《Sentinel v1.8 文档手册》 - 书栈网 · BookStack
Step1:控制台jar包下载地址:Tags · alibaba/Sentinel (github.com)
Step2:进入 cmd 窗口,运行下面指令即可启动控制台
# 方式一;默认启动
java -jar sentinel-dashboard-1.8.6.jar
# 方式二:指定端口和IP启动
java -Dserver.port=8081 -Dcsp.sentinel.dashboard.server=127.0.0.1:8081 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.6.jar
备注:要想要后台启动,可以使用nohup
指令,示例: nohup [java -jar sentinel-dashboard-1.8.6.jar] &
,然后通过 jps
指令查看是否启动成功
Step3:浏览器访问http://IP:8080/
默认的用户名和密码是 sentinel
Step4:构建Maven工程
1)依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
<version>2.4.2version>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
<version>2021.1version>
dependency>
2)配置文件
server:
port: 10086
spring:
application:
name: sentinel-demo
cloud:
sentinel:
transport:
dashboard: localhost:8080 #控制台的地址
3)测试代码
package com.ghp.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
public static int count = 0;
@GetMapping("/hello")
public String hello() {
System.out.printf("接口被访问%s次\n", count++);
return "Hello world!";
}
}
Step5:配置限流
此外还可以配置高级选项:
1)快速失败,当求情达到阈值时,直接抛出异常Blocked by Sentinel(flow limiting),服务器返回状态码429
快速失败,默认流控效果是快速失败,也就是访问超过的流量直接返回失败信息
2)Warm Up,预热流控效果,如果设置单机阈值为10,并不是一开始 1秒中就能够访问 10个请求,而是一开始只放行少量请求,然后慢慢得达到 每秒10个请求,选中 Warm Up 可以自己选定预热时长,如果 预热时长是 10 秒钟,那么一开始可能就只能访问 1次,第二秒访问两次,到第 10秒才能1秒访问10次
备注:默认最开始的阈值是 threshold/3
,如果阈值是 10,一开始一秒只能访问 10/3≈3 个请求
3)排队等待,超过阈值的直接进入等待队列,这个可以配置超时时间,如果排队的请求超过了这个超时时间,就会返回失败信息。
比如:我们阈值设置为1,超时时间设置为10s,此时我们一秒发送100个请求,最终能够成功处理的请求十多个请求
谷歌提供了名为Guava的开源Java库,其中包含了一个RateLimiter类,用于实现速率限制功能。
Google Guava的RateLimiter是基于令牌桶算法实现的。使用RateLimiter可以控制在一定时间内允许执行的操作次数或单位时间内允许执行的操作的频率。
Step1:引入依赖
<dependency>
<groupId>com.google.guavagroupId>
<artifactId>guavaartifactId>
<version>31.1-jreversion>
dependency>
Step2:编码
@Override
public boolean limit() {
RateLimiter rateLimiter = rateLimiterMap.computeIfAbsent(methodName, k ->
RateLimiter.create(10));
// 判断请求是否限流
if (!rateLimiter.tryAcquire()) {
// 限流
return true;
}
return false;
}
我设置qps为10时,压测结果:
最终测试,可以发现并没有扛住压力测试,我qps设置的是10,但是最终成功请求数居然是20!结果并不符合我的预期
总的来讲:
最后来一句万金油的话“最终系统选择使用哪一种限流策略,具体情况具体分析”,关于限流策略中的哪些参数选择,比如:固定窗口的大小,滑动窗口中滑块的大小,漏桶的容量、漏水的速率、令牌桶的大小、令牌的生成速率,还有如何确保线程安全,这些东西都是特别需要仔细斟酌的(可以结合做压力测试做最终的选择)
bug1:启动项目报循环依赖问题
The dependencies of some of the beans in the application context form a cycle:
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$EnableWebMvcConfiguration
┌─────┐
| com.alibaba.cloud.sentinel.SentinelWebAutoConfiguration (field private java.util.Optional com.alibaba.cloud.sentinel.SentinelWebAutoConfiguration.sentinelWebInterceptorOptional)
└─────┘
问题原因:SpringBoot版本与SpringCloud版本冲突。
我项目中使用的是 SpringBoot2.7版本的 Web依赖,而引入的SpringCloud是2021.1,版本对应关系参考下方这张图:
问题解决:将SpringBoot版本从 2.7.2 修改为 2.4.2
bug2:自定义SDK后,第三方项目引入结果注解不生效
问题原因:由于当前项目的包路径和SDK中Bean的跑路径不同,导致SDK中的Bean没有被Spring加载到 IOC 容器中
问题解决:在第三方项目中添加@Important(RequestLimitConfig.class)
,这样就能够导入SDK的配置类,然后在SDK的配置类中添加@ComponentScan
注解,这样就能够成功引入SDK了
参考文章
- 面试必备:4种经典限流算法讲解 - 知乎 (zhihu.com)
- 限流之计数器算法-CSDN博客
- 深入理解 Sentinel 中的限流算法 - 知乎 (zhihu.com)
- 谷歌Guava限流工具RateLimiter - 知乎 (zhihu.com)
- 一文搞懂高频面试题之限流算法,从算法原理到实现,再到对比分析 - 知乎 (zhihu.com)
- 关于滑动时间窗口算法 - 知乎 (zhihu.com)
分布式需求:是指同一个接口的服务部署在多个不同的节点(服务器)上,状态需要跨越节点实现共享 ↩︎