限流的原理以及常用算法
限流的原理以及常用算法
高并发的处理有三个比较常用的手段:缓存、限流和降级。
有了限流,就意味着在处理高并发的时候多了一种保护机制,不用担心瞬间流量导致系统挂掉或雪崩,最终做到有损服务而不是不服务;但是限流需要评估好,不能乱用,否则一些正常流量出现一些奇怪的问题而导致用户体验很差造成用户流失。
在一个分布式的高可用系统中,限流是必备的操作。这个流可以是:网络流量,带宽,每秒处理的事务数,每秒请求数,并发请求数,或者业务上的指标等。比如在参加一些秒杀活动的时候,我们可以看到,有时候会出现“系统繁忙,请稍后再试”或者 “请稍等”的提示,那这个系统就很可能是使用了限流的手段。
常见的限流方式
限制总并发数(如数据库连接池、线程池)
限制瞬时并发数(nginx的limit_req_conn模块,用来限制瞬时并发连接数)
限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req_zone模块,限制每秒的平均速率)
其他的还有限制远程接口调用速率、限制MQ的消费速率。
另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
限流算法
限流的实现主要是依靠限流算法,限流算法主要有下面4种。
1、固定时间窗口(计数器)
计数器固定时间窗口算法是最基础也是最简单的一种限流算法。
原理:对一段固定时间窗口内的请求进行计数,如果请求数超过了阈值,则拒绝该请求;如果没有达到设定的阈值,则接受该请求,且计数加1。当时间窗口结束时,重置计数器为0。
优点:实现简单,容易理解
缺点:
1)一段时间内服务不可用
2)限流策略过于粗略,不够平滑,无法应对两个时间窗口临界时间内的突发流量。
举个栗子:
假设这样一个场景,我们限制用户一分钟下单不超过 10 万次,现在在两个时间窗口的交汇点,前后一秒钟内,分别发送 10 万次请求。也就是说,窗口切换的这两秒钟内,系统接收了 20 万下单请求,这个峰值可能会超过系统阈值,影响服务稳定性。
参考代码:
1 <?php 2 3 // 固定时间窗口限流 4 class CounterRateLimiter 5 { 6 private $redis; 7 8 public function __construct() 9 { 10 $this->redis = new \Redis(); 11 } 12 13 /** 14 * @param string $key 计数key 15 * @param int $window 窗口时间 16 * @param int $limit 次数限制 17 * @return bool 18 */ 19 public function accessLimit(string $key, int $window, int $limit) 20 { 21 if (!$key) { 22 return false; 23 } 24 $used = $this->redis->incr($key); 25 // redis异常则直接退出 26 if ($used == false) { 27 return true; 28 } 29 // 超过限制,则重置 30 if ($used > $limit) { 31 $ttl = $this->redis->ttl($key); 32 if ($ttl == -1) { 33 $this->redis->expire($key, $window); 34 } 35 return false; 36 } 37 if ($used == 1) { 38 $this->redis->expire($key, $window); 39 } 40 return true; 41 } 42 43 }
2、滑动时间窗口
滑动窗口算法在固定窗口的基础上,将一个计时窗口分成了若干个小窗口,然后每个小窗口维护一个独立的计数器。
原理:滑动时间窗口算法是对固定时间窗口算法的一种改进,通过切分更细维度的计数器来记录一段时间窗口内请求数量,超过阈值拒绝请求。
优点:准确性更高。
缺点:没有从根本上解决临界问题。基于时间窗口的限流算法,不管是固定时间窗口还是滑动时间窗口,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。
参考代码:
1 <?php 2 3 // 滑动时间窗口限流 4 class SlideRateLimiter 5 { 6 private $redis; 7 8 public function __construct() 9 { 10 $this->redis = new \Redis(); 11 } 12 13 // 1分钟限制60次,将1分钟划分为10个窗口,$window=6s 14 public function accessLimit(string $key, int $window, int $limit) 15 { 16 $count = $this->redis->zCard($key); 17 if ($count >= $limit) { 18 return false; 19 } 20 // 若未超过,将窗口内的访问数增加 21 $this->increment($key, $window); 22 return true; 23 } 24 25 // 滑动窗口计数增长 26 public function increment(string $key, int $window) 27 { 28 // 当前时间 29 $now = time(); 30 $start = $now - $window * 10; 31 // 清除窗口过期成员 32 $this->redis->zRemRangeByScore($key, 0, $start); 33 // 添加当前时间 34 $this->redis->zAdd($key, $now, $now); 35 // 设置key的过期时间 36 $this->redis->expire($key, $window); 37 } 38 }
3、漏桶算法
思路:水(请求)先进入到漏桶里,漏桶以一定速度向外出水。当水流入速度过大,桶会直接溢出。即请求进入一个固定容量的Queue,若Queue满,则拒绝新的请求,可以阻塞,也可以抛异常。
这种模型其实非常类似MQ的思想,利用漏桶削峰填谷,使得Queue的下游具有一个稳定流量。
实现:生产者和消费者
优点:从出口处限制请求速率,并不存在上面计数器法的临界问题,请求曲线始终是平滑的。
缺点:对请求的过滤太精准了,不允许任何的突发流量。比如我们限制每秒下单 10 万次,那 第10 万零 1 次的请求,就会被拒绝。大部分业务场景下,虽然限流了,但还是希望系统允许一定的突发流量,这时候就需要令牌桶算法。
4、令牌桶算法
思路:系统以一个恒定的速率往桶里放入令牌。桶的容量是一定的,如果桶已经满了就不再继续添加。若有请求需要处理,则从令牌桶里获取令牌,当桶里没有令牌,否则拒绝请求或者加入队列进行排队等等。
令牌桶算法并不能实际的控制速率。比如,10秒往桶里放入10000个令牌桶,即10秒内只能处理10000个请求,那么qps就是100。但这种模型可以出现1秒内把10000个令牌全部消费完,即qps为10000。所以令牌桶算法实际是限制的平均流速。具体控制的粒度以放令牌的间隔和每次的量来决定。若想要把流速控制的更加稳定,就要缩短间隔时间。
优点:允许一定程度流量突发,但不会超过设置阈值,对用户友好同时有效保护系统。
缺点:请求异步处理,无法同步返回
漏桶算法VS令牌桶算法
1) 漏桶算法进水速率是不确定的,但是出水速率是一定的,当大量的请求到达时势必会有很多请求被丢弃。
2) 令牌桶算法会根据限流大小,设置一定的速率往桶中加令牌,这个速率可以很方便的修改,如果我们要提高系统对突发流量的处理,我们可以适当的提高生成token的速率。
总结
1)计数器固定时间窗口算法实现简单,容易理解。和漏桶算法相比,新来的请求也能够被马上处理到。但是流量曲线可能不够平滑,有“突刺现象”,在窗口切换时可能会产生两倍于阈值流量的请求。
2)计数器滑动窗口算法作为计数器固定窗口算法的一种改进,有效解决了窗口切换时可能会产生两倍于阈值流量请求的问题。
3)漏桶算法能够对流量起到整流的作用,让随机不稳定的流量以固定的速率流出,但是不能解决流量突发的问题。
4)令牌桶算法作为漏斗算法的一种改进,除了能够起到平滑流量的作用,还允许一定程度的流量突发。
以上四种限流算法都有自身的特点,具体使用时还是要结合自身的场景进行选取,没有最好的算法,只有最合适的算法。比如令牌桶算法一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。而漏桶算法一般用于保护第三方的系统,比如自身的系统需要调用第三方的接口,为了保护第三方的系统不被自身的调用打垮,便可以通过漏桶算法进行限流,保证自身的流量平稳的打到第三方的接口上。
实际的场景中完全可以灵活运用,没有最好的算法,只有最合适的算法。
限流组件
一般而言我们不需要自己实现限流算法来达到限流的目的,不管是接入层限流还是细粒度的接口限流其实都有现成的轮子使用,其实也是用了上述我们所说的限流算法。
- 比如Google Guava 提供的限流工具类 RateLimiter,是基于令牌桶实现的,并且扩展了算法,支持预热功能。
- 阿里开源的限流框架 Sentinel 中的匀速排队限流策略,就采用了漏桶算法。
- Nginx 中的限流模块 limit_req_zone,采用了漏桶算法,还有 OpenResty 中的 resty.limit.req库等等。
参考链接:
https://www.jianshu.com/p/6e4eb31128af
https://juejin.cn/post/6870396751178629127
https://www.cnblogs.com/shijiaqi1066/p/10508115.html