【限流01】限流算法理论篇

微服务就是将复杂的大应用拆分成小的应用,这样做的好处是各个应用之间独立开发、测试、上线,互不影响。但是服务拆分之后,带来的问题也很多,我们需要保障微服务的正常运行,就需要进行服务治理。常用手段有:鉴权、限流、降级、熔断等。

其中,限流是指对某个接口的调用频率进行限制,防止接口调用频率过快导致线程资源被耗尽从而导致整个系统响应变慢。限流在很多业务中都有应用,比如:秒杀、双11。当用户请求量过大时,就会拒绝后续接口请求,保障系统的稳定性。

接口限流的实现思路是:统计某个时间段内的接口调用次数,当调用次数超过设置的阈值时,就进行限流限制接口访问。

常见的限流算法有:固定时间窗口算法、滑动时间窗口算法、令牌桶算法、漏桶算法等,下面我们将一一介绍每种算法的实现思路和代码实现。

一、固定时间窗口限流算法

1、算法概述

固定时间窗口限流算法的思路就是:确定一段时间段,在该时间段内统计接口的调用次数,来判断是否限流。

实现步骤如下:
选定一个时间起点,当接口请求到来时,

  • 接口访问次数小于阈值,可以访问,接口访问次数 + 1;
  • 接口访问次数大于阈值,拒绝该时间段内后续访问进行限流,接口访问次数不变;
  • 进入下一个时间窗口之后,计数器清零,时间起点设置为当前时间,这样就进入下一个时间窗口。

示意图如下:
在这里插入图片描述
​ (图片来源:https://time.geekbang.org/column/article/80388?utm_term=zeusNGLWQ&utm_source=xiangqingye&utm_medium=geektime&utm_campaign=end&utm_content=xiangqingyelink1104,下图同上)

这种限流算法的缺点是:无法应对两个时间窗口临界时间内的突发流量。

如下图:假设要求每秒钟接口请求次数不超过100,在第1s时间窗口内接口请求次数为100,但是都集中在最后10ms;第2s时间窗口内接口请求次数也为100,也都集中在最后10ms内;两个时间窗口请求次数都小于100,满足要求。但是在两个10ms内接口请求次数=200 > 100。如果这个次数不是200,是2000万,可能就会导致系统崩溃。
在这里插入图片描述

2、代码实现

public class FixedWindowRateLimitAlg implements RateLimitAlg {
    // ms
    private static final long LOCK_EXPIRE_TIME = 200L;

    private Stopwatch stopWatch;
    // 限流计数器
    private AtomicInteger counter = new AtomicInteger(0);
    private final int limit;
    private Lock lock = new ReentrantLock();

    public FixedWindowRateLimitAlg(int limit) {
        this(limit, Stopwatch.createStarted());
    }

    public FixedWindowRateLimitAlg(int limit, Stopwatch stopWatch) {
        this.limit = limit;
        this.stopWatch = stopWatch;
    }

    @Override
    public boolean tryAcquire() throws InterruptedException {
        int currentCount = counter.incrementAndGet();
        // 未达到限流
        if (currentCount < limit) {
            return true;
        }

        // 使用固定时间窗口统计当前窗口请求数
        // 请求到来时,加锁进行计数器统计工作
        try {
            if (lock.tryLock(LOCK_EXPIRE_TIME, TimeUnit.MILLISECONDS)) {
                // 如果超过这个时间窗口, 则计数器counter归零, stopWatch, 窗口进入下一个窗口
                if (stopWatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {
                    counter.set(0);
                    stopWatch.reset();
                }

                // 不超过, 则当前时间窗口内的计数器counter+1
                currentCount = counter.incrementAndGet();
                return currentCount < limit;
            }
        } catch (InterruptedException e) {
            System.out.println("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
            throw new InterruptedException("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
        } finally {
            lock.unlock();
        }

        // 出现异常 不能影响接口正常请求
        return true;
    }


}

二、滑动时间窗口限流算法

1、算法概述

固定时间窗口限流算法无法处理两个时间窗口临界值流量突增的情况。为了解决这个问题,我们可以稍微优化下固定时间窗口限流算法,通过限制任意时间窗口内(比如:1S)接口请求数都不超过某个阈值,这个优化后的算法就叫做滑动时间窗口限流算法。

滑动时间窗口限流算法将一个大的时间窗口分成粒度更小的时间窗口,每个子窗口独立统计次数。每经过一个子窗口的时间,整体窗口就向右滑动一格。

在这里插入图片描述
如上图所示,假设要求每分钟通过次数不超过100次,将1分钟分成6个10s的单元格。
第一个图中假设最后1个10s内(序号:6)通过请求次数为100次,第二个图中假设第1个10s (序号:7)内请求次数也通过100次。由于是滑动窗口,第一个窗口向右移动一格后,在第二个滑动窗口内,序号6、7两者加起来的请求次数为200>100,所以限流,从而解决了固定时间窗口无法处理两个窗口临界值的问题。

虽然滑动时间窗口算法可以保证任意时间窗口内接口请求次数不超过阈值,但是仍然无法避免更细粒度流量突增的场景,比如在某个10s内的单元格内流量突增无法立即被限流。同时,使用滑动窗口算法时,流量曲线如下,无法达到平滑过渡的效果,无法控制流量速度
在这里插入图片描述

2、算法实现

可以使用循环队列来实现滑动时间窗口限流算法:

假设限流规则是任意1s内,接口请求数不超过N次。

创建一个N+1 (循环队列本身会浪费一个存储单元,所以是N+1)的循环队列,用来记录1S内的请求。

当有新的请求到来时,

  • 将与该请求的时间间隔超过1s的请求从队列中移除(移动head指针);
  • 再看循环队列中是否有空闲位置,如果有,则把新请求存储在队列尾部(tail指针所在位置,同时移动tail指针);
  • 如果循环队列尾部没有空闲位置,说明这个1s内的请求次数已经超过限流次数N,拒绝后续服务。

算法实现的示意图如下:
在这里插入图片描述

假设1S内请求次数不能超过6次,整个队列分成(6+1)个单元格。

  • 18:060代表 18s 60ms的时候,第一个请求到来,此时队列为空,于是存储在第一个单元格内(即head指针指向的位置);
  • 同样,18:123、18:336、18:569、18:702、18:906分别为第2、3、4、5、6个请求,均在1s间隔内且队列都有空闲位置,于是依次存储到对应单元格内;
  • 当19:003请求到来时,没有与其超过1s间隔的请求,所以不需要移除其他请求;由于队列尾部没有空闲位置,说明1S内的请求次数已经超过6次,拒绝该请求访问;
  • 当19:406到来时,与其超过1S间隔的请求有18:060、18:123、18:336,该3个请求需要移除队列(逆时针移动head指针3个单元格),同时tail指针逆时针移动一格后,存放当前请求19:406;

3、伪代码实现

/**
 * @author: wanggenshen
 * @date: 2020/6/29 21:56.
 * @description: 循环队列实现滑动窗口限流算法
 */
public class SlidingWindowLimiter {

    private int windowSize;
    private CircularQueue queue;

    public SlidingWindowLimiter(int windowSize) {
        this.windowSize = windowSize;
        queue = new CircularQueue(windowSize);
    }

    public boolean tryAcquire(long now) {

        // 判断是否有间隔1s的请求, 有则移除队列
        while (queue.prevNode() != -1 && now - queue.prevNode() > 1000) {
            System.out.println("超过1S间隔, 移除超过间隔的节点: " + queue.prevNode() + "当前时间: " + now + ", 间隔: " + (now - queue.prevNode()));
            queue.dequeue();
        }

        // 队列已满, 拒绝访问
        if (queue.isFull()) {
            System.out.println("队列已满, now: " + now);
            return false;
        }

        queue.enqueue(now);
        return true;
    }

    static class CircularQueue {
        /**
         * 每次请求的时间戳
         */
        private long[] timeQueue;

        /**
         * 队列大小
         */
        private int size;

        /**
         * 头指针
         */
        private int headIndex;

        /**
         * 尾指针
         */
        private int tailIndex;


        public CircularQueue(int size) {
            // 循环队列尾部指针多占用一个单元格
            timeQueue = new long[size + 1];
            this.size = size + 1;
        }

        /**
         * 入队
         */
        public void enqueue (long timestamp) {

            // 队列已满
            if (isFull()) {
                throw new RuntimeException("Exceed queue size.");
            }

            timeQueue[tailIndex] = timestamp;
            tailIndex = (tailIndex + 1) % size;
        }

        /**
         * 出队
         *
         * @return
         */
        public long dequeue () {
            // 队列为空
            if (isEmpty()) {
                return -1;
            }

            long timestamp = timeQueue[headIndex];
            headIndex = (headIndex + 1) % size;
            return timestamp;
        }

        public long prevNode() {
            // 队列为空
            if (isEmpty()) {
                return -1;
            }
            return timeQueue[headIndex];
        }

        public boolean isFull() {
            return (tailIndex + 1) % size == headIndex;
        }

        public boolean isEmpty() {
            return tailIndex == headIndex;
        }
    }

}

三、漏桶算法

1、算法概述

实际上,当请求数超过阈值时,我们不希望后续流量被全部限流,而是希望将流量控制在一定速度内。
漏桶算法就是基于流控来控制流量。
在这里插入图片描述

如下图所示,调用方请求比作是水龙头出的水,水桶出的水是比作是接口提供方处理的请求。当水龙头出水速度大于桶里的水流出速度(类似接口调用请求频率过快),水直接溢出(类似请求直接被限流)。通过这种方法不仅能保证流量不会超过阈值,同时保证接口的请求数以稳定的速度去处理。

漏桶算法的优点在于能够控制接口提供方的接口被匀速处理;缺点在于设置的速率不当会影响接口处理的效率。

2、代码实现

伪代码如下:

/**
 * @author: wanggenshen
 * @date: 2020/6/29 21:00.
 * @description: 漏桶限流算法
 */
public class LeakyBucketLimiter {

    /**
     * 桶内剩余的水
     */
    private long left;

    /**
     * 桶的容量
     */
    private long capacity;

    /**
     * 一桶水漏完的时间
     */
    private long duration;

    /**
     * 桶漏水的速率, capacity = duration*velocity
     */
    private double velocity;

    /**
     * 上一次成功放入水桶的时间
     */
    private long lastUpdateTime;

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

        // 剩余的水量 - 桶匀速漏出去的水
        left = Math.max(0, left - (long)((now - lastUpdateTime) * velocity));

        // 当前水桶再加一单位水没有溢出, 则可以继续访问
        if (left++ <= capacity) {
            lastUpdateTime = now;
            return true;
        } else {
            return false;
        }
    }
}

四、令牌桶算法

1、算法概述

令牌桶算法的实现原理是:

以恒定速率生成令牌放进令牌桶,令牌桶满了的时候就丢弃不再放入令牌桶;

如果想要处理请求,就需要从令牌桶中取一个令牌。能取出令牌则去处理请求;没有令牌则拒绝请求。
在这里插入图片描述
令牌桶算法与漏桶算法很类似,最主要的区别在于:

  • 漏桶算法输入速率不定,但是输出速率恒定;令牌桶算法输出速率可以根据流量大小进行调整;

  • 从接口处理者的角度看,漏桶算法只能以固定频率去处理请求(比如每秒只能处理1个请求,如果此时来了10个请求,漏桶需要花10s处理完);而令牌桶算法可以处理突发流量,比如来了20个请求,如果令牌桶中有>=20个令牌,那么处理者就可以一下子全部处理这20个请求;

2、伪代码实现

/**
 * @author: wanggenshen
 * @date: 2020/6/29 21:00.
 * @description: 令牌桶限流算法
 */
public class TokenBucketLimiter {

    /**
     * 令牌桶桶内剩余的令牌
     */
    private long left;

    /**
     * 令牌桶的容量
     */
    private long capacity;

    /**
     * 一桶水漏完的时间
     */
    private long duration;

    /**
     * 令牌桶生产令牌的速率, capacity = duration*velocity
     */
    private double velocity;

    /**
     * 上一次拿走令牌的时间
     */
    private long lastUpdateTime;

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

        // 令牌桶余量 =  【上一次令牌桶剩余的令牌】+ 【(上一次拿走令牌到现在的时间段) * 每个单位时间生产令牌的速率 】
        // 生产出的令牌 超过令牌桶的容量时, 则舍弃
        left = Math.min(capacity, left + (long)((now - lastUpdateTime) * velocity));

        // 若当前能够成功领取令牌, 则可以访问
        if (left-- >= 0) {
            lastUpdateTime = now;
            return true;
        } else {
            return false;
        }
    }
}

生产环境下可以考虑使用Guava提供的令牌桶算法实现类: RateLimiter来进行限流,RateLimiter的实现是线程安全的。

五、分布式限流

生产环境下服务基本上分布式部署,那么在对服务进行限流时需要考虑到分布式限流。

最简单的做法是给每台应用服务器平均分配流控阈值,将分布式限流转换为单机限流。如总流量不超过1000次,那么5个服务实例,每个实例请求数不能超过200次。但是如果遇到流量不均匀(比如一台机器流量一直是10、另外几台> 200)、或者有一台宕机,那么另外几台平均下来就是250>200,这种做法不是很好。

常见的实现思路有两种:

  • 中心化:使用一个第三方服务统一存储所有服务实例的调用次数,由其去判断是否进行限流。这种方式需要注意第三方服务宕机导致不可用问题。这个时候可以退化成单机流控。
  • 去中心化:每个服务单独保存同一份流控数据,但是很难做到保持状态一致,即CAP中的C。

一般使用中心化这种思路。

1、TokenServer 流控

Sentinel提供了TokenServer,作为一个独立服务来统计总调用量、判断单个请求是否允许访问。应用服务器每次接收到请求后,都要与TokenServer进行一次通信,判断该次请求能否访问。
在这里插入图片描述
这种实现方式的好处是:由TokenServer集中管理每个服务实例的总调用量,服务实例不用关心请求的统计工作;

缺点是:非常依赖于TokenServer的性能,因为需要与其进行网络通信。同时需要关系TokenServer服务的单节点故障问题。

2、存储式流控

存储式流控是每个服务请求到来时,从第三方存储(如Redis、MySQL)读取接口请求数、然后再将请求数更新回缓存;

拿到请求数后由每个服务实例自己去判断是否需要限流。
在这里插入图片描述
总结

要设计一个高性能、高可靠性的分布式流控性能需要考虑网络通信、加锁同步等对性能带来的影响,同时也需要考虑分布式环境的可靠性。


参考:https://mp.weixin.qq.com/s/joP22Z8zblcDBAV1keSdJw

展开阅读全文

Git 实用技巧

11-24
这几年越来越多的开发团队使用了Git,掌握Git的使用已经越来越重要,已经是一个开发者必备的一项技能;但很多人在刚开始学习Git的时候会遇到很多疑问,比如之前使用过SVN的开发者想不通Git提交代码为什么需要先commit然后再去push,而不是一条命令一次性搞定; 更多的开发者对Git已经入门,不过在遇到一些代码冲突、需要恢复Git代码时候就不知所措,这个时候哪些对 Git掌握得比较好的少数人,就像团队中的神一样,在队友遇到 Git 相关的问题的时候用各种流利的操作来帮助队友于水火。 我去年刚加入新团队,发现一些同事对Git的常规操作没太大问题,但对Git的理解还是比较生疏,比如说分支和分支之间的关联关系、合并代码时候的冲突解决、提交代码前未拉取新代码导致冲突问题的处理等,我在协助处理这些问题的时候也记录各种问题的解决办法,希望整理后通过教程帮助到更多对Git操作进阶的开发者。 本期教程学习方法分为“掌握基础——稳步进阶——熟悉协作”三个层次。从掌握基础的 Git的推送和拉取开始,以案例进行演示,分析每一个步骤的操作方式和原理,从理解Git 工具的操作到学会代码存储结构、演示不同场景下Git遇到问题的不同处理方案。循序渐进让同学们掌握Git工具在团队协作中的整体协作流程。 在教程中会通过大量案例进行分析,案例会模拟在工作中遇到的问题,从最基础的代码提交和拉取、代码冲突解决、代码仓库的数据维护、Git服务端搭建等。为了让同学们容易理解,对Git简单易懂,文章中详细记录了详细的操作步骤,提供大量演示截图和解析。在教程的最后部分,会从提升团队整体效率的角度对Git工具进行讲解,包括规范操作、Gitlab的搭建、钩子事件的应用等。 为了让同学们可以利用碎片化时间来灵活学习,在教程文章中大程度降低了上下文的依赖,让大家可以在工作之余进行学习与实战,并同时掌握里面涉及的Git不常见操作的相关知识,理解Git工具在工作遇到的问题解决思路和方法,相信一定会对大家的前端技能进阶大有帮助。
©️2020 CSDN 皮肤主题: 游动-白 设计师: 上身试试 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值