本文转载自微信公众号:占小狼的博客
保障服务稳定的三大利器:熔断降级、服务限流和故障模拟。今天和大家谈谈限流算法的几种实现方式,本文所说的限流并非是 Nginx 层面的限流,而是业务代码中的逻辑限流。
为什么需要限流
按照服务的调用方,可以分为以下几种类型服务
1.与用户打交道的服务
比如 web 服务、对外 API,这种类型的服务有以下几种可能导致机器被拖垮:
-
用户增长过快(这是好事)
-
因为某个热点事件(微博热搜)
-
竞争对象爬虫
-
恶意的刷单
这些情况都是无法预知的,不知道什么时候会有 10 倍甚至 20 倍的流量打进来,如果真碰上这种情况,扩容是根本来不及的(弹性扩容都是虚谈,一秒钟你给我扩一下试试)
2、对内的 RPC 服务
一个服务 A 的接口可能被 BCDE 多个服务进行调用,在 B 服务发生突发流量时,直接把 A 服务给调用挂了,导致 A 服务对 CDE 也无法提供服务。这种情况时有发生,解决方案有两种:
-
1、每个调用方采用线程池进行资源隔离
-
2、使用限流手段对每个调用方进行限流
限流算法实现
常见的限流算法有:计数器、令牌桶、漏桶。
1、计数器算法
采用计数器实现限流有点简单粗暴,一般我们会限制一秒钟的能够通过的请求数,比如限流 qps 为 100,算法的实现思路就是从第一个请求进来开始计时,在接下去的 1s 内,每来一个请求,就把计数加 1,如果累加的数字达到了 100,那么后续的请求就会被全部拒绝。等到 1s 结束后,把计数恢复成 0,重新开始计数。
具体的实现可以是这样的:对于每次服务调用,可以通过 AtomicLong#incrementAndGet()
方法来给计数器加 1 并返回最新值,通过这个最新值和阈值进行比较。
这种实现方式,相信大家都知道有一个弊端:如果我在单位时间 1s 内的前 10ms,已经通过了 100 个请求,那后面的 990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象”。
2、漏桶算法
为了消除 "突刺现象",可以采用漏桶算法实现限流,漏桶算法这个名字就很形象,算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。
不管服务调用方多么不稳定,通过漏桶算法进行限流,每 10 毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池(ScheduledExecutorService
)来定期从队列中获取请求并执行,可以一次性获取多个并发执行。
这种算法,在使用过后也存在弊端:无法应对短时间的突发流量。
3、令牌桶算法
从某种意义上讲,令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。
放令牌这个动作是持续不断的进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置 qps 为 100,那么限流器初始化完成一秒后,桶中就已经有 100 个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的 100 个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。
实现思路:可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
通过 Google 开源的 guava 包,我们可以很轻松的创建一个令牌桶算法的限流器。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
通过 RateLimiter 类的 create 方法,创建限流器。
public class RateLimiterMain {
public static void main(String[] args) {
RateLimiter rateLimiter = RateLimiter.create(10);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
rateLimiter.acquire()System.out.println("pass");
}
}).start();
}
}
}
其实 Guava 提供了多种 create 方法,方便创建适合各种需求的限流器。在上述例子中,创建了一个每秒生成 10 个令牌的限流器,即 100ms 生成一个,并最多保存 10 个令牌,多余的会被丢弃。
rateLimiter 提供了 acquire()和 tryAcquire() 接口:
-
1、使用 acquire() 方法,如果没有可用令牌,会一直阻塞直到有足够的令牌。
-
2、使用 tryAcquire() 方法,如果没有可用令牌,就直接返回 false。
-
3、使用 tryAcquire() 带超时时间的方法,如果没有可用令牌,就会判断在超时时间内是否可以等到令牌,如果不能,就返回 false,如果可以,就阻塞等待。
集群限流
前面讨论的几种算法都属于单机限流的范畴,但是业务需求五花八门,简单的单机限流,根本无法满足他们。
比如为了限制某个资源被每个用户或者商户的访问次数,5s 只能访问 2 次,或者一天只能调用 1000 次,这种需求,单机限流是无法实现的,这时就需要通过集群限流进行实现。
如何实现?
为了控制访问次数,肯定需要一个计数器,而且这个计数器只能保存在第三方服务,比如 redis。
大概思路:每次有相关操作的时候,就向 redis 服务器发送一个 incr 命令,比如需要限制某个用户访问 /index 接口的次数,只需要拼接用户 id 和接口名生成 redis 的 key,每次该用户访问此接口时,只需要对这个 key 执行 incr 命令,在这个 key 带上过期时间,就可以实现指定时间的访问频率。