限流之限流算法

111 阅读10分钟

固定窗口限流算法

  • 当次数少于限流阀值,就允许访问,并且计数器+1
  • 当次数大于限流阀值,就拒绝访问。
  • 当前的时间窗口过去之后,计数器清零。

假设单位时间是1秒,限流阀值为3。在单位时间1秒内,每来一个请求,计数器就加1,如果计数器累加的次数超过限流阀值3,后续的请求全部拒绝。等到1s结束后,计数器清0,重新开始计数。如下图:

image.png

伪代码如下:

import java.util.concurrent.atomic.AtomicLong;

public class FixedWindowRateLimiter {
    private final int capacity;  // 时间窗口内允许通过的请求数
    private final long interval; // 时间窗口的时长(毫秒)
    private final AtomicLong counter; // 当前时间窗口内的请求数
    private final AtomicLong startTime; // 当前时间窗口的起始时间

    public FixedWindowRateLimiter(int capacity, long interval) {
        this.capacity = capacity;
        this.interval = interval;
        this.counter = new AtomicLong(0);
        this.startTime = new AtomicLong(System.currentTimeMillis());
    }

    public synchronized boolean allowRequest() {
        long now = System.currentTimeMillis();
        if (now - startTime.get() > interval) {
            // 重置时间窗口
            counter.set(0);
            startTime.set(now);
        }

        if (counter.incrementAndGet() <= capacity) {
            return true;
        }

        return false;
    }

    public static void main(String[] args) {
        FixedWindowRateLimiter rateLimiter = new FixedWindowRateLimiter(500, 1000);

        for (int i = 0; i < 1000; i++) {
            if (rateLimiter.allowRequest()) {
                System.out.println("Request " + i + " allowed");
            } else {
                System.out.println("Request " + i + " blocked");
            }
        }
    }
}

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

image.png

固定窗口限流算法的缺陷:

  1. 突发请求问题: 固定窗口算法在时间窗口结束时(如每秒末尾)可能会遇到突发的请求,因为它允许在新的时间窗口开始前的瞬间处理大量请求。这可能会导致系统负载波动。
  2. 时间不对称性: 算法将时间窗口分成固定间隔,导致在某些时间段内请求速率的控制较宽松,而在某些时间段内则较严格。
  3. 竞争条件: 在多线程或分布式环境下,需要确保对计数器的原子性操作以避免竞争条件。

固定窗口限流算法通常适用于一些简单的场景,但不适用于需要更高精度和更均匀流量的场合。在高要求的情况下,可能需要考虑其他限流算法,例如滑动窗口、令牌桶、或漏桶算法。

滑动窗口限流算法(解决了固定窗口限流的逻辑漏洞)

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

一张图解释滑动窗口算法,如下:

image.png

假设单位时间还是1s,滑动窗口算法把它划分为5个小周期,也就是滑动窗口(单位时间)被划分为5个小格子。每格表示0.2s。每过0.2s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是0.83s到达的,0.8~1.0s对应的计数器就会加1。

我们来看下滑动窗口是如何解决临界问题的?

假设我们1s内的限流阀值还是5个请求,0.81.0s内(比如0.9s的时候)来了5个请求,落在黄色格子里。时间过了1.0s这个点之后,又来5个请求,落在紫色格子里。如果是固定窗口算法,是不会被限流的,但是滑动窗口的话,每过一个小周期,它会右移一个小格。过了1.0s这个点后,会右移一小格,当前的单位时间段是0.21.2s,这个区域的请求已经超过限定的5了,已触发限流啦,实际上,紫色格子的请求都被拒绝啦。

TIPS:  当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

滑动窗口算法伪代码实现如下:

import java.util.concurrent.atomic.AtomicLong;

public class SlidingWindowRateLimiter {
    private final int capacity;  // 滑动窗口容量
    private final long interval; // 滑动窗口的时间间隔(毫秒)
    private final AtomicLong[] windows; // 滑动窗口数组
    private final AtomicLong timestamp; // 当前时间戳

    public SlidingWindowRateLimiter(int capacity, long interval) {
        this.capacity = capacity;
        this.interval = interval;
        this.windows = new AtomicLong[capacity];
        for (int i = 0; i < capacity; i++) {
            windows[i] = new AtomicLong(0);
        }
        this.timestamp = new AtomicLong(System.currentTimeMillis());
    }

    public boolean allowRequest() {
        long now = System.currentTimeMillis();
        long lastTimestamp = timestamp.getAndSet(now);
        if (now - lastTimestamp > interval) {
            // 重置窗口
            for (int i = 0; i < capacity; i++) {
                windows[i].set(0);
            }
        }

        long sum = 0;
        for (int i = 0; i < capacity; i++) {
            sum += windows[i].get();
        }

        if (sum < capacity) {
            windows[(int) (now % capacity)].getAndIncrement();
            return true;
        }

        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        SlidingWindowRateLimiter rateLimiter = new SlidingWindowRateLimiter(500, 1000);

        for (int i = 0; i < 1000; i++) {
            if (rateLimiter.allowRequest()) {
                System.out.println("Request " + i + " allowed");
            } else {
                System.out.println("Request " + i + " blocked");
            }
            Thread.sleep(10); // 模拟请求间隔
        }
    }
}

滑动窗口限流算法的缺陷:

  1. 精度问题: 滑动窗口算法在限制精度上有限制。由于分割时间窗口和检查请求速率需要一定的计算时间,可能会导致算法对突发流量的响应不够灵敏。
  2. 内存开销: 随着窗口容量的增加,滑动窗口算法的内存开销会线性增加,这可能在容量非常大时导致高内存消耗。
  3. 滑动窗口平滑度: 滑动窗口限流算法可能不够平滑,因为它是基于时间窗口的。当时间窗口滑动时,会产生不均匀的流量控制。

滑动窗口限流算法通常用于一些场景中,但在某些高精度和高性能要求的场合,可能需要考虑其他限流算法,例如令牌桶算法或漏桶算法。

令牌桶算法

image.png

算法思想:

  • 令牌以固定速率产生,并缓存到令牌桶中;
  • 令牌桶放满时,多余的令牌被丢失;
  • 请求要消耗等比例的令牌才能被处理;
  • 令牌不够时,请求被缓存。

代码实现:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class TokenBucketRateLimiter {
    private final int capacity;  // 令牌桶容量
    private final AtomicLong tokens; // 当前令牌数量
    private final long refillInterval; // 令牌补充间隔(纳秒)
    private final long maxTokens; // 令牌桶最大容量

    public TokenBucketRateLimiter(int qps, int capacity) {
        this.capacity = capacity;
        this.tokens = new AtomicLong(capacity);
        this.refillInterval = TimeUnit.SECONDS.toNanos(1) / qps;
        this.maxTokens = capacity;
        startRefillThread();
    }

    private void startRefillThread() {
        Thread refillThread = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(TimeUnit.NANOSECONDS.toMillis(refillInterval));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                refillTokens();
            }
        });
        refillThread.setDaemon(true);
        refillThread.start();
    }

    private void refillTokens() {
        long currentTokens = tokens.get();
        long newTokens = Math.min(currentTokens + capacity, maxTokens);
        tokens.compareAndSet(currentTokens, newTokens);
    }

    public boolean allowRequest() {
        long currentTokens = tokens.get();
        if (currentTokens > 0) {
            if (tokens.compareAndSet(currentTokens, currentTokens - 1)) {
                return true;
            }
        }
        return false;
    }

    public static void main(String[] args) {
        TokenBucketRateLimiter rateLimiter = new TokenBucketRateLimiter(500, 500);

        for (int i = 0; i < 1000; i++) {
            if (rateLimiter.allowRequest()) {
                System.out.println("Request " + i + " allowed");
            } else {
                System.out.println("Request " + i + " blocked");
            }
        }
    }
}

令牌桶限流算法的优点:

  1. 平滑流量:令牌桶算法可以平滑控制请求的到达速率,避免了突发流量。
  2. 精确控制:通过调整令牌产生速率,可以非常精确地控制 QPS。
  3. 可扩展性:令牌桶算法适用于多个实例之间的限流,无需全局状态。

令牌桶限流算法的缺陷:

  1. 算法本身较为复杂,需要定期生成令牌的线程,可能会引入一些复杂性和额外的计算开销。

上述示例代码提供了一个基本的令牌桶限流算法实现,但在生产环境中,通常需要更多的细节处理,如线程安全性和容错性。如果需要更高性能和可靠性,可以考虑使用现有的限流库或服务。

漏桶算法

image.png

算法思想是:

  • 漏桶有一个固定的容量(水桶容量),表示漏桶能够存储的最大请求数或令牌数。
  • 请求(或令牌)以一定速率被放入漏桶,就像水滴被倒入水桶。
  • 漏桶以恒定的速率从底部流出(出水速率),表示系统能够处理的请求速率。
  • 如果漏桶内的请求数量超过了漏桶的容量,多余的请求将被丢弃或拒绝。
  • 如果漏桶为空,新的请求必须等待,直到有足够的令牌可用,以确保请求以恒定速率被处理。

代码实现:

import java.util.concurrent.atomic.AtomicLong;

public class LeakyBucketRateLimiter {
    private final int capacity;         // 漏桶容量
    private final AtomicLong waterLevel; // 当前水位
    private final long rate;            // 恒定的出水速率(毫秒/令牌)
    private final long lastTimestamp;   // 上次漏水的时间戳

    public LeakyBucketRateLimiter(int qps, int capacity) {
        this.capacity = capacity;
        this.waterLevel = new AtomicLong(0);
        this.rate = 1000 / qps;          // 计算出水速率(每隔多少毫秒放出一个令牌)
        this.lastTimestamp = System.currentTimeMillis(); // 初始化上次漏水时间为当前时间
    }

    public synchronized boolean allowRequest() {
        long currentTimestamp = System.currentTimeMillis();  // 获取当前时间戳
        long elapsedTime = currentTimestamp - lastTimestamp; // 计算自上次漏水以来的时间间隔
        lastTimestamp = currentTimestamp; // 更新上次漏水时间

        long leakedTokens = (long) (elapsedTime / rate); // 计算在时间间隔内漏出的令牌数量
        waterLevel.set(Math.max(0, waterLevel.get() - leakedTokens)); // 漏水后更新当前水位

        if (waterLevel.get() < capacity) { // 如果当前水位小于容量,表示桶内有空闲令牌
            waterLevel.incrementAndGet(); // 放入一个令牌
            return true; // 允许请求通过
        }

        return false; // 如果水位已满,拒绝请求
    }

    public static void main(String[] args) {
        LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(500, 500); // 创建漏桶限流器,QPS为500,容量为500

        for (int i = 0; i < 1000; i++) { // 模拟1000次请求
            if (rateLimiter.allowRequest()) {
                System.out.println("Request " + i + " allowed"); // 允许请求通过
            } else {
                System.out.println("Request " + i + " blocked"); // 请求被拒绝
            }
        }
    }
}

漏桶限流算法的缺陷:

  1. 固定速率: 漏桶算法以固定的速率处理请求,这可能导致系统在处理突发流量时表现不佳。
  2. 不平滑: 漏桶算法不平滑流量,因为它在每个时间间隔内处理的请求数量是固定的。
  3. 令牌浪费: 如果没有请求到达,漏桶会浪费已生成的令牌,因为令牌不会累积。

漏桶限流算法通常适用于需要以恒定速率处理请求的场景,但不适用于需要处理突发流量或对请求速率有更高精度要求的场合。在实际应用中,可能需要结合其他算法来满足不同场景的需求。

现成的限流算法库

  1. Guava RateLimiter: Google Guava库中提供了一个RateLimiter类,它可以用于基于令牌桶算法的限流。
  2. Netflix Zuul and Hystrix: Netflix开发的Zuul和Hystrix是用于构建微服务架构中的网关和断路器的库。它们可以用于限流和熔断等用途。
  3. Envoy: Envoy是一个开源的云原生代理,它内置了许多高级限流功能,包括基于令牌桶和漏桶的限流。
  4. NATS Streaming Server: NATS Streaming Server是一个高性能的消息系统,它具有发布/订阅、消息队列等功能,可以用于流量控制。
  5. Kong: Kong是一个开源的API网关和微服务管理平台,它支持各种插件,包括限流插件,可以用于实现复杂的限流策略。
  6. Cloud Native Computing Foundation (CNCF): CNCF维护了一些开源项目,如Envoy、Istio、Linkerd等,它们提供了强大的流量控制和限流功能。
  7. 自定义限流服务: 如果需要更高度定制的限流策略,您可以考虑开发自己的限流服务,这可以根据您的具体需求进行定制。

一般来说,我们只需要了解各种限流算法/限流算法库的优缺点,继而选择符合我们业务需求的,不必要自己造轮子。