常见限流算法及其实现

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 在分布式系统中,随着业务量的增长,如何保护核心资源、防止系统过载、保证系统的稳定性成为了一个重要的问题。限流算法作为一种有效的流量控制手段,被广泛应用于各类系统中。本文将详细介绍四种常见的限流算法、两种常用的限流器工具,从原理、源码的角度进行分析。

一、背景

在分布式系统中,随着业务量的增长,如何保护核心资源、防止系统过载、保证系统的稳定性成为了一个重要的问题。限流算法作为一种有效的流量控制手段,被广泛应用于各类系统中。本文将详细介绍四种常见的限流算法、两种常用的限流器工具,从原理、源码的角度进行分析。

二、常见算法

1. 基于计数的限流算法

1.1 原理

这种算法的基本思想是通过维护一个计数器,在特定的时间窗口内累计接收到的请求次数,当请求次数达到预设的阈值时,后续的请求会被限流或直接拒绝。

工作原理:

  1. 在一个固定的时间窗口(如1分钟)内,系统初始化一个计数器count为0。
  2. 每当一个新的请求到达时,计数器增加1。
  3. 当计数器的值超过了预先设定的限流阈值时,后续的请求会被限制。
  4. 时间窗口结束后(即过了1分钟),不管当前计数器的数值如何,都会重置为0,下一个时间窗口开始重新计数。

image-20240227112248154.png

1.2 代码实现

public interface RateLimiter {
   
   
    boolean acquire();
}
public class CounterBasedRateLimiter implements RateLimiter {
   
   
    /**
     * 窗口时长,毫秒级
     */
    private final long windowSize;
    /**
     * 限流大小
     */
    private final int limit;
    /**
     * 窗口开始时间,毫秒级
     */
    private long windowStartTime;
    /**
     * 计数器
     */
    private final AtomicInteger counter;public CounterBasedRateLimiter(long windowSize, int limit) {
   
   
        this.windowSize = windowSize;
        this.limit = limit;
        this.counter = new AtomicInteger();
        reset();
    }private synchronized void reset() {
   
   
        if (checkIfWindowValid()) {
   
   
            return;
        }
        windowStartTime = System.currentTimeMillis();
        counter.set(0);
    }private boolean checkIfWindowValid() {
   
   
        return System.currentTimeMillis() - windowStartTime <= windowSize;
    }@Override
    public boolean acquire() {
   
   
        // 检查窗口是否有效,若已无效则重置
        if (!checkIfWindowValid()) {
   
   
            reset();
        }
        return counter.incrementAndGet() <= limit;
    }
}

1.3 优缺点

1.3.1 优点

  • 实现简单:它是最简单的限流算法之一,只需要维护一个计数器变量,每来一个请求就进行计数操作,无需复杂的逻辑设计
  • 直观易懂:设置明确的阈值,比如规定每秒允许100个请求,易于理解和配置
  • 实时性好:当请求到达时能够迅速做出是否允许的决策,不需要等待额外的信号或者状态变化
  • 资源消耗少:对于单机应用而言,仅需基本的内存空间来保存计数器即可,无需额外的队列或其他复杂数据结构

1.3.2 缺点

  • 突刺现象(毛刺效应) :在时间窗口切换时,若前一个窗口内的请求未满额,而后一个窗口一开始即有大量的请求涌入,则可能导致服务器瞬间压力过大。例如,如果1秒内允许100个请求,但在某秒的最后时刻突然来了100个请求,然后下一秒又是100个请求,即使总的请求并未超出每秒100次的限制,但连续两个窗口之间并没有均匀分配请求,从而造成服务压力波动。
  • 无法平滑限流:固定窗口计数器无法平滑控制请求流量,即无法很好地处理突发流量和平均流量之间的平衡。
  • 对周期较长的时间窗口效果不佳:长时间窗口内的限流可能会因为请求分布不均而导致服务器负载忽高忽低。

image-20240227112301186.png

2. 基于滑动窗口的限流算法

2.1 原理

基于滑动窗口的限流算法是一种较为先进且灵活的流量控制技术,用于限制在一定时间窗口内某个资源的访问次数或流量。相较于简单的固定窗口计数器限流,滑动窗口算法能更好地处理请求的均匀分布和平滑限流,减少因为窗口切换带来的不连续性和峰值问题。

工作原理:

  1. 窗口划分:将时间线划分为一系列固定大小的连续小窗口,例如,将一分钟划分为60个一秒的窗口。
  2. 窗口滑动:随着时间的推进,窗口就像一个滑动门一样,不断地向右滑动,每过一秒,新的窗口就会取代旧窗口。
  3. 请求计数:每当一个请求到来时,系统会在对应的时间窗口内进行计数。也就是说,每个窗口都有一个独立的计数器,记录在此窗口内发生的请求次数。
  4. 限流判断:判断当前时间点对应的完整滑动窗口内(从现在开始回溯至窗口大小之前的所有时间)的请求总数是否超过了预设的阈值。如果超过阈值,则拒绝新增的请求;否则,接受请求并将该窗口内的计数器加一。
  5. 窗口更新:每当滑动窗口向前移动时,旧窗口内的计数器不再增加,并且可能被清除或复位,以便继续统计新窗口的请求。
  6. 平滑处理突发流量:相比固定窗口,滑动窗口的优势在于它能够更平滑地处理流量的变化,因为它总是考虑的是最近一段时间内的请求总量,而不是在固定的间隔点重置计数。

image-20240227162250082.png

2.2 代码实现

public class SlidingWindowRateLimiter implements RateLimiter {
   
   
    /**
     * 窗口时长,秒级
     */
    private final long windowSize;
    /**
     * 限流大小
     */
    private final int limit;
    /**
     * 滑动窗口
     */
    private final ConcurrentHashMap<Long, AtomicInteger> window;
    /**
     * 当前滑动窗口的起始区间
     */
    private long windowStart;
    /**
     * 当前总数
     */
    private final AtomicInteger total;public SlidingWindowRateLimiter(long windowSize, int limit) {
   
   
        this.windowSize = windowSize;
        this.limit = limit;
        this.window = new ConcurrentHashMap<>();
        this.total = new AtomicInteger();
        this.windowStart = System.currentTimeMillis() / 1000;
    }private synchronized void refresh(long cur) {
   
   
        // 再次检查
        if (window.containsKey(cur)) {
   
   
            return;
        }// 清理过期区间数据
        long newWindowStart = cur - windowSize + 1;
        for (long i = windowStart; i < newWindowStart; i++) {
   
   
            AtomicInteger removed = window.remove(i);
            if (removed != null) {
   
   
                total.addAndGet(-removed.intValue());
            }
        }
        windowStart = newWindowStart;// 最后加上新的时间区间
        window.putIfAbsent(cur, new AtomicInteger(0));
    }@Override
    public boolean acquire() {
   
   
        long cur = System.currentTimeMillis() / 1000;
        // 检查当前时间区间是否已初始化,若未初始化,则进行初始化
        if (!window.containsKey(cur)) {
   
   
            refresh(cur);
        }// 尝试从滑动窗口获取元素
        while (!Thread.interrupted()) {
   
   
            int curTotal = total.get();
            if (curTotal + 1 > limit) {
   
   
                return false;
            }
            if (total.compareAndSet(curTotal, curTotal + 1)) {
   
   
                window.get(cur).incrementAndGet();
                return true;
            }
        }
        return false;
    }
}

2.3 优缺点

2.3.1 优点

  • 平滑限流:滑动窗口算法能够在一定程度上平滑地控制流量,因为它不是基于固定时间间隔进行重置计数,而是随着时间的推移逐步更新窗口内的请求计数,这样可以有效避免固定窗口算法在窗口切换时出现的“突刺现象”,即短时间内流量集中涌入。
  • 灵活性:可以灵活地控制时间窗口的粒度,例如将其划分为多个小窗口,这样可以根据实际业务需求调整限流策略的灵敏度和精度。
  • 即时性:滑动窗口能够即时反应系统的实时负载状况,每当一个窗口过去,新的窗口立刻生效,所以限流策略能够更快地响应系统负载的变化。
  • 适应突发流量:对于短期的突发流量,滑动窗口限流算法相比于固定窗口更能合理地分配流量,因为它考虑到的是过去一段时间内整体的请求量,而非单一窗口内的绝对数量。

2.3.2 缺点

  • 复杂性提高:相较于固定窗口计数器,滑动窗口算法在实现上更为复杂,需要维护多个窗口及其计数器的状态,增加了系统的复杂性和实现成本。
  • 空间占用:随着窗口粒度的细化,需要存储的数据结构(如队列或哈希表)所占用的内存空间也会相应增大。特别是在高并发和长时间跨度的情况下,可能需要更大的内存来支持多窗口的计数。
  • 处理突发流量局限性:虽然相比固定窗口有所改善,但如果突发流量非常猛烈且持续时间超过一个窗口的长度,滑动窗口限流仍可能无法完全消除流量尖峰对系统的影响。

3. 漏桶算法

3.1 原理

基于漏桶(Leaky Bucket)的限流算法是一种在网络传输和系统资源管理中广泛应用的流量整形和控制技术。该算法的核心理念是模拟一个带有小孔的桶,其中水代表流入系统的请求或数据包,桶则象征系统的处理能力。

工作原理:

  1. 桶容量:漏桶有一个固定容量,代表着系统能够暂时缓冲的最大请求量。不过,不同于令牌桶算法,漏桶的实际容量并不直接影响限流速率,只是决定了系统能够承受多大的突发流量。
  2. 漏水速率:漏桶上有固定速率的漏水口,这个速率代表了系统能够处理请求的恒定速度。无论桶内有多少水(请求),系统都按此速率向外处理请求。
  3. 流入请求:请求像水滴一样源源不断地进入漏桶,不论请求的速率有多快,漏桶都会接收所有的请求。
  4. 流量控制:如果请求的速率超过了漏水速率,那么漏桶内部的水量将会逐渐积累起来。当桶满时,新来的请求将被丢弃或拒绝,以此来限制流入系统的总体流量。
  5. 无突发处理能力:漏桶算法的一个显著特点是它不具备处理突发流量的能力。即使桶内没有水(请求空闲期),漏水速率也不会因此加快,这意味着系统的处理速率始终保持恒定。

image-20240227162313907.png

3.2 代码实现

public class LeakyBucketRateLimiter implements RateLimiter {
   
   
    /**
     * 流速,每秒漏rate个
     */
    private final int rate;
    /**
     * 桶大小
     */
    private final int bucketSize;
    /**
     * 漏桶
     */
    private final BlockingQueue<Object> bucket;public LeakyBucketRateLimiter(int bucketSize, int rate) {
   
   
        this.bucketSize = bucketSize;
        this.rate = rate;
        bucket = new ArrayBlockingQueue<>(bucketSize);
        new Thread(this::leaky).start();
    }public void leaky() {
   
   
        // 按照规定速度漏
        while (!Thread.interrupted()) {
   
   
            bucket.poll();
            sleep();
        }
    }@Override
    public boolean acquire() {
   
   
        // 尝试向桶插入元素,若有空间能插入返回true,否则返回false
        return bucket.offer(1);
    }public void sleep() {
   
   
        try {
   
   
            Thread.sleep(1000 / rate);
        } catch (InterruptedException e) {
   
   
            Thread.currentThread().interrupt();
        }
    }
}

3.3 优缺点

3.3.1 优点

  • 流量整形:漏桶算法能够强制将流量塑造成稳定的涓流,确保系统处理请求的速率恒定,这对于保护系统免受突发流量冲击,保持稳定性能非常重要。
  • 公平性:所有进入漏桶的请求都会按照预定的速率被处理,无论请求何时到达,都能得到公平对待,不存在请求间的竞争关系。
  • 易于实现:漏桶算法的概念和实现相对简单,可以用一个队列加上定时任务来模拟漏桶的行为,便于快速实施和调试。
  • 防止系统过载:当系统无法处理过多的请求时,漏桶可以作为一个缓存区,暂时存储部分请求,但由于漏水速率恒定,一旦桶满,超出的部分会被丢弃或拒绝,从而保护系统不被过度压垮。

3.3.2 缺点

  • 无法处理突发流量:漏桶算法最大的缺点是无法应对合理的突发流量需求。无论系统当前负载如何,只要漏桶的漏水速率不变,即使是系统有能力处理更多的请求时,也无法加速处理突发的大量请求。
  • 资源利用率不高:在流量较低时,漏桶算法可能导致系统资源利用率不足。即使系统此时有足够的处理能力,也无法增加处理速率,这可能导致在某些时段内系统性能未能充分利用。
  • 缺乏弹性:对于那些希望系统能在负载较低时积攒一定的处理能力以应对未来可能的突发请求的场景,漏桶算法并不能提供这样的弹性伸缩特性。
  • 无法区分优先级:漏桶算法对所有请求一视同仁,不考虑请求的优先级,所有请求都按相同的速率流出,不利于实现基于优先级的流量控制。

4. 令牌桶算法

4.1 原理

基于令牌桶(Token Bucket)的限流算法是一种在网络传输、系统资源调度和API调用限速等领域广泛应用的流量控制策略。该算法通过模拟一个不断填充令牌的桶来决定哪些请求可以被执行。

工作原理:

  1. 令牌生成:系统按照一个恒定的速率生成令牌并存入令牌桶中。这个速率体现了系统允许的最大处理速率。
  2. 令牌桶容量:令牌桶具有一个固定的容量上限,当桶内令牌数量达到容量上限时,多余的令牌将被丢弃。
  3. 请求处理:当请求到达时,必须从令牌桶中获取一个或多个令牌(取决于请求所需的成本或权重)。只有当桶中有足够的令牌可供消费时,请求才会被允许执行。
  4. 突发处理:令牌桶的一个重要特性是可以积累令牌,因此在请求较少时,令牌会不断累积在桶内。这样一来,当后续有突发请求时,桶内已经累积的令牌可以快速满足这些请求,使得系统在一定程度上能够应对短期内的流量高峰。
  5. 流量控制:通过控制令牌生成速率和桶的容量,系统可以实现对请求处理速率的限制。如果令牌桶为空并且系统还在按照限速速率填充令牌,后续的请求将不得不等待令牌生成后再处理,从而实现了限流目的。

image-20240227162327195.png

4.2 代码实现

public class TokenBucketRateLimiter implements RateLimiter {
   
   
    /**
     * 每秒补充的令牌数
     */
    private final int rate;
    /**
     * 最大令牌数量
     */
    private final int bucketSize;
    /**
     * 令牌桶
     */
    private final AtomicInteger bucket;public TokenBucketRateLimiter(int rate, int bucketSize) {
   
   
        this.rate = rate;
        this.bucketSize = bucketSize;
        bucket = new AtomicInteger(0);
        new Thread(this::refill).start();
    }public void refill() {
   
   
        // 定时补充令牌
        while (!Thread.interrupted()) {
   
   
            if (bucket.get() < bucketSize) {
   
   
                bucket.incrementAndGet();
            }
            sleep();
        }
    }@Override
    public boolean acquire() {
   
   
        // 尝试获取令牌
        while (!Thread.interrupted()) {
   
   
            int cur = bucket.get();
            if (cur == 0) {
   
   
                return false;
            }
            if (bucket.compareAndSet(cur, cur - 1)) {
   
   
                return true;
            }
        }
        return false;
    }public void sleep() {
   
   
        try {
   
   
            Thread.sleep(1000 / rate);
        } catch (InterruptedException e) {
   
   
            Thread.currentThread().interrupt();
        }
    }
}

4.3 优缺点

4.3.1 优点

  • 允许突发流量:令牌桶算法能够在规定了平均发送速率的同时,允许某种程度的突发请求。当令牌桶中有足够令牌积累时,短时间内大量请求可以被迅速处理,这有利于应对系统的峰值负载。
  • 平滑限流:虽然令牌桶本身并不直接平滑输出流量,但它能通过持续且均匀地向桶中添加令牌来维持一个稳定的平均处理速率。因为令牌可以预先积累,所以对于短期的超额流量有一定的容忍度。
  • 灵活配置:令牌桶算法可以根据需要调整令牌生成速率(即限流速率)和桶的容量(即最大突发容量),从而灵活地适应不同应用场景下的流量控制要求。
  • 响应及时性:只要令牌桶中有令牌,请求就能立即得到处理,减少了延迟,提升了用户体验。

4.3.2 缺点

  • 无法严格限制瞬时流量:尽管令牌桶算法能在一定程度上抑制突发流量,但如果桶的容量较大,短时间内仍可能允许超出平均速率的流量通过。
  • 实现复杂度:相比于简单的固定速率控制机制,令牌桶算法的实现相对复杂,需要设计和维护令牌的生产和消费过程。
  • 无法精确匹配特定时间窗口内的绝对限流:在一些需要严格保证每个时间窗口内请求总量不超过某一阈值的场景下,令牌桶可能无法做到完全精确控制。
  • 内存消耗:令牌桶需要存储令牌的数量信息,大规模分布式系统中可能会带来额外的内存开销。

5. 小结

对比基于计数的限流算法(这里指固定窗口计数器算法)、滑动窗口算法、漏桶算法和令牌桶算法:

基于计数的限流算法(固定窗口) 滑动窗口算法 漏桶算法 令牌桶算法
原理 固定时间段内计数,超限则限流 时间窗口内细分计数,逐个窗口检查 请求进入队列并按恒定速率流出 按固定速率填充令牌,请求需消耗令牌才能处理
特点 粗粒度限流,易实现 精细化限流,更平滑 控制流出速率,无视突发请求 允许一定突发流量,同时保持平均速率
灵活性 较差,窗口切换时可能出现突刺 较好,连续性和稳定性较好 良好,恒定处理速率 最佳,可调节限流速率和突发处理能力
突发处理 不允许 有一定处理能力 不允许 允许
平滑性 较差 较好 很好 很好
实现难度 简单 中等 简单到中等 中等到复杂
适用场景 对简单限流需求,如基础并发控制 需要更精细流量控制的场景 稳定性要求高,流量整形 网络传输、接口限流、既要限速又要允许突发流量

三、限流工具

1. Guava RateLimiter

Guava RateLimiter 是 Google Guava 库中提供的一个强大的限流工具类,主要用于控制系统的吞吐量或请求频率,防止服务因短时间内接收到过多请求而过载。RateLimiter 实现了令牌桶算法,可以按指定的速率发放令牌,请求到来时只有拿到令牌才能继续执行。

1.1 使用

依赖:

<dependencies>
    <!-- Google Guava -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>31.1-jre</version>
    </dependency>
</dependencies>
RateLimiter rateLimiter = RateLimiter.create(10);
double acquire = rateLimiter.acquire(); // 返回等待时间

1.2 原理

Guava RateLimiter 采用的是令牌桶算法。采用了非常巧妙地方式实现,和上述介绍令牌桶算法小节的代码有所差异。Guava RateLimiter采用时间差计算令牌+提前消费令牌+睡眠等待的机制实现令牌桶算法。

主要逻辑如下:

  • 成员变量说明

    • storedPermits:表示当前令牌桶中存储的令牌数量。
    • maxPermits:表示令牌桶的最大容量,即最多能存储多少令牌。
    • stableIntervalMicros:稳定状态下,生成一个令牌所需的固定时间间隔,单位是微秒(microsecond)。
    • nextFreeTicketMicros:下一次请求能够获取令牌的时间点,这个时间会被不断推后以保证稳定的速率。
  • reserveEarliestAvailable() 方法

    • 该方法是限流的核心方法,传入参数包括所需的令牌数量(requiredPermits)和当前时间(nowMicros)。
    • 首先,调用resync(nowMicros)来同步令牌桶的状态,确保令牌生成逻辑与当前时间一致。
    • 接着计算本次请求可以从当前令牌桶中直接消费的令牌数量(storedPermitsToSpend)。
    • 然后计算还需额外生成的新令牌数量(freshPermits)以及为此需要等待的时间(waitMicros)。
    • 更新nextFreeTicketMicros,将其推进到下一个可供发放令牌的时间点。
    • 消耗掉存储令牌桶中的相应令牌数,并返回客户端需要等待的时间(以便于客户端可以据此选择是否阻塞或延迟执行)。
  • resync() 方法

    • 当发现当前时间大于下一次发放令牌的时间时,表明已经有段时间没有发放新的令牌,这时需要重新同步令牌桶状态。
    • 根据时间差计算在这段时间内本应生成的令牌数,并增加到当前的令牌存储量(storedPermits)中,但不超过最大令牌数(maxPermits)。
    • 同时将nextFreeTicketMicros更新为当前时间,从而恢复正常的令牌发放节奏。

resync过程:

image-20240228103833338.png

获取令牌:

image-20240228104047338.png

整体来看,这段代码通过reserveEarliestAvailable()方法实现了动态调整令牌发放策略,确保限流器在不同的请求情况下都能维持预期的稳定速率,同时允许在令牌充足时快速响应请求,在令牌不足时则合理安排等待时间。

public abstract class RateLimiter {
   
   
  @CanIgnoreReturnValue
  public double acquire(int permits) {
   
   
    // 预留permits个令牌,返回需要等待的时长 microsToWait
    long microsToWait = reserve(permits);
    // 睡眠 microsToWait
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
  }

  final long reserve(int permits) {
   
   
    // 检查参数
    checkPermits(permits);
    synchronized (mutex()) {
   
   
      // 预留permits个令牌,并且获取需要等待的时长。并且传入当前时间
      return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
  }

  final long reserveAndGetWaitLength(int permits, long nowMicros) {
   
   
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);
  }
}
abstract class SmoothRateLimiter extends RateLimiter {
   
   
  /** The currently stored permits. 当前剩余令牌数*/
  double storedPermits;

  /** The maximum number of stored permits. 最大令牌数*/
  double maxPermits;

  /**
   * 生成1个令牌的时间间隔
   * The interval between two unit requests, at our stable rate. E.g., a stable rate of 5 permits
   * per second has a stable interval of 200ms.
   */
  double stableIntervalMicros;

  /**
   * 下一次释放令牌的时间
   * The time when the next request (no matter its size) will be granted. After granting a request,
   * this is pushed further in the future. Large requests push this further than small requests.
   */
  private long nextFreeTicketMicros = 0L; // could be either in the past or future

  @Override
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
   
   
    resync(nowMicros);
    // 用于返回的时间,用于睡眠。如果当前有令牌,返回nowMicros;否则返回下一次有令牌的时间
    long returnValue = nextFreeTicketMicros;
    // 算出当前可以被消费的令牌数storedPermitsToSpend、提前消费的令牌数freshPermits
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    double freshPermits = requiredPermits - storedPermitsToSpend;
    // 算出提前消费的令牌导致的等待时间waitMicros、下一次释放令牌的时间nextFreeTicketMicros
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); 
    // 消费令牌
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }

  void resync(long nowMicros) {
   
   
    // 如果当前时间已经超过了释放令牌的时间,则需要更新
    if (nowMicros > nextFreeTicketMicros) {
   
   
      // 计算当前时间 - 释放令牌时间,算出时间间隔。除以1个时间间隔,算出在此时间间隔会生成多少个令牌。最大不得超过maxPermits个令牌
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      // 更新令牌释放时间
      nextFreeTicketMicros = nowMicros; 
    }
  }
}

2. Redisson RateLimiter

Redisson RateLimiter 是 Redisson 客户端库提供的一种分布式限流器实现,它基于 Redis 的强大数据结构和 Lua 脚本支持,能够在分布式环境下实现高效的限流功能。

2.1 使用

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.0</version>
</dependency>
RRateLimiter rateLimiter = redissonClient.getRateLimiter("rateLimiter");
// 全局限流,每小时不超过100个请求
rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS);
// 单客户端限流,每分钟不超过5个请求
//rateLimiter.trySetRate(RateType.PER_CLIENT, 5, 1, RateIntervalUnit.MINUTES);
// 尝试获取一个令牌,如果获取成功则返回true,失败则返回false
boolean permitted = rateLimiter.tryAcquire();
// 或者尝试获取多个令牌,指定最长等待时间
//boolean permitted = rateLimiter.tryAcquire(3, 1000, TimeUnit.MILLISECONDS); // 尝试获取3个令牌,最多等待1秒

2.2 原理

Redisson的RateLimiter通过lua脚本保证执行的原子性。主要采用固定窗口的算法。

限流器主要由两个key组成:

  • 元数据,用于保存限流器的配置参数。key是限流器名字,类型是hash,主要包括几个字段:

    • rate:保存限流器速率
    • interval:保存窗口的大小,也就是窗口的时间间隔
    • type:限流器类型,全局限流 or 单客户端限流
  • 计数器,用于保存剩余可用数量。key是 {限流器名字}:value,类型是string

主要的方法包括trySetRateAsync,用于初始化限流器;tryAcquireAsync用于获取令牌。

2.2.1 设置限流速率

从下列lua脚本可以看出,方法的主要作用就是设置元数据。

@Override
public RFuture<Boolean> trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
   
   
    // 异步执行一个Redis命令,尝试设置限流器的速率、间隔和类型
    return commandExecutor.evalWriteAsync(
            // 设置Redis key为限流器的名字
            getName(),
            // 使用LongCodec实例进行序列化和反序列化
            LongCodec.INSTANCE,
            // 使用EVAL_BOOLEAN命令,表示执行Lua脚本并期望返回一个布尔值
            RedisCommands.EVAL_BOOLEAN,

            // Lua脚本内容:
            "redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]); " // 1. 尝试设置哈希表中key为'rate'的字段,其值为ARGV[1](即限流速率rate)
            + "redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]); " // 2. 尝试设置哈希表中key为'interval'的字段,其值为ARGV[2](即限流间隔转换成毫秒后的值)
            + "return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);", // 3. 尝试设置哈希表中key为'type'的字段,其值为ARGV[3](即限流类型的枚举序号)

            // Lua脚本参数,第一个元素是keys数组,这里是限流器名字
            Collections.<Object>singletonList(getName()),

            // Lua脚本的额外参数,分别对应限流速率、限流间隔(转换为毫秒)和限流类型(转换为枚举序号)
            rate, unit.toMillis(rateInterval), type.ordinal());
}

2.2.2 获取令牌

这段代码实现了一个异步方法,用于尝试从Redis存储的限流器中获取指定数量的令牌。

Lua脚本首先读取限流器的相关配置,然后根据令牌计数器当前的值判断是否可以发放令牌,并进行相应的增减操作。

如果令牌发放成功,返回nil;如果不成功,则返回令牌计数器的剩余生存时间。

整个过程都在Redis中完成,实现了高效的分布式限流控制。

// Java方法定义,异步尝试获取令牌(Try acquiring asynchronously)
private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {
   
   
    // 使用Redis命令执行器执行Lua脚本,并返回异步结果
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,

        // Lua脚本开始
        "local rate = redis.call('hget', KEYS[1], 'rate'); " // 1. 从哈希表中获取限流速率(rate)
        + "local interval = redis.call('hget', KEYS[1], 'interval'); " // 2. 从哈希表中获取限流间隔(interval)
        + "local type = redis.call('hget', KEYS[1], 'type'); " // 3. 从哈希表中获取限流类型(type)
        + "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized') " // 4. 断言限流器已初始化

        + "local valueName = KEYS[2]; " // 5. 初始化变量valueName,指向全局令牌计数器的键名
        + "if type == '1' then " // 6. 如果限流类型为某种特定类型(此处可能是客户端级别的限流)
            + "valueName = KEYS[3]; " // 7. 将valueName指向客户端令牌计数器的键名
        + "end; "

        + "local currentValue = redis.call('get', valueName); " // 8. 获取当前令牌计数器的值
        + "if currentValue ~= false then " // 9. 如果当前令牌计数器存在值
            + "if tonumber(currentValue) < tonumber(ARGV[1]) then " // 10. 如果当前令牌数量小于请求的令牌数
                + "return redis.call('pttl', valueName); " // 11. 返回令牌计数器剩余的生存时间(毫秒)
            + "else " // 12. 否则,当前令牌数量足够
                + "redis.call('decrby', valueName, ARGV[1]); " // 13. 从令牌计数器中减去请求的令牌数
                + "return nil; " // 14. 返回nil,表示成功获取令牌(异步方法中,nil通常表示正常执行)
            + "end; "
        + "else " // 15. 如果当前令牌计数器不存在值
            + "assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate'); " // 16. 断言请求令牌数不超过限流速率
            + "redis.call('set', valueName, rate, 'px', interval); " // 17. 重新设置令牌计数器的值,并设置过期时间
            + "redis.call('decrby', valueName, ARGV[1]); " // 18. 从令牌计数器中减去请求的令牌数
            + "return nil; " // 19. 返回nil,表示成功获取令牌
        + "end; ",

        // Lua脚本参数列表,包含限流器的名字、全局令牌计数器的键名和客户端令牌计数器的键名
        Arrays.<Object>asList(getName(), getValueName(), getClientValueName()), 

        // Lua脚本中的额外参数,分别是请求的令牌数量和当前Redis连接管理器的ID
        value, commandExecutor.getConnectionManager().getId());
}

3. 其他

除了Guava RateLimiter 和 Redisson RateLimiter,还有许多其他的限流工具和技术,下面是其中一部分:

  1. Spring Cloud Gateway Rate Limiter Spring Cloud Gateway 提供了一种基于过滤器的限流机制,可以通过集成如Sentinel或Resilience4j等限流组件来实现。
  2. Apache Commons Pool 虽然Apache Commons Pool主要是一个对象池管理工具,但是通过调整池大小和借用对象的策略,也可以间接实现对资源的限流。
  3. Sentinel 阿里巴巴开源的Sentinel是一个面向分布式服务架构的流量控制、熔断降级和系统负载保护组件,它拥有丰富的限流策略,如QPS、线程数、系统并发数等。
  4. Hystrix Netflix开发的Hystrix库,虽然已经停止维护,但在过去曾经广泛用于服务降级和限流,它提供了一套完整的断路器模式实现,其中包括了基于线程池和信号量的限流功能。
  5. Resilience4j Resilience4j 是一个轻量级的故障恢复库,提供了RateLimiter组件,实现基于令牌桶算法的限流功能,同时兼容Java 8的函数式编程风格。
  6. Envoy Envoy 是一个由Lyft开发的高性能代理和通信层安全软件,可用于服务网格,它也提供了基于HTTP头部、权重分配等方式的限流功能。
  7. Istio Istio服务网格提供了细粒度的流量管理和控制能力,可以通过配置对服务间调用进行限流,支持多种限流策略和指标。
  8. Ingress Controller 如 Nginx Nginx Ingress Controller可通过配置限流插件(如ngx_http_limit_req_module)实现对进入服务集群的流量进行限速。
  9. JCTools Ratelimiters JCTools 是一个专注于低级别并发原语的Java库,提供了基于 CAS 优化的非阻塞型限流器实现。
  10. LocalLimit 一些项目会选择自己实现简单的本地限流器,例如使用AtomicLong配合System.nanoTime()实现滑动窗口限流,这种方式适用于单机场景且对性能要求较高时。
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
3月前
|
算法 NoSQL Java
spring cloud的限流算法有哪些?
【8月更文挑战第18天】spring cloud的限流算法有哪些?
86 3
|
4月前
|
存储 算法 Java
高并发架构设计三大利器:缓存、限流和降级问题之滑动日志算法问题如何解决
高并发架构设计三大利器:缓存、限流和降级问题之滑动日志算法问题如何解决
|
4月前
|
算法 Java 调度
高并发架构设计三大利器:缓存、限流和降级问题之使用Java代码实现令牌桶算法问题如何解决
高并发架构设计三大利器:缓存、限流和降级问题之使用Java代码实现令牌桶算法问题如何解决
|
4月前
|
缓存 算法 Java
高并发架构设计三大利器:缓存、限流和降级问题之使用代码实现漏桶算法问题如何解决
高并发架构设计三大利器:缓存、限流和降级问题之使用代码实现漏桶算法问题如何解决
|
4月前
|
算法 UED 缓存
高并发架构设计三大利器:缓存、限流和降级问题之滑动窗口算法适用于哪些场景
高并发架构设计三大利器:缓存、限流和降级问题之滑动窗口算法适用于哪些场景
|
4月前
|
存储 算法 缓存
高并发架构设计三大利器:缓存、限流和降级问题之滑动窗口算法的原理是什么
高并发架构设计三大利器:缓存、限流和降级问题之滑动窗口算法的原理是什么
|
4月前
|
算法 API 缓存
高并发架构设计三大利器:缓存、限流和降级问题之固定窗口限流算法的原理是什么
高并发架构设计三大利器:缓存、限流和降级问题之固定窗口限流算法的原理是什么
|
4月前
|
监控 算法 Java
详解 Java 限流接口实现问题之避免令牌桶限流算法可能导致的过载问题如何解决
详解 Java 限流接口实现问题之避免令牌桶限流算法可能导致的过载问题如何解决
|
4月前
|
算法 Java
详解 Java 限流接口实现问题之漏桶限流算法的缺点问题如何解决
详解 Java 限流接口实现问题之漏桶限流算法的缺点问题如何解决
|
4月前
|
算法 Java UED
详解 Java 限流接口实现问题之滑动窗口限流算法的缺点如何解决
详解 Java 限流接口实现问题之滑动窗口限流算法的缺点如何解决