如何限流?如何分布式限流?

1,227 阅读4分钟

系统优化三大法宝:缓存、降级、限流。

何为限流?怎么限?分布式限流是个啥?我们一一解释下。

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特点来处理,可以监控集群级别的限流。