系统优化三大法宝:缓存、降级、限流。
何为限流?怎么限?分布式限流是个啥?我们一一解释下。
1、什么是限流
举个例子:小A发了一条抖音,然后花钱请了一个团队帮自己刷赞。按照模型和行为分析推算,一般一条抖音在一分钟内点赞次数不会超过1000次。为了防止这种恶意刷赞的行为,就要加上限流,当你一分钟内点赞次数超过1000了,再点赞的时候就提示你被限流了。
2、怎么限流
简单介绍四种常见的算法
2.1、计数器法
普通计数
拿Redis举例,设置一个限流的key,key=抖音作品ID+当前分钟级别时间。设置一个默认计时器,每收到一个点赞请求计数器+1,当计数器累加到1000之后,后续请求直接拒绝即可。
假设作品ID:10001;收到第请求后,检测计数器是否存在,不存在则设置,存在即校验:
$key = "limit_10001_".date('YmdHi');
$limit = 1000;
$cnt = $redis->get($key);
if($cnt > $limit){
//提示限流,并返回
}
//限流通过,计数器+1;
$redis->incr($key);
可能存在的问题:
每分钟都是一个新的key,假如用户在 2020-06-01 12:00:59 请求了1000次,在 2020-06-01 12:01:00 又请求了1000次,我们虽然达到了我们的目的,但是连续在两秒内对服务器进行 QPS 1000 的请求还是一个比较危险的行为,这只是一个抖音作品,如果是很多个作品,那么可能造成集群直接被打死。
升级计数
有没有什么办法可以避免连续两秒以1000的QPS轰炸服务器呢?
$key = "limit_10001";
$limit = 1000;
$cnt = $redis->get($key);
if($cnt > $limit){
//提示限流,并返回
}
$nowCnt = $redis->incr($key);
if($nowCnt == 1){
$redis->expire($key, 60);
}可能存在的问题:
如何确保incr、expire的原子操作?如果不确保的话。会造成资源竞争、内存泄漏等问题(可以通过lua脚本的EVAL来避免,也可以用redis事务、set来保证原子操作)
等待计数
如果还想加上让用户等待的时间如何处理呢?
假设作品ID:10001;收到第请求后,检测计数器是否存在,不存在则设置,存在即校验:
$key = "limit_10001";
$limit = 1000;
$cnt = $redis->get($key);
if($cnt > $limit){
//提示限流,并返回
}
//事务保证原子操作,每次计数器累加过期时间都重置60秒后
$redis->multi()->incr($key)->expire($key, 60)->exec();可能存在的问题:
12:00:00 的时候接收到 999 个点赞,12:00:59 的时候接受到 1 个点赞,点赞次数满1000,重置过期时间后,12:01:58 的时候再次点赞仍然提示限流中,这种必须要通过等待才能进行后续操作,可能会带来不好的用户体验
队列计数
假设作品ID:10001;收到第请求后,检测计数器是否存在,不存在则设置,存在即校验:
$key = "limit_10001";
$limit = 1000;
$time = time();
$cnt = $redis->lLen($key);
if($cnt > $limit){
//提示限流,并返回
}
//队尾追加本次请求时间戳
$redis->rPush($key, $time);
//获取对头的元素,如果超时即丢弃
$head = $redis->lrange($key, 0, 0);
if($time - $head >= 60{
$redis->lpop($key);
}可能存在的问题:
为了确保操作的原子性,需要开启redis的事务操作,上例中也可以通过设置过期时间达到资源最大利用率。
滑动窗口
有没有更好的办法,可以让流量平均一下?避免这种 1000 QPS的波峰,缓解服务器压力?
这种情况下可以考虑下滑动窗口法,在我们例子中,整个区块是1分钟=60秒,那么我们对1分钟进行拆分是不是就可以了,将整个区块分成10块,每6秒一个小区块,平均到每个小区块里限流上限为100的QPS。
2.2、令牌桶算法
假设限速为100r/s,则按需每10ms就需要生成一个令牌放入桶中。
桶中最多存在b个令牌,当桶内放满令牌后,后续放入的令牌丢弃或拒绝。
当请求到达,从桶内删除一个令牌(或多个令牌),处理业务逻辑。
当桶内已无令牌,则该请求舍弃或者放在缓冲区等待。
如图:

$key = "limit_10001";
$limit = 600;
$time = 60; //1min允许访问的最大次数600
//利用Redis的乐观锁来监听事务,防止操作过程中数据已被修改
$redis->watch($key);
$info = $redis->get($key);
if($info){
$data = json_decode($info, true);
//计算当前桶内剩余令牌数
//在未满的情况下,获取桶内剩余的令牌数 + 此段时间内生成的令牌数 - 本次要使用的1个
$surplus = (time()-$data['lastTime']) * ($limit/$time) + $data['surplus'] - 1;
$surplus = min($limit, $surplus);
if($surplus <= 0){
//已用完,限流返回
}
$data = ['surplus' => $surplus, 'lastTime' => time()];
}else{
//默认第一次存储桶内最大令牌数
$data = ['surplus' => $limit, 'lastTime' => time()];
}
$redis->multi();
$redis->set($key, json_encode($data));
$ret = $redis->exec();
if(!$ret){
//操作失败,请稍后重试
}2.3、漏桶算法
可以粗略的认为就是注水漏水过程,往桶中以一定速率流出水,以任意速率流入水,当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率,是一个典型的生产者-消费者模型。
生产者:请求到达后先进入水池中,当超过了流水速率则延迟消费。
消费者:以固定速率流出,消费请求。

2.4、区别对比
令牌桶:固定流入,不固定流出,允许一定的并发。
漏桶:固定流出,不固定流入,请求相对比较平缓。
3、分布式限流
一般采用两种方式:
Nginx+Lua:采用共享内存的方式,将请求计数或者数据存储到机器内存中,但是局限于单机,集群节点内无法共享。
Lua+Redis:采用Redis作为统一存储,利用Lua的EVAL特点来处理,可以监控集群级别的限流。