常见的限流算法有哪些?各自的区别和使用场景

缓存、降级和限流是高并发系统中使用的保护系统的方式。

常用的限流方式和场景有:限制总并发数(比如数据库连接池、线程池)、限制瞬时并发数(如nginx的limitconn模块,用来限制瞬时并发连接数,Java的Semaphore也可以实现)、限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率);
其他还有如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。

比如说,我们需要限制方法被调用的并发数不能超过100(同一时间并发数),则我们可以用信号量 Semaphore实现。可如果我们要限制方法在一段时间内平均被调用次数不超过100,则需要使用 RateLimiter。

限流算法

1、计数器算法

计数器算法是限流算法里最简单也是最容易实现的一种算法。
比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter,具体算法的示意图如下:

常见的限流算法有哪些?各自的区别和使用场景_第1张图片

代码实现:

public class Counter {
    public long timeStamp = System.currentTimeMillis(); // 当前时间
    public int reqCount = 0; // 初始化计数器
    public final int limit = 100; // 时间窗口内最大请求数
    public final long interval = 1000 * 60; // 时间窗口ms


    public boolean limit() {
        long now = System.currentTimeMillis();
        if (now < timeStamp + interval) {
            // 在时间窗口内
            reqCount++;
            // 判断当前时间窗口内是否超过最大请求控制数
            return reqCount <= limit;
        } else {
            timeStamp = now;
            // 超时后重置
            reqCount = 1;
            return true;
        }
    }
}

存在的问题:
假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,那么其实这个用户在 1秒里面,瞬间发送了200个请求。我们刚才规定的是1分钟最多100个请求,也就是每秒钟最多1.7个请求,用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。

2. 滑动窗口算法

在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口 划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。

那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格 子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触发了限流。

我再来回顾一下刚才的计数器算法,我们可以发现,滑动时间窗口限流法其实就是计数器算法的一个变种,依然存在临界值的问题。只是计数器没有对时间窗口做进一步地划分,所以只有1格。由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

代码实现:

public class SlidingWindow {
    // 时间窗口大小,单位秒
    private static final int WINDOW_SIZE = 60;
    // 数组长度
    private static final int ARRAY_SIZE = 10;
    // 时间粒度 单位毫秒
    private static final int TIME_UNIT = WINDOW_SIZE * 1000 / ARRAY_SIZE;
    // 请求阈值
    private static final int LIMIT = 100;
    // 请求计数器
    private static final int[] WINDOW = new int[ARRAY_SIZE];

    public static boolean isAllowed() {
        synchronized (WINDOW) {
            // 获取当前时间的数组下标
            int index = (int) ((System.currentTimeMillis() / TIME_UNIT) % ARRAY_SIZE);
            // 如果当前时间已经在最后一个时间段内,则把数组左移一位
            if (index == 0) {
                System.arraycopy(WINDOW, 1, WINDOW, 0, ARRAY_SIZE - 1);
                WINDOW[ARRAY_SIZE - 1] = 0;
            }
            // 记录请求数量
            WINDOW[index]++;
            // 统计请求数量
            int count = 0;
            for (int i = 0; i < ARRAY_SIZE; i++) {
                count += WINDOW[i];
            }
            // 如果请求数量大于阈值,则拒绝请求
            if (count > LIMIT) {
                return false;
            }
            return true;
        }
    }
}

3. 令牌桶算法

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

可以支持突发流量。

常见的是Guava的RateLimiter实现。

4. 漏桶算法

请求先进入到漏桶里,漏桶以一定的速度放出请求,当请求速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。因为桶容量是不变的,保证了整体的速率。

漏桶算法的实现往往依赖于队列,请求到达如果队列未满则直接放入队列,然后有一个处理器按照固定频率从队列头取出请求进行处理。如果请求量大,则会导致队列满,那么新来的请求就会被抛弃。

存在的问题:
因为漏桶算法的流出速率是固定的,所以漏桶算法不支持出现突发流出流量。但是在实际情况下,流量往往是突发的。

你可能感兴趣的:(后端java)