菜鸟笔记
提升您的技术认知

几种限流算法

阅读 : 70

前言

在流量突增的场景下,为了保证后端服务在整体上一个稳定性,我们需要对请求进行限流,来避免系统崩溃。

不过限流会对少部分用户的请求直接进行拒绝或者延迟处理,影响这些用户的体验。

本文会介绍一些常见的限流算法,并在最后附上对分布式限流的一些思考。

计数器法

计数器算法,也成固定窗口法。可以控制在固定的时间窗口内,允许通过的最大的请求数。

例如,我们设定时间间隔窗口intervalWindow为1分钟,该窗口内的最大请求数max为100。

当第1个请求到来时,我们记录下当前窗口内的第一个请求的时间firstReqTime,那么之后的请求到来时,先判断是否进入下一个窗口。

如果进入,则直接重置firstReqTime为当前时间,该请求通过。如果没进入,再判断是否超过max。

demo如下(并没有考虑线程安全):

/**
 * @author qcy
 * @create 2021/12/04 18:20:30
 */
public class CountLimiter {
    /**
     * 时间间隔窗口(单位:毫秒)
     */
    private long intervalWindow;
    /**
     * 该窗口内的最大请求数
     */
    private int max;
    /**
     * 当前窗口内的请求计数
     */
    private int count;
    /**
     * 当前窗口内的第一个请求的时间
     */
    private long firstReqTime = System.currentTimeMillis();

    public CountLimiter(int intervalWindow, int max) {
        this.intervalWindow = intervalWindow;
        this.max = max;
    }

    //省略get与set方法

    public boolean limit() {
        long now = System.currentTimeMillis();

        if (now > firstReqTime + intervalWindow) {
            //代表已经进入下一个窗口
            firstReqTime = now;
            count = 1;
            return true;
        }

        //还在当前的时间窗口内
        if (count + 1 <= max) {
            count++;
            return true;
        }

        return false;
    }
    
}

计数器法,非常简单粗暴,以上demo只是单机式限流。

如果需要进行分布式限流,可以使用Redis。以接口名称作为key,max作为value,intervalWindow作为key过期时间。

当请求过来时,如果key不存在,则代表已经进入下一个窗口,value赋值为max-1,并允许请求通过。

如果key存在,则再判断value是否大于0。大于0则允许请求通过,否则进行限流。

使用Redis进行分布式限流,需要注意保证代码的原子性,可以直接使用lua脚本。

计数器法的缺点

该算法无法应对突发的流量,因为计数器法是固定窗口的。

例如第一个请求10:00:00到来,那么第一个时间窗口为10:00:00-10:01:00。之后在10:00:59时,突然来了99个请求,又在下一个时间窗口的10:01:01来了100个请求。

也就是说,在10:00:59-10:01:01的短短几秒内,共有199个请求到来,可能会瞬间压垮我们的应用。

滑动窗口法

滑动窗口法可以解决计数器在固定窗口法下无法应对突发流量的问题

固定窗口法是以第一个请求为窗口开始期,并向后截取intervalWindow长度,只有当窗口时间流逝完,才开辟新的窗口。

滑动窗口法以每一个请求为窗口结束期,向前截取intervalWindow长度,检查该范围内的请求总和,相当于会为每个请求开辟一个新窗口。

既然要知道前intervalWindow长度内到底有多少个请求,那么就要为每个放行的请求记录发生时间。

demo如下:

public class SlidingWindowLimiter {

    /**
     * 时间间隔窗口(单位:毫秒)
     */
    private long intervalWindow;
    /**
     * 窗口内的最大请求数
     */
    private int max;
    /**
     * 限流容器
     * 队列尾部保存最新通过的请求时间
     */
    private LinkedList<Long> list = new LinkedList<>();

    public SlidingWindowLimiter(int intervalWindow, int max) {
        this.intervalWindow = intervalWindow;
        this.max = max;
    }

    //省略get与set方法


    public boolean limit() {
        long now = System.currentTimeMillis();

        //队列未满,说明当前窗口还可以接收请求
        if (list.size() < max) {
            list.addLast(now);
            return true;
        }

        //队列已满
        Long first = list.getFirst();
        if (now - first <= intervalWindow) {
            //说明新请求和队列中的请求还处于一个窗口内,触发了限流
            return false;
        }

        //说明新请求和队列中的请求不在通过窗口内
        list.removeFirst();
        list.addLast(now);
        return true;
    }
}

当然,也可以使用Redis的List或Zset实现吗,大致步骤和以上demo类似。

这里多说一句,限流中的滑动窗口法和TCP的滑动窗口其实很像。滑动窗口法是去主动限流,而TCP的滑动窗口则是接收方为了告诉发送方自己还能接受多少数据,是对发送方的“限流”。

滑动窗口法的缺点

在滑动窗口法中,因为要倒推窗口的开始期,所以需要记录每个请求的执行时间,会额外占用一些内存。

此外,在算法中会频繁地removeFirst与addLast,在选择错误的数据结构下(例如数组),可能会造成很大的移动开销。

漏桶法

水龙头可以通过松紧来控制出水的速率,下方有一个储蓄桶来保存当前的水。储蓄通底部有一个出口,内部的水会以恒定的速率从出口漏掉。

如果储蓄桶满了之后,再进来的水会全部溢出。只有当出水速率和漏水速率相同时,储蓄桶才会在不漏水的前提下达到最大的吞吐量。

我们把水比作请求,水龙头就是客户端。请求产生的速率肯定不是恒定的,但处理请求的速率是恒定的。当储蓄桶满了之后,请求产生的速率必须要和处理请求的速率一致。

demo如下:

public class LeakyBucketLimiter {
    /**
     * 上次请求到来的时间
     */
    private long preTime = System.currentTimeMillis();
    /**
     * 漏水速率,n/s
     */
    private int leakRate;
    /**
     * 储蓄桶容量
     */
    private int capacity;
    /**
     * 当前水量
     */
    private int water;

    public LeakyBucketLimiter(int leakRate, int capacity) {
        this.leakRate = leakRate;
        this.capacity = capacity;
    }

    //省略get与set方法

    public boolean limit() {
        long now = System.currentTimeMillis();

        //先漏水,计算剩余水量
        water = Math.max(0, water - (int) ((now - preTime) / 1000) * leakRate);
        preTime = now;

        //桶未满
        if (water + 1 <= capacity) {
            water++;
            return true;
        }

        return false;
    }
}

仔细一想,储蓄桶能够把不定速率的请求转化为恒定速率的请求,和消息队列一样,具有削峰填谷的作用。

其实整套装置和ScheduledThreadPoolExecutor线程池更像,将储蓄桶想象为具有延时特性的阻塞队列,超出队列容量的请求,将直接执行拒绝策略。

如果储蓄桶的容量为Integer.MAX_VALUE,流速为10/s,则可通过以下的代码来模拟漏桶:

        //最大任务数为Integer.MAX_VALUE,即储蓄桶容量
        ScheduledExecutorService pool = Executors.newScheduledThreadPool(30);
        //每隔0.1秒处理1个请求,即流速为10/s
        pool.scheduleAtFixedRate(() -> System.out.println("处理请求"), 0, 100, TimeUnit.MILLISECONDS);

漏桶法的缺点

使用漏桶法去做限流,在业务平稳期其实已经够用了。但是在业务高峰期,我们又希望动态地去调整处理请求的速率,而不是一成不变的速率。

我们大可以动态地去改变参数leakRate的值,不过在计算剩余水量的时候,将会十分复杂。

因此,如果要考虑到对突发流量的控制,就不太推荐漏桶法了。

令牌桶法

首先有一个令牌桶,然后系统以一个恒定的速率向桶中放入令牌。当桶满时,会丢弃生成的令牌。

每有一个请求过来时,拿到令牌就可以执行,否则阻塞获取或者被直接丢弃。

一个简要的demo如下:

public class TokenBucketLimiter {
    /**
     * 上次请求到来的时间
     */
    private long preTime = System.currentTimeMillis();
    /**
     * 放入令牌速率,n/s
     */
    private int putRate;
    /**
     * 令牌桶容量
     */
    private int capacity;
    /**
     * 当前令牌数
     */
    private int bucket;

    public TokenBucketLimiter(int putRate, int capacity) {
        this.putRate = putRate;
        this.capacity = capacity;
    }

    //省略get与set方法

    public boolean limit() {
        long now = System.currentTimeMillis();

        //先放入令牌,再获取令牌
        bucket = Math.min(capacity, bucket + (int) ((now - preTime) / 1000) * putRate);
        preTime = now;

        if (bucket == 0) {
            return false;
        }

        bucket--;
        return true;
    }
}

看的出来,令牌桶和漏桶的原理有些相似。

漏桶是以一个恒定速率的出水,即处理请求的速率是恒定的。而令牌桶则是以一个恒定的速率往桶中放入令牌,在桶中令牌用完之前,并不限制处理请求的速率。

令牌桶的一个优势在于,可以允许短时间内的一次突发流量。但不会允许在短时间内的多次突发流量,因为令牌的填充也是需要时间的。

Guava中的RateLimiter

google的工具包Guava中的RateLimiter就是对令牌桶的实现,其包含了两种限流模式,位置处于SmoothRateLimiter的两个静态内部类中:

  • SmoothBursty,稳定模式,令牌生成的速率是恒定的,为默认模式。
  • SmoothWarmingUp,预热模式,逐渐提升令牌的生成速率到一固定值。

其中acquire方法支持阻塞式获取,tryAcquire支持获取不到就返回或者在指定时间内阻塞获取。

关于RateLimiter源码分析,后面应该会另起篇幅介绍。

分布式限流

以上的RateLimiter属于单机式限流,如果要进行分布式限流该怎么处理呢?

无非是将控制请求的阈值从单机中挪到统一的中间件上,例如Redis。

对于计数器法

如果要限制一天中对某个接口的调用次数,则可以使用接口的名称作为key,value作为预设的阈值,过期时间为24小时。请求到来时利用原子指令判断key是否存在,不存在则设置该key;存在则减1,再判断是否大于0。

对于滑动窗口法

单机中我们使用list,在分布式系统中,则可以使用Redis的有序集合zset,key为某个接口名称,value为处理请求的时间戳。请求到来时,先使用removeRangeByScore移除上一个时间窗口内的记录,接着使用size获取集合长度,若大于阈值,则进行限流。

对于漏桶法

阿里巴巴的开源分布式限流系统Sentienel,支持漏桶与令牌桶算法。

个人觉得非常有必要去了解一下Sentienel的整体架构,可以看这篇文章入门阿里巴巴开源限流系统 Sentinel 全解析

对于令牌桶法

可以在每个应用中起一个延时的线程池,定时生产令牌到Redis中,这种方案在水平扩展时可以同比例的扩大限流阈值,但性能不高。

当然也可以利用lua脚本,在lua脚本中直接将生产令牌与获取令牌的操作合在一起,即和上文的demo一样,先放入令牌再获取令牌。之后将脚本放在代码中,每个应用先判断Redis中是否存在该脚本,若不存在再加载该脚本,后续获取令牌时直接执行该脚本即可。