Redis限流
前言:限流思路在很多业务场景中很常见,当系统的处理能力有限的时候,限制某个用户的某个行为在某段时间内只能发生次,或者遇到同行的恶意请求等等,面对大批的额外请求,服务器应该如何处理这些请求,来达到系统业务的稳定性。
一、固定时间窗口限流
举个栗子,也是场景一,给用户发送短信验证码,完全由用户进行触发,如果服务端不做任何限制,不能任由用户随便发,不仅第三方短信接口付费,你自己的系统服务器压力也会上升,所以我们需要简单的限制一下用户的这种极端行为。
最开始我们可能直接生成一个验证码然后发送,后来我们想到了用一个计数器来累加用户发短信的次数,而这种情况不能用单个进程的对象来存储次数,需要借助存储介质来存储,比如发送记录存到表里,再次发送之前再进行某一时间段的count查询,但是这样数据量多了难免性能会下降,所以我们最常使用的可能就是在redis加个锁来实现,我们利用redis来简单实现一下。
public String sendSms(String userName, String phone) throws Exception {
if (!getSendLock(userName)) {
throw new Exception("操作太频繁,请稍后重试");
}
// 发送短信
return RandomUtil.randomNumbers(6);
}
private boolean getSendLock(String userName) {
String key = String.format("user:mes:%s", userName);
long count = stringRedisTemplate.opsForValue().increment(key, 1);
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
return count <= 5;
}
方法首先获取锁,这个锁里面相当于一个计数器,由于设置了过期时间为一个小时,所以最终的效果就相当于一个小时里面最多只能发5次验证码,这样就做到了防止滥刷验证码接口的作用,sendSms方法如果判断获取了锁,则执行发生短信的业务,
这里我们简单写个测试类可以模拟一下
@Test
void sendMesTest() {
for (int i = 0; i < 10; i++) {
try {
String code = visitLimitService.sendSms("C123", "");
System.out.println(String.format("发送次数%s,验证码为%s", i + 1, code));
} catch (Exception e) {
log.error("send error{}", e.getMessage());
}
}
}
我们假设执行10次,可以看到前五次发送成功了,后面在这一个小时剩余的时间里继续发送,也会是发送失败的,这样就起到了简单的限制作用。
以上就是固定窗口期限流,可以理解为固定时间段内的计数器,上面也是最简单的限流以及最常用的使用场景。
二、滑动窗口限流
上面的限流方法只能针对单个用户在某一段时间内的操作次数,不能限制整个方法的操作次数,假如请求某个接口,60s内我们限制请求次数为10次,那其他请求只能等着,而且频繁的增加删除key,而且方法如果一直有访问,这个计数器一直在累加,一直不过期,导致后边的请求永远都进不来,我们可以稍微优化一下上面这个锁
在第一次请求的时候设置过期时间,这样假如7:00开始计数,8:00肯定会重新计数,每个小时过期旧的,有请求过来新建key来计数,但是这样也会有问题,临界点也会出现问题(突刺问题),7.59开始请求五次,8:00重制,8:01请求五次,同样也会有问题,所以为了避免临界值问题,让限制更加平滑, 我们引入计数滑动窗口算法,
整个橘色的框向右一直移动,每个方框在这里代表每个小时的计数器,这样我们就能只计算窗口期内的数据,不在窗口期内的数据直接砍掉,如果我们用redis来实现滑动窗口限流,zset结构的score正好可以圈出来这个窗口期,其余的根据score全部删掉,当前时间表示score,由于是set需要确定value的唯一性,用uuid又太占空间,所以我们可以用当前的时间戳或者自增的id来当value值。
这里我们举例,假如某个操作数据库的接口1s钟之内只能请求30次,多了数据库就受不了啦,所以我们利用redis的zset来做一个滑动窗口限制
/**
* 滑动窗口期限流
* @param period 窗口期(s)
* @param maxVisitNum 最大访问次数
* @return
*/
public boolean currentLimit(int period, int maxVisitNum) {
String key = String.format("visit:limit");
long timeMillis = DateUtil.currentSeconds();
// 当前操作时间添加值
stringRedisTemplate.opsForZSet().add(key, DateUtil.current() + "", timeMillis);
// 移除窗口期之外的
stringRedisTemplate.opsForZSet().removeRangeByScore(key, 0, timeMillis - period);
// 当前操作次数
Long size = stringRedisTemplate.opsForZSet().size(key);
// 过期防止占用空间
stringRedisTemplate.expire(key, period + 1, TimeUnit.SECONDS);
return size <= maxVisitNum;
}
因为这里我们按秒计算,所以这里zset的value不能写成秒时间戳,所以我们使用的是毫秒时间戳,为了测试1s内并发访问,我们新建一个线程池进行访问
通过打印的日志我们可以看到确实在1s内访问了100次,而前30次返回的结果是true,从第31次开始已经拒绝访问了,这样就达到了我们1s钟之内限制访问次数为30的限流的目的,
但是上述代码多次连接redis,而且也可能存在中途失败的情况,所以我们使用管道连接,优化上述代码如下:
但是经过测试,上面的代码还是有点问题的,因为是毫秒时间戳,放进去的score是当前的时间戳,但是删除的时候max的值为当前时间戳 - 窗口期 * 1000,这就会导致这个区间不太准确,
在需要特别精细的控制的时候会发现里面的元素删除不及时,导致刚开始的几次请求可以访问,后续请求一直访问不了,所以我们再优化一下粒度。
看控制台输出,我们可以看到在相对时间内,1s内只有三次可以通过,基本满足我们的要求,这里的value值没有实际意义,这里为了计数只要保证唯一性只起到占个坑的作用,但是这种限流方法它要记录这个方法窗口期内发生的所有记录,如果这个量很大,比如限定60s内不超过100w次这样的参数,他是不适合做这样的限流的,因为会消耗大量的存储空间。
三、漏斗限流
漏斗限流,也有叫做漏桶限流,虽然原型稍有不同,但原理一模一样,这里只以漏斗为例,可以认为是注水漏水的过程(发起请求,处理请求),往漏斗中以任意速率注入水(请求次数不稳定的,突发请求平缓请求),因为漏斗的容器是固定的,所以固定的速率流出(服务端恒定处理速度),当水流过大,漏斗会满,溢出水(请求过大拒绝请求)。如果漏斗流水的速率大于灌水的速率,那么漏斗是装不满的,如果漏斗的流水速度小于灌水速度,那么到一定时间漏斗就会满,那么一旦漏斗满了,灌水就需要暂停并等待漏斗流出。
根据以上思路,我们可以模拟一个单机漏斗限流的方法: