单机限流策略(应用级限流)
在学习分布式限流之前,需要先了解一下单机基本的限流策略。
限流即流量限制,或者高大上一点,叫做流量整形,限流的目的是在遇到流量高峰期或者流量突增(流量尖刺)时,把流量速率限制在系统所能接受的合理范围之内,不至于让系统被高流量击垮。限流的结果对于调用方来说,可能是延迟获得返回,或者直接调用失败。
其实,服务降级系统中的限流并没有我们想象的那么简单,第一,限流方案必须是可选择的,没有任何方案可以适用所有场景,每种限流方案都有自己适合的场景,我们得根据业务和资源的特性和要求来选择限流方案;第二,限流策略必须是可配的,对策略调优一定是个长期的过程,这里说的策略,可以理解成建立在某个限流方案上的一套相关参数。
单机限流主要有几种思路:
- 限制单位时间内的访问次数,比如一分钟内最多访问多少次
- 限制并发量,同一时刻访问量不超过一个阈值
单机限流主要有3种实现方式:
- 计数器
- 周期清零(对应第一种思路的具体实现)
- 并发计数(对应第二种思路的具体实现)
- 漏桶 Leaky Bucket(对应第二种思路的具体实现)
- 令牌桶 Token Bucket(对应第一种(每个周期添加固定的令牌)+第二种(修改每次获取的令牌数)思路的具体实现) Guava rateLimiter
接下来分别讲讲这几种思路以及对应实现方式的优缺点。
(1)限制单位时间内访问次数,通常会用计数器来实现。考虑到线程安全问题,选择使用 AtomicLong作为计数器。在实际应用中,通常会采用环形结构的计数器,这是一个不错的计数方式。这里用 AtomicLong[3] 数组来计数,AtomicLong[0] 表示前一个周期的计数器,AtomicLong[1] 表示当前周期的计数器,AtomicLong[2] 表示下一个周期的计数器。不停的循环,来保证计数以及统计的需求。上一周期的数据可以保存用于发送。下一个周期的数据清0,以便在新周期该位可以用于计数。清0的方式也有多种,例如交给某一个线程来定时清0,或者,在计数时做判断,这时需要额外的变量存储计数更新时间。
这种方式实现起来简单,阈值如果可以动态配置,甚至可以作为业务开关来使用。
但是这种方式也有一定局限性,突刺消耗,指一个周期内前段时间的突增导致资源被大量消耗,致使剩余时间内该服务拒绝服务或延迟提供服务到下一个周期。此外,这种粗略的限制方式对于大量调用在极短时间内的冲击来说,并不能很好应对,可能会直接压跨系统甚至造成雪崩效应。
(2)限制并发量,这种方式更加成熟,限制并发量的同时,也限制了单位时间内的访问次数。这种思路的实现可以用信号量来做,但是这种方式,只控制了边界条件。
漏桶算法,可以说是一种流量整型算法,它严格控制了输出的速率,而对于输入的任务,是有一定容量的,当超过这个容量,只能丢弃。首先想到的就是这对于业务来说不怎么友好,当业务流量激增,就不得不重新调参。而且对于大型服务来说,漏桶处可能会成为瓶颈。线程池 + 有限容量的队列,就是一个漏桶。漏桶基本可以看成一个缓冲队列的设计。
(3)既能限制单位时间内的访问次数,又能限制并发量,令牌桶的方式更加灵活,对于放置令牌和回收令牌,同样可以用信号量来实现,但是它不再限制获取信号量后必须归还,它用另一个线程定时往桶中放入令牌达到限流的目的。同时,令牌的消耗也可以随着令牌的使用率(使用中的令牌数/令牌总数)增大而增大,使得后面增速变慢。
Guava 的RateLimiter 提供了令牌桶的实现,包括平滑突发限流和平滑预热限流。
RFC 的 流量限制
这部分先不做学习了解,这里令牌桶可以分为两种,单色双色
分布式限流
如果从服务的性能角度去考虑,应用级限流是最好的方式,而分布式限流的场景其实更多的是在于业务上限流,比如双十一,京东618,或者是小米抢购,这种在最上层入口处做限流。分布式限流需要借助第三方来实现,考虑原子性和速率的问题,Redis 是一个不错的实现工具。Redis + Lua 是一种不错的方式,因为本身Lua脚本就可以对这几种限流方案做很好的实现。此外,也可以用Nginx + Lua。
计数器:
基于 Redis 去做,用 Redis 作为计数器,某个进程对Redis做清除。
漏桶:
这种方式也不难,一种较好的方式是,桶是放在本地的,这样减少了请求时把请求存放到Redis中的网络和存储开销。但是这样可能会存在些问题,理想情况下负载均衡,各个服务器的桶的情况应该相近,但是事实未必如此。限流的池放在Redis的set中,拿到set中的值后,就可以从自己的桶里取出来一个数,这种方式来做整体的限流。可以先从桶拿到请求,然后再从Redis拿到许可,这样避免了许可拿到后发现没有要处理的数据,再归还许可。
令牌桶:
实现仍然类似