限流算法及接口实现

目录

一、简介

二、作用

三、限流算法

3.1、固定窗口算法(计数器)

3.1.1、简介

3.1.2、原理

3.1.3、适用场景

3.1.4、代码实现

4.1.5、优劣分析

3.2、滑动窗口算法

3.2.1、简介

3.2.2、原理

3.2.3、适用场景

3.2.4、代码实现

3.2.5、优劣分析

3.3、漏桶算法

3.3.1、简介

3.3.2、特点

3.3.3、原理

3.3.4、适用场景

3.3.5、代码实现

3.3.6、优劣分析

3.4、令牌桶算法

3.4.1、简介

3.4.2、原理

3.4.3、适用场景

3.4.4、代码实现

3.4.5、优劣分析

3.5、滑动日志算法(比较冷门)

3.5.1、简介

3.5.2、原理

3.5.3、适用场景

3.5.4、代码实现

3.5.5、优劣分析

四、总结


一、简介

限流是一种通过控制请求的速率或数量来保护系统免受过载的技术。流控的精髓是限制单位时间内的请求量,最大程度保障系统的可靠性及可用性。

二、作用

限流是在高并发环境下,为了保护系统的稳定性和可用性而引入的一种策略。通过限制并发请求的数量或频率,可以防止系统被过多的请求压垮或耗尽资源。

三、限流算法

常见的流控算法包括:固定窗口、滑动窗口、漏桶、令牌桶、滑动日志等算法。

3.1、固定窗口算法(计数器)

3.1.1、简介

固定窗口限流算法(Fixed Window Rate Limiting Algorithm)是一种最简单的限流算法,其原理是在固定时间窗口(单位时间)内限制请求的数量。

3.1.2、原理

固定窗口是最简单的流控算法。即,给定时间窗口,维护一个计数器用于统计访问次数,并实现以下规则:
1.如果访问次数小于阈值,则允许访问,访问次数+1;
2.如果访问次数超出阈值,则限制访问,访问次数不增;
3.如果超过了时间窗口,计数器清零,并重置清零后的首次成功访问时间为当前时间。

3.1.3、适用场景

  • 保护后端服务免受大流量冲击,避免服务崩溃;
  • 对 API 调用进行限制,保证公平使用;
  • 防止恶意用户对服务进行洪水攻击;

3.1.4、代码实现

/**
 * @ClassName: FixedWindowRateLimiter
 * @projectName: cat
 * @description: 限流算法:固定窗口算法。
 * @author: yangwenxue
 * @date: 2024/1/25 15:40
 * @Version: 1.0
 */
public class FixedWindowRateLimiter {

    private static int counter = 0;  // 统计请求数
    private static long lastAcquireTime = 0L;
    private static final long windowUnit = 1000L; // 假设固定时间窗口是1000ms
    private static final int threshold = 10; // 窗口阀值是10

    /**
     * 获取令牌
     *
     * @return
     */
    public synchronized boolean tryAcquire() {
        long currentTime = System.currentTimeMillis();  // 获取系统当前时间
        if (currentTime - lastAcquireTime > windowUnit) {  // 检查是否在时间窗口内
            counter = 0;  // 计数器清零
            lastAcquireTime = currentTime;  // 开启新的时间窗口
        }
        if (counter < threshold) {  // 小于阀值
            counter++;  // 计数器加1
            return true;  // 获取请求成功
        }
        return false;  // 超过阀值,无法获取请求
    }

    public static void main(String[] args) {
        for (int i = 0; i < 50; i++) {
            boolean acquire = new FixedWindowRateLimiter().tryAcquire();
            System.out.println("获取令牌:" + acquire);
            try {
                Thread.sleep(30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4.1.5、优劣分析

优点

  • 固定窗口算法非常简单,易于实现和理解。
  • 性能高

缺点

  • 存在明显的临界问题。比如: 假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s内的,则并发数高达10,已经超过单位时间1s不超过5阀值的定义了。

3.2、滑动窗口算法

3.2.1、简介

为了解决临界突变问题,可以引入滑动窗口。即:把大的时间窗口拆分成若干粒度更细的子窗口,每个子窗口独立统计,按子窗口时间滑动,统一限流。
当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

3.2.2、原理

将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。它可以解决固定窗口临界值的问题。

3.2.3、适用场景

同固定窗口的场景,且对流量限制要求较高的场景,需要更好地应对突发流量。

3.2.4、代码实现


import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @ClassName: SlidingWindowLimiter
 * @projectName: cat
 * @description: 限流算法:滑动窗口算法:实现方式一
 * @author: yangwenxue
 * @date: 2023/10/16 15:23
 * @Version: 1.0
 */
public class SlidingWindowLimiter {
    /**
     * 每个窗口的最大请求数量
     */
    public static long threshold = 10;
    /**
     * 窗口大小,1000ms
     */
    public static long windowUnit = 1000;
    /**
     * 请求集合,用来存储窗口内的请求数量
     */
    public static List requestList = new ArrayList<>();

    /**
     * 限流方法,返回true表示通过
     */
    public boolean limit() {
        System.out.println("requestList=" + requestList.size());
        // 获取系统当前时间
        long currentTime = System.currentTimeMillis();
        // 统计当前窗口内,有效的请求数量
        int sizeOfValid = this.sizeOfValid(currentTime);
        // 判断是否超过最大请求数量
        if (sizeOfValid < threshold) {
            // 把当前请求添加到请求集合里
            requestList.add(currentTime);
            return true;
        }
        return false;
    }

    /**
     * 统计当前窗口内,有效的请求数量
     */
    private int sizeOfValid(long currentTime) {
        int sizeOfValid = 0;
        for (Long requestTime : requestList) {
            // 判断是否在当前时间窗口内
            if (currentTime - requestTime <= windowUnit) {
                sizeOfValid++;
            }
        }
        return sizeOfValid;
    }

    /**
     * 清理过期的请求(单独启动一个线程处理)
     */
    private void clean() {
        // 判断是否超出当前时间窗口内
        requestList.removeIf(requestTime -> System.currentTimeMillis() - requestTime > windowUnit);
    }

    // 测试
    public static void main(String[] args) {
        SlidingWindowLimiter slidingWindowLimiter = new SlidingWindowLimiter();

        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                boolean limit = slidingWindowLimiter.limit();
                System.out.println(Thread.currentThread().getName() + "==>" + limit);
            });
        }
        executorService.shutdown();
    }
}

3.2.5、优劣分析

优势

  • 简单易懂
  • 精度高(通过调整时间窗口的大小来实现不同的限流效果)
  • 可扩展性强(可以非常容易地与其他限流算法结合使用)

劣质

  • 突发流量无法处理(无法应对短时间内的大量请求,但是一旦到达限流后,请求都会直接暴力被拒绝。这样我们会损失一部分请求,这其实对于产品来说,并不太友好),需要合理调整时间窗口大小。

3.3、漏桶算法

3.3.1、简介

基于(出口)流速来做流控。在网络通信中常用于流量整形,可以很好地解决平滑度问题。

3.3.2、特点

  • 可以以任意速率流入水滴到漏桶(流入请求)
  • 漏桶具有固定容量,出水速率是固定常量(流出请求)
  • 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)

3.3.3、原理

  • 思想

将数据包看作是水滴,漏桶看作是一个固定容量的水桶,数据包像水滴一样从桶的顶部流入桶中,并通过桶底的一个小孔以一定的速度流出,从而限制了数据包的流量

  • 工作原理

对于每个到来的数据包,都将其加入到漏桶中,并检查漏桶中当前的水量是否超过了漏桶的容量。如果超过了容量,就将多余的数据包丢弃。如果漏桶中还有水,就以一定的速率从桶底输出数据包,保证输出的速率不超过预设的速率,从而达到限流的目的。

3.3.4、适用场景

一般用于保护第三方的系统,比如自身的系统需要调用第三方的接口,为了保护第三方的系统不被自身的调用打垮,便可以通过漏斗算法进行限流,保证自身的流量平稳的打到第三方的接口上。

3.3.5、代码实现

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @ClassName: LeakyBucketLimiter
 * @projectName: cat
 * @description: 限流算法:漏桶算法
 * @author: yangwenxue
 * @date: 2023/10/16 10:48
 * @Version: 1.0
 */
public class LeakyBucketLimiter {

    /**
     * 水的流出速率(每秒允许的次数)
     */
    private double rate;

    /**
     * 桶的大小
     */
    private double burst;

    /**
     * 最后更新时间
     */
    private long refreshTime;

    /**
     * 漏桶当前水量
     */
    private int water;

    /**
     * @param rate  水的流出速率
     * @param burst 桶的大小
     */
    public LeakyBucketLimiter(double rate, double burst) {
        this.rate = rate;
        this.burst = burst;
    }

    /**
     * 刷新桶的水量
     */
    public void refreshWate() {
        long now = System.currentTimeMillis();
        water = (int) Math.max(0, water - (now - refreshTime) / 1000 * rate);
        refreshTime = now;
        System.out.println("当前桶余量:" + (burst - water));
    }

    /**
     * 获取令牌
     *
     * @return
     */
    public synchronized boolean tryAcquire() {
        refreshWate();
        if (water < burst) {
            water++;
            return true;
        } else {
            return false;
        }
    }

    // 测试:用一个线程池模拟多个请求,看看是否到达限流的效果
    private static LeakyBucketLimiter leakBucket = new LeakyBucketLimiter(80, 200);

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 1000; i++) {
            executorService.execute(() -> {
                System.out.println(Thread.currentThread().getName() + ":" + leakBucket.tryAcquire());
            });
//            try {
//                Thread.sleep(10);
//            } catch (InterruptedException e) {
//                e.printStackTrace();
//            }
        }

        executorService.shutdown();
    }

}

3.3.6、优劣分析

优势

  • 可以平滑限制请求的处理速度,避免瞬间请求过多导致系统崩溃或者雪崩。
  • 可以控制请求的处理速度,使得系统可以适应不同的流量需求,避免过载或者过度闲置。
  • 可以通过调整桶的大小和漏出速率来满足不同的限流需求,可以灵活地适应不同的场景。

劣质

  • 需要对请求进行缓存,会增加服务器的内存消耗。
  • 对于流量波动比较大的场景,需要较为灵活的参数配置才能达到较好的效果。
  • 但是面对突发流量的时候,漏桶算法还是循规蹈矩地处理请求,这不是我们想看到的啦。流量变突发时,我们肯定希望系统尽量快点处理请求,提升用户体验嘛。

3.4、令牌桶算法

3.4.1、简介

基于(入口)流速来做流控的一种限流算法。

3.4.2、原理

该算法维护一个固定容量的令牌桶,每秒钟会向令牌桶中放入一定数量的令牌。当有请求到来时,如果令牌桶中有足够的令牌,则请求被允许通过并从令牌桶中消耗一个令牌,否则请求被拒绝。

3.4.3、适用场景

一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。

3.4.4、代码实现

import java.math.BigDecimal;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * @ClassName: TokenLimiter
 * @projectName: cat
 * @description: 限流算法:令牌桶算法(推荐)
 * @author: yangwenxue
 * @date: 2023/9/26 15:05
 * @Version: 1.0
 */
public class TokenLimiter {

    /**
     * 令牌
     */
    public static final String TOKEN = "lp";
    /**
     * 阻塞队列,用于存放令牌
     */
    private ArrayBlockingQueue blockingQueue;

    /**
     * 令牌桶容量
     */
    private int limit;

    /**
     * 生产令牌的间隔时间,单位:毫秒
     */
    private int period;

    /**
     * 每次生产令牌的个数
     */
    private int amount;

    public TokenLimiter(int limit, int period, int amount) {
        this.limit = limit;
        this.period = period;
        this.amount = amount;
        blockingQueue = new ArrayBlockingQueue<>(limit);
        init();
        start();
    }

    /**
     * 创建初始化令牌
     */
    private void init() {
        for (int i = 0; i < limit; i++) {
            blockingQueue.add(TOKEN);
        }
    }

    /**
     * 添加令牌
     */
    private void addToken() {
        for (int i = 0; i < limit; i++) {
            // 溢出则返回false
            blockingQueue.offer(TOKEN);
        }
    }

    /**
     * 获取令牌,如果令牌桶为空则返回false
     *
     * @return
     */
    public synchronized boolean tryAcquire() {
        int size = blockingQueue.size();
        System.out.println("token剩余数量====>" + size);
        BigDecimal total = new BigDecimal(limit);
        BigDecimal use = new BigDecimal(size);
        BigDecimal ss = use.divide(total, 2, BigDecimal.ROUND_HALF_UP);
        System.out.println("使用率:"+ ss.multiply(new BigDecimal(100)) + "%");
        //队首元素出队
        return blockingQueue.poll() == null ? false : true;
    }

    /**
     * 生产令牌
     */
    public void start() {
        Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
                    addToken();
                }, 500, //第一次执行任务延迟时间
                this.period, //连续执行任务的周期,也就是间隔时间
                TimeUnit.MILLISECONDS
        );
    }

    // 测试
    public static void main(String[] args) throws InterruptedException {
        int period = 500;
        //先生产3个令牌,减少4个令牌,再每500ms生产3个令牌
        TokenLimiter tokenLimiter = new TokenLimiter(3, period, 3);

        for (int i = 0; i < 4; i++) {
            new Thread(() -> {
                while (true) {
                    String name = "线程[" + Thread.currentThread().getName() + "]";
                    if (tokenLimiter.tryAcquire()) {
                        System.out.println(name + "拿到令牌");
                    } else {
                        System.out.println(name + "没有拿到令牌");
                    }

                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }


    }
}

3.4.5、优劣分析

优势

  • 稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定。
  • 精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流。
  • 弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量。

劣质

  • 实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法。
  • 时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想。

3.5、滑动日志算法(比较冷门)

3.5.1、简介

滑动日志限速算法需要记录请求的时间戳,通常使用有序集合来存储,我们可以在单个有序集合中跟踪用户在一个时间段内所有的请求。

3.5.2、原理

滑动日志算法可以用于实现限流功能,即控制系统在单位时间内处理请求的数量,以保护系统免受过载的影响。以下是滑动日志算法用于限流的原理:

  1. 划分时间窗口:将时间划分为固定的时间窗口,例如每秒、每分钟或每小时等。
  2. 维护滑动窗口:使用一个滑动窗口来记录每个时间窗口内的请求次数。这个滑动窗口可以是一个固定长度的队列或数组。
  3. 请求计数:当一个请求到达时,将其计数加一并放入当前时间窗口中。
  4. 滑动:随着时间的流逝,滑动窗口会根据当前时间窗口的长度,移除最旧的请求计数,并将新的请求计数添加到最新的时间窗口中。
  5. 限流判断:在每个时间窗口结束时,统计滑动窗口中的请求计数总和,并与预设的阈值进行比较。如果总请求数超过阈值,则触发限流处理。
  6. 限流处理:一旦触发限流,可以采取不同的处理策略,如拒绝请求、延迟处理、返回错误信息等。具体的限流策略可以根据实际情况进行选择。

通过滑动日志算法进行限流,可以实现对单位时间内的请求进行精确控制。它基于实时统计的方式,能够动态地适应请求流量的变化,并且在内存使用上比较高效。同时,通过调整时间窗口的长度和阈值的设置,可以灵活地控制限流的精度和灵敏度。

3.5.3、适用场景

对实时性要求高,且需要精确控制请求速率的高级限流场景。

3.5.4、代码实现

import java.util.LinkedList;
import java.util.List;

/**
 * @ClassName: SlidingLogRateLimiter
 * @projectName: cat
 * @description: 限流算法:滑动日志算法(比较冷门)
 * @author: yangwenxue
 * @date: 2024/1/25 16:35
 * @Version: 1.0
 */
public class SlidingLogRateLimiter {
    private int requests; // 请求总数
    private List timestamps; // 存储请求的时间戳列表
    private long windowDuration; // 窗口持续时间,单位:毫秒
    private int threshold; // 窗口内的请求数阀值

    /**
     * 构造函数
     *
     * @param threshold      窗口内的请求数阀值
     * @param windowDuration 窗口持续时间,单位:毫秒
     */
    public SlidingLogRateLimiter(int threshold, long windowDuration) {
        this.requests = 0;
        this.timestamps = new LinkedList<>();
        this.windowDuration = windowDuration;
        this.threshold = threshold;
    }

    public synchronized boolean tryAcquire() {
        long currentTime = System.currentTimeMillis(); // 获取当前时间戳

        // 删除超过窗口持续时间的时间戳
        while (!timestamps.isEmpty() && currentTime - timestamps.get(0) > windowDuration) {
            timestamps.remove(0);
            requests--;
        }

        if (requests < threshold) { // 判断当前窗口内请求数是否小于阀值
            timestamps.add(currentTime); // 将当前时间戳添加到列表
            requests++; // 请求总数增加
            System.out.println("当前请求总量:" + requests);
            return true; // 获取请求成功
        }

        return false; // 超过阀值,无法获取请求
    }

    /**
     * 测试
     * @param args
     */
    public static void main(String[] args) {
        SlidingLogRateLimiter slidingLogRateLimiter = new SlidingLogRateLimiter(10, 1000);
        for (int i = 0; i < 200; i++) {
            boolean acquire = slidingLogRateLimiter.tryAcquire();
            System.out.println(acquire);
            try {
                Thread.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

3.5.5、优劣分析

优势

  • 滑动日志能够避免突发流量,实现较为精准的限流;
  • 更加灵活,能够支持更加复杂的限流策略 如多级限流,每分钟不超过100次,每小时不超过300次,每天不超过1000次,我们只需要保存最近24小时所有的请求日志即可实现。

劣质

  • 占用存储空间要高于其他限流算法。

四、总结

以上就是常见的五种限流的原理、特点、算法、适用场景介绍,其中比较推荐令牌桶算法。除了上述实现,还有常用工具Guava的RateLimiter(单机),以及阿里巴巴的限流和熔断降级组件sentinel(单机或者分布式),这里不做过多赘述。

你可能感兴趣的:(java,算法)