四种限流算法以及限流实践

175 阅读9分钟

1.固定时间窗口限流算法

在固定时间内进行限流,比如下午1点到下午2点只允许用户进行操作十次,那如果超过这十次就会拒绝用户进行访问。

问题:可能会出现流量突刺,如果用户正好卡在1点59分59秒进行十次访问,在2点00分00秒又进行了十次访问,那用户就在极短的时间内进行了多次访问,产生了流量突刺。

简单进行实现固定窗口限流。

统计限流次数使用常量即可。

import org.junit.jupiter.api.Test;

import java.time.LocalDateTime;
import java.time.ZoneOffset;

public class FixedTimeWindows {

public static long recordTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000;

/**
 * 获取限流的时间戳 毫秒 十秒内只能访问五次
 */
public static final long LIMIT_TIME = LocalDateTime.now().plusSeconds(10).toEpochSecond(ZoneOffset.UTC) * 1000 - recordTime;

/**
 * 定义一小时能可以进行调用的次数
 */
public static final int LIMIT_TIMES = 5;

/**
 * 存储用户调用的次数
 */
public static int userTimes = 0;

/**
 * 实现固定窗口限流算法
 */
public static boolean fixedTimeWindowLimit() {
    // 获取当前时间
    long now = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000;

    if (now - recordTime > LIMIT_TIME) {
        // 更新时间限制
        System.out.println("更新时间限制");
        userTimes = 0;
        recordTime = now;
    } else {
        if (userTimes >= LIMIT_TIMES) {
            return false;
        }
    }
    userTimes++;
    return true;
}

public static void getNiuma() {
    System.out.println("牛马");
}

@Test
void testGetNiuma() throws InterruptedException {

    for (int i = 0; i <= 20; i++) {
        Thread.sleep(1 * 1000);
        boolean result = fixedTimeWindowLimit();
        if (!result) {
            System.out.println("超出调用限制");
        } else {
            getNiuma();
        }
    }
}

}

2.滑动时间窗口限流算法

    设定一个单位时间:一小时内只能有十次访问,在黄色期间的一小时只能进行访问十次,当时间发生变化向后推迟了十分中,在紫色范围能只能访问十次。

image.png

这种算法巧妙的避免了流量突刺的情况,即使你分两段对我进行多次访问,只要这两段访问时间在我的单位时间单元内,那我就可以使用流量阻塞,不让你进行访问。

优点:可以解决上述流量突刺的问题。

缺点:实现相对复杂,限流效果和你的滑动单位有关,滑动单位越小,限流效果越好,但是往往很难进行选择一个特别合适的滑动单位。而且会直接暴力拒绝请求,比较影响用户的体验性。

简单实现滑动窗口限流。

统计限流次数使用数组进行滑动时间处理。

 import org.junit.jupiter.api.Test;

mport java.time.LocalDateTime;
import java.time.ZoneOffset;
 import java.util.Arrays;

 public class SlideTimeWindow {

/**
 * 定义初始化记录的事件
 */
public static long recordTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000;

/**
 * 获取限流的时间戳 毫秒 十秒内内只能访问五次
 */
public static final long LIMIT_TIME = LocalDateTime.now().plusSeconds(10).toEpochSecond(ZoneOffset.UTC) * 1000 - recordTime;

/**
 * 分割为十个时间单位
 */
public static final int UNITS_OF_TIME = 10;

/**
 * 定义一小时能可以进行调用的次数
 */
public static final int LIMIT_TIMES = 5;

/**
 * 存储用户调用的次数
 */
public static int[] userTimeArr = new int[UNITS_OF_TIME];

/**
 * 数组求和
 */
public static int arrSum() {
    return Arrays.stream(userTimeArr).sum();
}

/**
 * 根据时间进行数组清零
 * @param number 时间
 */
public static void arrCleanZero(int number) {
    for (int i = 0; i <= number; i++) {
        userTimeArr[i] = 0;
    }
}

/**
 * 定义滑动窗框限流
 */
public static boolean slideTimeWindowLimit() {
    // 获取当前时间
    long now = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000;

    // 1. 当前时间 - 上次调用记录时间 > 10秒 清零 -> 允许调用
    if (now - recordTime >= LIMIT_TIME) {
        userTimeArr = new int[UNITS_OF_TIME];
    }

    // 2. 当前时间 - 上次调用记录时间 < 10秒
    if (now - recordTime < LIMIT_TIME) {
        // 2.1 数组求和 > 限制次数 滚蛋
        if (arrSum() >= LIMIT_TIMES) {
            // 一定要更新记录时间
            recordTime = now;
            return false;
        }
        // 2.2 数组求和 < 限制次数 允许调用
    }
    // 3. 调用规整

    // 3.0 给哪个时间加这个次数
    int timeNumber = (int) Math.floor((double) (now - recordTime) / 1000);
    // 处理过界情况
    if (timeNumber > 10) {
        timeNumber -= 10;
    }
    
    // 3.1 统一进行加1
    userTimeArr[timeNumber] += 1;
    // 3.2 更新记录时间
    recordTime = now;

    return true;
}

public static void getNiuma() {
    System.out.println("牛马");
}

@Test
void testGetNiuma() throws InterruptedException {
    boolean result;

    for (int i = 0; i <= 10; i++) {
        Thread.sleep(500);
        result = slideTimeWindowLimit();
        if (!result) {
            System.out.println("超出调用限制");
        } else {
            getNiuma();
        }
    }

    Thread.sleep(5000);

    for (int i = 0; i <= 10; i++) {
        Thread.sleep(500);
        result = slideTimeWindowLimit();
        if (!result) {
            System.out.println("超出调用限制");
        } else {
            getNiuma();
        }
    }

    Thread.sleep(10000);

    for (int i = 0; i <= 10; i++) {
        Thread.sleep(500);
        result = slideTimeWindowLimit();
        if (!result) {
            System.out.println("超出调用限制");
        } else {
            getNiuma();
        }
    }
}

3.漏桶限流算法
漏桶算法就是定义一个桶,进行存储请求流量,但是出桶进行处理请求的时候,是根据固定速率出来请求进行处理。

桶有一定的容量,如果请求存储到漏桶中,超出了容量,就会进行拒绝请求。

image.png

优点:在一定程度上可以应对流量突刺,能够固定速率处理请求,保证服务器的安全。

缺点:无法迅速处理一批请求,只能一个一个按顺序来处理(固定速率的缺点),当系统出现流量高峰的时候,还这样处理速度不变,对用户的体验会非常不好。

简单实现漏桶限流算法。

 import java.util.ArrayList;
import java.util.List;

public class LeakyBuckets {

/**
 * 定义固定速率
 */
public static final int SPEED = 1;

/**
 * 定义桶容量
 */
public static final int CAPACITY = 10;

/**
 * 定义桶
 */
public static List<Integer> bucketArr = new ArrayList<>();

/**
 * 定义漏桶限流
 * false 执行限流 true 不执行限流
 * @return
 */
public static boolean leakyBucketsLimit() {
    // 1. 将请求加入到漏桶中
    // 判断桶是否满了
    if (bucketArr.size() >= CAPACITY) {
        // 执行限流
        System.out.println("============== 桶满啦 ==============");
        return false;
    }
    // 实际过程中可以进行加入请求标识等
    bucketArr.add(1);

    // 2. 以固定流速进行处理请求
    // 这里只进行实现单线程的 实际请求中固定速率高的时候应该开多线程
    return true;
}

public static void getNiuma() {
    System.out.println("牛马");
}

4.令牌桶限流算法
就是管理员先进行生成一批令牌,每秒生成10个令牌,当用户要进行操作前,先去拿到一个令牌,有令牌的人才有资格进行操作,能同时进行操作,拿不到令牌的就需要进行等着。

实现令牌桶算法。

 import java.util.concurrent.Callable;

public class MyCallable implements Callable<String> {

private String name;

public MyCallable(String name) {
    this.name = name;
}

@Override
public String call() throws Exception {
    return TokenBucket.getNiuma(name);
}
}
import org.junit.jupiter.api.Test;

 import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class TokenBucket {

/**
 * 定义固定速率
 */
public static final int SPEED = 1;

/**
 * 定义令牌前缀
 */
public static final String TOKEN_FRONT = "token";

/**
 * 定义令牌桶
 */
public static List<String> tokenBucket = new ArrayList<>();

/**
 * 生成令牌
 * @param key 关键key
 */
public static String productToken(String key) {
    String token = TOKEN_FRONT + key;
    tokenBucket.add(token);
    return token;
}


/**
 * 初始化令牌
 * @param number 初始化令牌数量
 */
public static void initToken(int number) {
    for (int i = 0; i < number; i++) {
        productToken("niuma" + i);
    }
}

/**
 * 校验令牌
 * @param token 令牌
 * @return false 限流 true 成功
 */
public static boolean tokenBucketLimit(String token) {
    // 校验无令牌
    if (token == null || token.isEmpty() || token.equals(" ") || !tokenBucket.contains(token)) {
        return false;
    }

    return true;
}

public static String getNiuma(String name) {
    return "线程名: " + name + ", 牛马。";
}

@Test
void testGetNiuma() throws ExecutionException, InterruptedException {
    // 初始化令牌
    initToken(3);

    // 初始化多线程
    MyCallable callable1 = new MyCallable("线程1");
    MyCallable callable2 = new MyCallable("线程2");
    FutureTask<String> futureTask1 = new FutureTask<>(callable1);
    FutureTask<String> futureTask2 = new FutureTask<>(callable2);

    // 校验令牌
    if (tokenBucketLimit(TOKEN_FRONT + "niuma1")) {
        new Thread(futureTask1).start();
        new Thread(futureTask2).start();
        System.out.println(futureTask1.get());
        System.out.println(futureTask2.get());
    }

    if (tokenBucketLimit("duaisbdiasu")) {
        new Thread(futureTask2).start();
        System.out.println(futureTask2.get());
    }
}

}

    优点:能够并发处理同时的请求,并发性能会更高,因为在低流量的时候可以进行积攒令牌,高流量的时候,积攒了足够的令牌可以应对高并发的请求,解决了漏桶算法的问题。

    需要考虑的问题:还是存在时间单位选取的问题。如果流量突刺时令牌数量超过了系统可以进行接收的流量最大范围,那服务器就挂了,如果太少就会拒绝请求,导致用户体验过差。

5.限流粒度
1.针对某个方法进行限流,即单位时间内最多允许同时XX个操作使用这个办法。

2.针对某个用户进行限流,比如单个用户单位时间内最多执行XX次操作。

3.针对某个用户X方法进行限流,比如单个用户时间内最多执行XX次这个方法。

**6.四种限流算法的思考 **
对于针对用户限流最大的问题就是,如果此时DDOS攻击者调动大量肉鸡,对我们的系统进行进攻,我们的限流如果仅仅针对用户的调用次数进行限流,并采用对用户进行限流的算法,那么我们的系统会被大量肉鸡产生的流量突刺,会占用大量服务器资源,直接突破我们的系统。

所以我们就是不仅要对用户在操作接口时进行限流操作,更要在对每个接口进行限流。

对于每个用户的限流就是为了防止DDOS攻击者操控一个用户大量进行请求,占用我们的资源,使得其他用户无法进行张昌访问。

对于接口(全部用户)进行限流是为了防止接口遭遇流量突刺,因为如果我们仅仅对一个用户进行限流,DDOS攻击者,操控多个用户对我们发起突刺,也会沾满我们的系统,所以我们要对整个系统的接口进行限流。

而且我们可以进行监控用户的操作,如果用户的操作,并非正常行为(比如持续高输出)那我们可以立刻对其进行封锁,限制用户行为,监控的话,我们可以使用AOP(单机)或者网关(分布式)。

7.本地限流实现(单机限流)
每个服务器进行单独限流,一般适用于单体项目,就是你得项目只有一个服务器。

使用Guava RateLimiter实现单机限流。

底层使用的是令牌桶算法。

8.分布式限流实现(多机限流)
如果你的项目有多个服务器,比如微服务,那么建议使用分布式限流。

1.把用户的使用频率等数据放到一个集中的存储中进行统计,比如Redis,这样无论用户的请求落到了哪台服务器,都可以以集中的数据存储内的数据为准(Redisson)。

2.在网关中集中进行限流和统计(比如Sentinel,Spring Cloud Gateway)。

redisson底层进行限流的时候使用的也是令牌桶算法进行限流。

9.使用redisson实现限流

9.1Redisson限流实现
Redission内置了一个限流工具类,可以帮助你利用Redis来存储,来统计。

9.2Redisson的实现

9.2.1本地/远程安装Redis
Redission依赖于Redis所以需要进行安装Redis进行使用。

9.2.2引入Redisson的依赖

1738668714841.png 9.2.3实现Redisson的配置

1738668777552.png 9.2.4实现限流操作

1738668827930.png 9.2.5进行单元测试

1738668867074.png 10.结语 没想到自己居然听了一天白羊,麻了.... ————————————————