单机限流
一、接口限流
1、RateLimiter(Guava提供 控制速率)
代码实现1:
public class RateLimiterTest {
public static void main(String[] args) throws InterruptedException {
//新建一个每秒限制3个的令牌桶
RateLimiter rateLimiter = RateLimiter.create(3.0);
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10; i++) {
executor.execute(new Runnable() {
@Override
public void run() {
//获取令牌桶中一个令牌,最多等待10秒
if (rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS)) {
System.out.println(Thread.currentThread().getName()+" "+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
}
}
});
}
executor.shutdown();
}
}
/**
* pool-1-thread-1 2019-03-27 16:47:52
* pool-1-thread-10 2019-03-27 16:47:53
* pool-1-thread-9 2019-03-27 16:47:53
* pool-1-thread-8 2019-03-27 16:47:53
* pool-1-thread-7 2019-03-27 16:47:54
* pool-1-thread-6 2019-03-27 16:47:54
* pool-1-thread-5 2019-03-27 16:47:54
* pool-1-thread-4 2019-03-27 16:47:55
* pool-1-thread-3 2019-03-27 16:47:55
* pool-1-thread-2 2019-03-27 16:47:55
*/
/**
* 从RateLimiter获取许可
* 如果该许可可以在不超过timeout的时间内获取得到的话返回true
* 如果无法在timeout过期之前获取得到许可的话返回false
*/
代码实现2:
public class RateLimiterTest {
public static void main(String[] args) throws InterruptedException {
//每1s产生0.5个令牌,也就是说该接口2s只允许调用1次
RateLimiter rateLimiter = RateLimiter.create(0.5,1,TimeUnit.SECONDS);
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10; i++) {
executor.execute(new Runnable() {
@Override
public void run() {
//获取令牌桶中一个令牌,最多等待10秒
if (rateLimiter.tryAcquire(1, 10, TimeUnit.SECONDS)) {
System.out.println(Thread.currentThread().getName()+" "+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
}else {
System.out.println("请求频繁");
}
}
});
}
executor.shutdown();
}
}
/**
* 请求频繁
* 请求频繁
* 请求频繁
* 请求频繁
* 请求频繁
* pool-1-thread-1 2019-03-27 17:18:01
* pool-1-thread-10 2019-03-27 17:18:03
* pool-1-thread-2 2019-03-27 17:18:05
* pool-1-thread-9 2019-03-27 17:18:07
* pool-1-thread-8 2019-03-27 17:18:09
*/
/**
* 接口限制为每2秒请求一次
* 那么同时来10个线程需要20s全部处理完
* 但是rateLimiter.tryAcquire限制了10s内没有获取到令牌就抛出异常
* 所以结果中会有5个是请求频繁的
*/
2、Samephore(JDK提供 控制并发)
代码实现:
public class Demo {
public static void main(String[] args) {
//创建许可证数量为5的Semaphore
Semaphore semaphore = new Semaphore(5);
Runnable runnable = () -> {
String threadName = Thread.currentThread().getName();
try{
//获取一个许可证
semaphore.acquire();
System.out.println(threadName + "执行任务...");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放一个许可证
semaphore.release();
}
};
ExecutorService executorService = Executors.newFixedThreadPool(10);
for(int i = 0; i < 10; i++){
executorService.execute(runnable);
}
executorService.shutdown();
}
}
/* 开始输出:
* pool-1-thread-1执行任务...
* pool-1-thread-5执行任务...
* pool-1-thread-6执行任务...
* pool-1-thread-7执行任务...
* pool-1-thread-3执行任务...
* 三秒后输出:
* pool-1-thread-4执行任务...
* pool-1-thread-8执行任务...
* pool-1-thread-2执行任务...
* pool-1-thread-10执行任务...
* pool-1-thread-9执行任务...
*/
二、整个单机服务限流
利用过滤器实现即可
集群限流
一、网关层限流
1、Nginx(漏桶算法)
Nginx 提供了两种限流手段:一是控制速率,二是控制并发数
1)控制速率
我们需要使用limit_req_zone用来限制单位时间内的请求数,即速率限制,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}
以上配置表示,限制每个IP访问的速度为2r/s,因为Nginx的限流统计是基于毫秒的,我们设置的速度是2r/s,转换一下就是500ms内单个IP只允许通过1个请求,从501ms开始才允许通过第2个请求
我们使用单IP在10ms内发并发送了6个请求的执行结果如下:
从以上结果可以看出他的执行符合我们的预期,只有1个执行成功了,其他的5个被拒绝了(第2个在501ms才会被正常执行)
2)控制速率升级
上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个IP单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用burst关键字开启此设置,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}
burst=4表示每个IP最多允许4个突发请求,如果单个IP在10ms内发送6次请求的结果如下:
从以上结果可以看出,有1个请求被立即处理了,4个请求被放到burst队列里排队执行了,另外1个请求被拒绝了
3)控制并发数
利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数,示例配置如下:
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
...
limit_conn perip 10;
limit_conn perserver 100;
}
其中 limit_conn perip 10表示限制单个IP同时最多能持有10个连接;limit_conn perserver 100表示 server同时能处理并发连接的总数为100个
注意:只有当request header被后端处理后,这个连接才进行计数
2、Tomcat
Tomcat8.5版本的最大线程数在conf/server.xml配置中,如下所示:
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="150"
redirectPort="8443" />
其中maxThreads就是Tomcat的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的
注意:maxThreads的值可以适当的调大一些,此值默认为150(Tomcat版本8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用1MB的JVM内存空间用于作为线程栈使用,并且线程越多GC的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows每个进程中的线程数不允许超过2000,Linux 每个进程中的线程数不允许超过1000
3、SpringCloud-Gateway
不作描述
二、应用层限流(Redis分布式限流实现)
常见限流策略:
- 流量整形: 指不管流量到达的速率多么不稳定,在接收流量后,都将其匀速输出的过程,即“乱进齐出”
- 容忍突发流量: 指的是限流策略允许流量在短时间内突增,且在突增结束后不会影响后续流量的正常限流
- 平滑限流: 指的是在限流周期内流量分布均匀,比如限制10秒内请求次数不超过1000,平滑限流应做到分摊到每秒不超过100次请求。反之,不平滑限流有可能在第1秒就请求了1000次,后面9秒无法再发出任何请求
限流策略 | 流量整形 | 容忍突发流量 | 平滑限流 | 实现复杂度 |
---|---|---|---|---|
固定窗口 | 不支持 | 不支持 | 不支持 | 低 |
滑动窗口 | 不支持 | 不支持 | 不支持 | 中 |
漏桶算法 | 支持 | 不支持 | 支持 | 高 |
令牌桶算法 | 支持 | 支持 | 支持 | 高 |
1、滑动窗口限流
滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取60s的时间,在这60s之内运行最大的访问数为100,此时算法的执行逻辑为:先清除60s之前的所有请求记录,再计算当前集合内请求数量是否大于设定的最大请求数100,如果大于则执行限流拒绝策略,否则插入本次请求记录并返回可以正常执行的标识给客户端
我们可以借助Redis的有序集合ZSet来实现时间窗口算法限流,实现的过程是先使用ZSet的key存储限流的ID,score用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回false执行限流操作,负责允许执行业务逻辑,并且在ZSet中添加一条有效的访问记录,具体实现代码如下
借助Jedis包来操作Redis,在pom.xml添加Jedis依赖,配置如下:
<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>
具体的 Java 实现代码如下:
public class RedisLimit {
// Redis 操作客户端
static Jedis jedis = new Jedis("127.0.0.1", 6379);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 15; i++) {
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("正常执行请求:" + i);
} else {
System.out.println("被限流:" + i);
}
}
// 休眠 4s
Thread.sleep(4000);
// 超过最大执行时间之后,再从发起请求
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("休眠后,正常执行请求");
} else {
System.out.println("休眠后,被限流");
}
}
/**
* 限流方法(滑动时间算法)
* @param key 限流标识
* @param period 限流时间范围(单位:秒)
* @param maxCount 最大运行访问次数
* @return
*/
private static boolean isPeriodLimiting(String key, int period, int maxCount) {
long nowTs = System.currentTimeMillis(); // 当前时间戳
// 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
long currCount = jedis.zcard(key); // 当前请求次数
if (currCount >= maxCount) {
// 超过最大请求次数,执行限流
return false;
}
// 未达到最大请求数,正常执行业务
jedis.zadd(key, nowTs, "" + nowTs); // 请求记录 +1
return true;
}
}
缺陷:
- 使用ZSet存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如60s允许100W访问时
- 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终会导致结果不准确
- 在一定范围内,比如60s内只能有10个请求,当第一秒时就到达了10个请求,那么剩下的59s只能把所有的请求都给拒绝掉
2、漏桶限流
漏桶算法可以粗略认为就是注水漏水过程,往桶中以任意速率流入水,以一定速率流出水,当水超过桶容量则丢弃,因为桶容量是不变的,所以可以保证整体的速率
漏桶是网络环境中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据进入到网络的速率,平滑网络上的突发流量
我们可以使用Redis 4.0版本中提供的Redis-Cell模块,该模块使用的是漏斗算法,并且提供了原子的限流指令,而且依靠Redis这个天生的分布式程序就可以实现比较完美的限流了
Redis-Cell实现限流的方法也很简单,只需要使用一条指令(cl.throttle)即可,使用示例如下:
CL.THROTTLE key 15 30 60 1
上面这个指令的意思是允许「用户回复行为」的频率为每60s最多30次 (漏水速率),漏斗的初始容量为15,也就是说一开始可以连续回复15个帖子,然后才开始受漏水速率的影响
可以看到这个指令中漏水速率变成了2个参数,替代了之前的单个浮点数,用两个参数相除的结果来表达漏水速率相对单个浮点数要更加直观一些
在执行限流指令时,如果被拒绝了,就需要丢弃或重试。cl.throttle指令考虑得非常周到,连重试时间都算好了,直接取返回结果数组的第四个值进行sleep即可,如果不想阻塞线程,也可以异步定时任务来重试
返回值:
0 运行
1 拒绝
15 漏斗容量capacity
14 漏斗剩余空间left_quota
-1 被拒绝了,需要多长时间后重试(单位秒)
2 需要多长时间后,漏斗完全空出来(单位秒)
Docker安装:
docker pull hsz1273327/redis-cell //拉取镜像
docker run -d -p 6380:6379 hsz1273327/redis-cell //映射至6380端口
redis-cli -p 6380 //启动客户端
Java集成:
<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.5.0</version>
</dependency>
import redis.clients.jedis.commands.ProtocolCommand;
import redis.clients.jedis.util.SafeEncoder;
/**
* 因为是扩展的指令,所以需要自己定义指令
*/
public enum CellCommand implements ProtocolCommand {
CLTHROTTLE("CL.THROTTLE");
private final byte[] raw;
CellCommand(String alt) {
raw = SafeEncoder.encode(alt);
}
@Override
public byte[] getRaw() {
return raw;
}
}
/**
* 测试类
*/
public class RedisCellTest {
private Jedis jedis;
public RedisCellTest(Jedis jedis) {
this.jedis = jedis;
}
public boolean isActionAllow(String key, String capacity, String number, String time) {
Client client = jedis.getClient();
boolean is;
client.sendCommand(CellCommand.CLTHROTTLE, key, capacity, number, time);
List<Long> replay = client.getIntegerMultiBulkReply();
if (replay.get(2) > 0) {
is = true;
} else {
is = false;
}
client.close();
return is;
}
public static void main(String[] args) {
RedisCellTest redisCellTest = new RedisCellTest(new Jedis("192.168.31.149", 6380));
for (int i = 0; i < 20; i++) {
//这里得到结果之后可以进行业务处理
System.out.println(redisCellTest.isActionAllow("testkey:reply", "15", "30", "60"));
}
}
}
缺陷:可以解决滑动窗口限流缺陷,但是无法应对短时间的突发流量
3、令牌桶限流(Redis+Lua)
令牌桶算法有点类似于生产者消费者模式,专门有一个生产者往令牌桶中以恒定速率放入令牌,而请求处理器(消费者)在处理请求时必须先从桶中获得令牌,如果没有拿到令牌,有两种策略:一种是直接返回拒绝请求;另一种是等待一段时间,再次尝试获取令牌
令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送
定义注解:
/**
* @author zhenghaorui
* @description 自定义限流注解
* @date 2022/4/15
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Limit {
/**
* key
*/
String key() default "";
/**
* 时间 单位(秒)
*/
int period();
/**
* 限制访问次数
*/
int count();
}
限流切面:
/**
* @author zhenghaorui
* @description 限流切面实现
* @date 2022/4/15
*/
@Aspect
@Configuration
@Slf4j
public class LimitAspect {
private final RedisTemplate<Object, Object> redisTemplate;
@Autowired
public LimitAspect (RedisTemplate<Object, Object> redisTemplate) {
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
this.limitRedisTemplate = limitRedisTemplate;
}
/**
* @param proceedingJoinPoint
* @author zhenghaorui
* @description 切面
* @date 2022/4/15
*/
@Around("execution(public * *(..)) && @annotation(com.demo.annotation.Limit)")
public Object interceptor(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
Method method = methodSignature.getMethod();
Limit limit = method.getAnnotation(Limit.class);
String key = limit.key();
ImmutableList<Object> keys = ImmutableList.of(key);
String lua = "local num"
RedisScript<Number> redisScript = new DefaultRedisScript<>(lua, Number.class);
Number number= redisTemplate.execute(redisScript, keys, limit.count(), limit.period());
if (null != number && number.intValue() <= limit.count()) {
log.info("方法{}: 第{}次访问 有效时间为 {}", limit.key(), number, redisTemplate.getExpire(limit.key()));
return proceedingJoinPoint.proceed();
} else {
throw new BadRequestException("访问次数受限");
}
}
}
Controller层:
@Limit(key = "limitTest", period = 60, count = 10)
@GetMapping("/limitTest")
public void limitTest() {
//业务逻辑
}