限流原理和代码实现

介绍

面对高并发的三大利器:限流、降级和缓存。这里就来谈谈限流。

为什么需要限流?当服务接口的QPS或者并发量超过了接口的承受能力时,可能会导致接口处理变慢,更为严重的,会导致应用崩溃。因此,需要对接口进行限流保护,不单单保护接口自身的应用,也保护接口所依赖的第三方资源。

限流的本质是对突发流量进行整形,从而实现保护系统的目的。

分类

按照限流类型来说,可分为并发数限流和QPS限流。

并发数限流:限制同一时刻的最大并发请求数量;

QPS限流:限制一段时间内的的请求数量,也就是说在一定的时间窗口内进行限制。如固定窗口限流、滑动窗口限流、漏桶和令牌桶。

按照作用的范围来说,可分为单机限流和分布式限流。

单机限流:针对单台机器进行限流;

分布式限流:针对集群进行限流,需要通过公共服务或者中间件(redis)来存储数据;

原理和代码实现

并发数限流

定义:限制同一时刻的并发数。缺点:只能在一个时间点对并发数进行限制,无法做到在时间段内的限流。这里分别采用原子类和Semaphore实现。

  • 原子类实现的核心代码如下:

    public class AtomicCountRateLimiter extends AbstractCountRateLimiter{
    
        /**
         * 允许的最大并发数
         */
        private Long maxPermits;
    
        /**
         * 计数器
         */
        private AtomicLong count = new AtomicLong();
    
        public AtomicCountRateLimiter(long maxPermits) {
            this.maxPermits = maxPermits;
        }
    
        /**
         * 获取许可
         * @return true:正常,不会被限流
         */
        @Override
        public boolean tryAcquire() {
            if(count.incrementAndGet() > maxPermits) {
                return false;
            } else {
                return true;
            }
        }
    
        /**
         * 释放许可
         */
        @Override
        public void release() {
            count.decrementAndGet();
        }
    }
    
  • Semaphore实现的核心代码如下:

    public class SemaphoreCountRateLimiter extends AbstractCountRateLimiter{
    
        /**
         * 允许的最大并发数
         */
        private Integer maxPermits;
    
        private Semaphore semaphore;
    
        public SemaphoreCountRateLimiter(int maxPermits) {
            this.maxPermits = maxPermits;
            semaphore = new Semaphore(maxPermits);
        }
    
    
        @Override
        public boolean tryAcquire() {
            return semaphore.tryAcquire();
        }
    
        @Override
        public void release() {
            semaphore.release();
        }
    }
    

固定时间窗口限流

并发数限流存在 无法针对时间段内的请求数(如QPS) 进行限制的缺点,因此可以通过固定窗口完成时间段内的限流。

  • 代码实现如下:

    维护原子变量reqCount表示当前请求数,时间戳lastVisitedTime表示上一次请求的时间。当来了新的请求后,判断当前时间戳和lastVisitedTime是否相差大于1s,是则重置reqCount并更新lastVisitedTime为当前的时间戳,否则直接进行请求数+1;

    public class WindowsRateLimiter implements RateLimiter{
    
        private AtomicLong reqCount;
    
        private Long maxPermits;
    
        private long lastVisitedTime = System.currentTimeMillis();
    
        public WindowsRateLimiter(Long maxPermits) {
            this.maxPermits = maxPermits;
            reqCount = new AtomicLong();
        }
    
        /**
         * 是否获得许可, 即不会被限流
         * @return true 通过
         */
        @Override
        public synchronized boolean tryAcquire() {
            long now = System.currentTimeMillis();
            if(now - lastVisitedTime > 1000) {
                reqCount.getAndSet(0);
                lastVisitedTime = now;
            }
            reqCount.incrementAndGet();
            return reqCount.longValue() <= maxPermits;
        }
    }
    
  • 缺点:固定窗口无法解决相邻时间的边界问题。比如限制最大的QPS为100,第一秒的后500ms来了100个请求,第二秒的前500ms来了100个请求,那么由第一秒的后500ms和第二秒的前500ms组成的1s内,最大的QPS已经为200了,但是没有进行限制。基于这个问题,引出了滑动时间窗口算法。

滑动时间窗口限流

在这里插入图片描述

  • 基本思想:将1s进行拆分为10个时间段组成,每个时间段是100ms。当收到一个请求时,会在对应的时间段内对请求进行累加。维护一个定时任务,每隔100ms移动当前窗口的位置,并从总的计数中减去窗口中的计数。

  • 解决了相邻时间的边界问题,因为并不是直接把当前请求数直接清0,而是淘汰掉最早的100ms内的计数,最终始终维护了1s内的时间窗口的计数。

  • 问题

    • 需要为每个窗口维护一个定时任务,浪费资源
    • 窗口划分的问题,如果划分的太粗,依然会有边界问题,划分太细,定时任务需要按照毫秒或者微妙执行,资源消耗高,同时精度也越高
  • 核心代码实现如下:

    public class SlidingWindowRateLimiter implements RateLimiter {
    
        /**
         * 允许的最大请求数
         */
        private Long maxPermits;
    
        /**
         * 滑动窗口数组
         */
        private AtomicLong[] slidingWindows;
    
        /**
         * 窗口大小
         */
        private int windowSize;
    
        /**
         * 当前的请求数量
         */
        private AtomicLong reqCount = new AtomicLong();
    
        /**
         * 当前处于滑动窗口的位置
         */
        private volatile int index;
    
        private ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(1);
    
        public SlidingWindowRateLimiter(long maxPermits, int windowSize) {
            this.maxPermits = maxPermits;
            this.windowSize = windowSize;
            slidingWindows = new AtomicLong[windowSize];
            for (int i = 0; i < windowSize; i++) {
                slidingWindows[i] = new AtomicLong();
            }
            index = 0;
            init();
        }
    
        /**
         * 获得许可
         *
         * @return true: 正常通过, false: 被限流
         */
        @Override
        public boolean tryAcquire() {
            slidingWindows[index].incrementAndGet();
            reqCount.incrementAndGet();
            return !isOverLimit();
        }
    
        /**
         * 是否被限流
         *
         * @return true:被限流
         */
        private boolean isOverLimit() {
            return reqCount.get() > maxPermits;
        }
    
        /**
         * 定时移动窗口位置, reqCount并不是直接清零, 所以不会有边界问题
         */
        private void init() {
            if(1000 % windowSize != 0) {
                throw new IllegalArgumentException("windowSize必须能够被1000整除");
            }
            long period = 1000 / windowSize;
            scheduledExecutor.scheduleAtFixedRate(new Runnable() {
                @Override
                public void run() {
                    index = (index + 1) % windowSize;
                    long val = slidingWindows[index].getAndSet(0);
                    reqCount.addAndGet(-val);
                }
            }, 0, period, TimeUnit.MILLISECONDS);
        }
    }
    

漏桶

待续

令牌桶

待续

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章