逐行拆解Guava限流器RateLimiter

日常开发中,经常会遇到一些需要限流的场景。我们希望每一秒的请求量不要超过某一个阈值,以防止过多的请求对服务造成过大的压力。常见的限流算法有计数器法、漏桶算法和令牌桶算法,下面我们简单的了解一下这几个算法。

一.常见限流算法

1.计数器法

设置一个时间窗口内允许的最大请求量,如果当前窗口请求数超过这个设定数量,则拒绝该窗口内之后的请求。

关键词:时间窗口,计数器。

举个例子,我们设置1秒钟的最大请求数量为100,使用一个计数器来记录这一秒中的请求数。每一秒开始时,计数器都从0开始记录请求量,如果在这一秒内计数器达到了100,则在这一秒未结束时,之后的请求全部拒绝。

计数器法是一个简单粗暴的方法。也就是因为简单粗暴,会有一定的问题。比如0.9秒时有100个请求,1.1秒时也有100个请求。按照计数器法的计算,第一秒和第二秒确实都在各自的时间范围内限制了100个请求,但是从0.5秒1.5秒这一秒之间有200个请求,这是计数器法的临界问题。如果请求量集中在两个时间窗口的临界点,会产生请求的尖峰。
image

我们可以引用滑动窗口的方式更加精细的划分时间窗口解决这个问题。将1秒钟细分为10个格子,每个格子用单独的计数器计算当前100毫秒的请求。随着时间的流逝,我们的窗口也跟着时间滑动,每次都是计算最近10个格子的请求量。如果最近10个格子的请求总量超过了100,则下一个格子里就拒绝全部的请求。格子数量越多,我们对流量控制的也就越精细,这部分逻辑可以借助LinkedList来实现。

而滑动窗口的缺点是,需要一直占用内存空间保存最近一个时间窗口内每个格子的请求。

2.漏桶算法

我们想象有一个固定容量的桶,桶底有个洞。请求就是从上往下倒进来的水,可能水流湍急,也可能是涓涓细流。因为桶的容量是固定的,所以灌进来太多的水,溢出去了我们就用不了了。所以无论怎样,桶底漏出来的水,都是匀速流出的。而我们要做的,就是处理这些匀速流出的水就好了。

关键词:流入速率不定,流出速率恒定,只处理流出的请求。

漏桶的优点就是,需要处理的请求一直都是固定速率的,特别稳定。而对应的缺点就是,漏桶没有办法解决突发流量的情况。

3.令牌桶算法

相比于漏桶而言,令牌桶的处理方式完全相反。我们想象依旧有一个固定容量的桶,不过这次是以稳定的速率生成令牌放在桶中。当有请求时,需要获取令牌才能通过。因为桶的容量固定,令牌满了就不生产了。桶中有充足的令牌时,突发的流量可以直接获取令牌通过。当令牌取光后,桶空了,后面的请求就得等生成令牌后才能通过,这种情况下请求通过的速率就变得稳定了。

关键词:令牌生成速率固定,能取到令牌即可通过。

令牌桶的优点是可用处理突发流量,而且被广泛的应用于各种限流工具中。比如Guava的RateLimiter。下面我们就来由浅及深的了解一下,RateLimiter中的令牌桶是怎么实现的吧。

二.RateLimiter令牌桶解析

下面来解析一下,RateLimiter类的工作原理与源码。

1.教你如何用令牌桶的方式卖包子

我们先不看源码,举个形象的例子先感受一下,RateLimiter类是如何实现令牌桶逻辑的。

小白包子铺因为小笼包皮薄馅香汤汁多而生意兴隆,每天都会有很多人排队来吃。因为蒸笼个数有限,后厨师傅的人力也有限,每分钟只能出锅一屉小笼包,每个小时一共能蒸出60屉小笼包供顾客享用。如果这60屉小笼包没人买,就先不蒸了,反正也没地方放。

每位顾客需要先在门口付买包子的钱,然后进到铺子里吃包子。如小笼包都卖没了,门口的顾客就只能付完钱等着,等有新出炉的小笼包可卖时,再进门享用。

包子铺每天早上7点准时开门,师傅开始制作这60屉小笼包【RateLimiter rateLimiter = RateLimiter.create(60)】。顾客蜂拥而至排队,第一个顾客先付一屉小笼包的钱【rateLimiter.acquire()】,等一分钟后小笼包出锅,便可以进屋吃包子了。这时伙计会和顾客说:“您本次等待了1分钟,久等了。”【rateLimiter.acquire()返回的值】。之后是第二个顾客,付钱,等一分钟,进屋吃包子。于是,早上大家就这样一直稳定有序的进屋吃包子。这事来了个火急火燎的上班族,说:“我着急吃饭,不想等,有现成的包子吗?”【rateLimiter.tryAcquire()】伙计看了一眼后厨说:“没有现成的包子,需要等会哦。”一看还要等,第三个顾客就走了。

早高峰过去后,买包子的人少了,师傅做完60个包子后,就休息了。

中午12点,隔壁科技园的程序员们下班了,顾客蜂拥而至。因为已经有了60个库存,大家不用等,付了钱就可以直接进屋吃包子,屋子里一下就涌入了60个顾客。看到小笼包都买光了,后厨师傅又赶紧忙了起来。

这时来了个大胃王,大嗓门喊道:“我想要30屉包子。”【rateLimiter.acquire(30)】伙计一愣,心想,这一下也做不出这么多包子呀。但是本着客户至上的原则,他对大胃王说:“好的先生,不过我们的包子都是现做的,您先吃着,我们蒸好了陆陆续续给您端上来可以吗?”大胃王说:“哈哈,好!好饭不怕晚!”于是交钱进门吃包子了。这时候又来了个顾客A,说我想要一屉包子。伙计心里一算,现在是一点,等刚才那个大胃王要的包子上齐了,才能给这位顾客上包子,所以需要等到1:30才能给这位顾客吃上,好惨一顾客。于是说:“不好意思这位顾客,小笼包现在没有了,您需要等一段时间。”顾客A说:“好吧,我交了钱等着吧。”于是过了半个小时,后厨做出来了第31屉包子,顾客A就满足的进屋吃包子了。

2.RateLimiter的重要思想

上一节的例子里,我们可以这样理解:每一屉小笼包就是一个令牌,包子铺的后厨就是令牌桶。

  1. 令牌桶有最大令牌数限制,最多只能装60个令牌,再多就装不下了。
  2. 令牌的生成也是匀速的,每个小时生成60个令牌,如果时刻都有人来取令牌,那这些人会保持均匀的速率领取。(SmoothBursty模式和SmoothWarmingUp模式稳定期)
  3. 如果之前累积了大量的令牌,一旦有大量的请求来获取令牌,这些请求都会被通过。
  4. RateLimiter是预获取令牌模式,即:如果一次性被消耗掉大于已有数量的令牌,RateLimiter的策略是先满足你,不够的令牌数我先欠着,等后续生成了足够的令牌后,再给下一个请求发放令牌。
  5. 令牌桶通过记录下一次请求可发放令牌的时间点来确定是否接受下一个请求,以及下一个请求需要等待的时间。

下面我们从源码入手,来进一步了解RateLimiter类的实现原理吧!

3.RateLimiter类源码解析

RateLimiter的源码其实很简单,只涉及到RateLimiter和SmoothRateLimiter两个类文件。其中RateLimiter类是一个抽象类,定义了限流器常用的方法。具体的逻辑都是通过其子类SmoothRateLimiter来实现的。

在这里我们主要看最核心的部分,来了解中RateLimiter的基本工作原理。

3.1 计时器

首先要说一下RateLimiter类中的计时器。

计时器随限流器初始化时创建,贯穿始终,伴随限流器漫长的一生。令牌桶中数量的计算、是否到达可以接收请求的时间点都需要计时器参与。我认为计时器是限流器成立的基础。

限流器使用的是SleepingStopwatch这个内部类作为计时器。而SleepingStopwatch的则是通过持有一个Stopwatch对象来实现计时和休眠。

/**
 * The underlying timer; used both to measure elapsed time and sleep as necessary. A separate
 * object to facilitate testing.
 * 一个底层的限流器,用于测量运行时间和睡眠时间
 */
private final SleepingStopwatch stopwatch;

abstract static class SleepingStopwatch {
    /**
     * Constructor for use by subclasses.
     */
    protected SleepingStopwatch() {
    }

    /**
     * 获取当前时间点
     *
     * @return
     */
    protected abstract long readMicros();

    /**
     * 按照计算的睡眠时间等待休眠
     *
     * @param micros
     */
    protected abstract void sleepMicrosUninterruptibly(long micros);

    public static SleepingStopwatch createFromSystemTimer() {
        return new SleepingStopwatch() {
            final Stopwatch stopwatch = Stopwatch.createStarted();

            @Override
            protected long readMicros() {
                return stopwatch.elapsed(MICROSECONDS);
            }

            @Override
            protected void sleepMicrosUninterruptibly(long micros) {
                if (micros > 0) {
                    Uninterruptibles.sleepUninterruptibly(micros, MICROSECONDS);
                }
            }
        };
    }
}

3.2 重要的变量

再看一下SmoothRateLimiter类中我们需要记住的几个变量。令牌桶的令牌生成、消耗、领取和等待都是对这几个变量的操作,这些变量约束控制令牌桶的空间状态和令牌生成速度。

具体每个变量是做什么的,关注代码里面的注释哦。

/**
 * The currently stored permits.
 * 当前桶中已存在的令牌数,如果请求需要的令牌数量小于已存在的令牌数,就可以直接领了令牌去搞事情啦
 */
double storedPermits;

/**
 * The maximum number of stored permits.
 * 令牌桶可以保存的最大令牌数,达到这个数量,就不再生产令牌了,装不下啦
 */
double maxPermits;

/**
 * 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.
 * 稳定令牌生成间隔时间,单位是微秒。其实这个数字是根据设置令牌桶时计算的:1s/每秒生成令牌数量(QPS)。当然,后期如果修改了每秒生成令牌的数量,这个值也是会变的。
 */
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;

3.3 核心方法使用与原理

了解了控制限流器时间和空间相关的变量,我们就来看一下核心流程,限流器是如何工作的吧。

Guava对RateLimiter有两种实现方式。

  • SmoothBursty:平滑突发限流,以稳定的速率生成令牌。下文中称SmoothBursty实现的限流器为稳定限流器。
  • SmoothWarmingUp:平滑预热限流,随着请求量的增加,令牌生成速率会缓慢提升直到一个稳定的速率。下文称SmoothWarmingUp实现的限流器为预热限流器。

其中,稳定限流器支持处理突发请求。比如令牌桶现有令牌数为5,这时连续进行10个请求,则前5个请求会全部直接通过,没有等待时间,之后5个请求则每隔200毫秒通过一次。
而预热限流器则只会让第一个请求直接通过,之后的请求都会有等待时间,等待时间不断缩短,直到稳定在每隔200毫秒通过一次。这样,就会有一个预热的过程。

先上一个简单的demo,直观的感受一下这两种限流器的差别。

/**
 * 突发请求情况对比
 */
public static void compareBurstyRequest() {
    RateLimiter burstyLimiter = RateLimiter.create(5);
    RateLimiter warmingUpLimiter = RateLimiter.create(5, 2, TimeUnit.SECONDS);

    DecimalFormat df = new DecimalFormat("0.00");
    // 积攒1秒
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    System.out.println("稳定限流器");
    IntStream.range(0, 10).forEach(a -> {
        double acquire = burstyLimiter.acquire();
        System.out.println("第" + a + "次请求等待时间:" + df.format(acquire));
    });

    System.out.println("预热限流器");
    IntStream.range(0, 10).forEach(a -> {
        double acquire = warmingUpLimiter.acquire();
        System.out.println("第" + a + "次请求等待时间:" + df.format(acquire));
    });
}

代码执行结果对比如下:

image

结果一目了然。

好啦,那我们开始代码拆解之旅吧。

1.创建限流器

RateLimiter提供了两个静态方法来创建限流器,其中一个是创建稳定限流器,创建时只需要传入每秒生成的令牌数即可。具体逻辑和讲解都在代码的注释里啦,注意跟着代码走哦~

// 创建稳定限流器
RateLimiter burstyLimiter = RateLimiter.create(5);

它是由SmoothBursty实现的,比较简单:

static final class SmoothBursty extends SmoothRateLimiter {
    /**
     * The work (permits) of how many seconds can be saved up if this RateLimiter is unused?
     * 用于计算maxPermits,在初始化时默认设置为1秒。所以最多存储1秒内生成的令牌数
     */
    final double maxBurstSeconds;

    SmoothBursty(SleepingStopwatch stopwatch, double maxBurstSeconds) {
        super(stopwatch);
        this.maxBurstSeconds = maxBurstSeconds;
    }

    @Override
    void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
        double oldMaxPermits = this.maxPermits;
        // 设置最大令牌数
        maxPermits = maxBurstSeconds * permitsPerSecond;
        if (oldMaxPermits == Double.POSITIVE_INFINITY) {
            // if we don't special-case this, we would get storedPermits == NaN, below
            // 我也不知道啥时候会出现这个情况
            storedPermits = maxPermits;
        } else {
            // 如果oldMaxPermits为0,则说明是初始化限流器,当前令牌桶已存在令牌置为0。
            // 否则按照新的最大令牌数等比例的扩大已存在的令牌数
            storedPermits = (oldMaxPermits == 0.0) ? 0.0  : storedPermits * maxPermits / oldMaxPermits;
        }
    }

    @Override
    long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
        return 0L;
    }

    @Override
    double coolDownIntervalMicros() {
        return stableIntervalMicros;
    }
}

另一个是预热限流器,创建时需要传入每秒生成的令牌数和预热时间。

// 创建预热限流器
RateLimiter warmingUpLimiter = RateLimiter.create(5, 2, TimeUnit.SECONDS);

预热限流器由SmoothWarmingUp实现,有些复杂。我们先看涉及到构造方法的这部分就好,后面的storedPermitsToWaitTime方法等到下一节涉及到获取令牌时的逻辑再看。

static final class SmoothWarmingUp extends SmoothRateLimiter {
    /**
     * 预热时间,单位毫秒
     */
    private final long warmupPeriodMicros;
    /**
     * The slope of the line from the stable interval (when permits == 0), to the cold interval
     * (when permits == maxPermits)
     * 斜率,用于计算坐标系中梯形面积
     */
    private double slope;

    /**
     * 预热期与稳定期令牌数临界值
     */
    private double thresholdPermits;

    /**
     * 冷却期因子,用于计算冷却期产生令牌时间间隔
     */
    private double coldFactor;

    SmoothWarmingUp(
            SleepingStopwatch stopwatch, long warmupPeriod, TimeUnit timeUnit, double coldFactor) {
        super(stopwatch);
        this.warmupPeriodMicros = timeUnit.toMicros(warmupPeriod);
        this.coldFactor = coldFactor;
    }

    /**
     * @param permitsPerSecond     每秒生成令牌数,SmoothWarmingUp用不上
     * @param stableIntervalMicros 稳定期令牌生成间隔时间
     */
    @Override
    void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
        double oldMaxPermits = maxPermits;
        // 冷却期产生令牌时间间隔 = 稳定期令牌生成间隔时间 * 冷却期因子
        double coldIntervalMicros = stableIntervalMicros * coldFactor;
        // 临界值 = 0.5 * 预热时间 / 稳定期令牌生成间隔时间
        // 源码的注释中说,从maxPermits到thresholdPermits,需要用warmupPeriod的时间,而从thresholdPermits到0需要warmupPeriod一半的时间
        // 因为前面coldFactor被硬编码为3.好吧~
        thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros;
        // 最大令牌数 = 临界值 + 2 * 预热时间 / (稳定期令牌生成间隔时间 + 冷却期产生令牌时间间隔)
        // 为什么是这么算出来的呢?源码的注释已经给出了推导过程。因为坐标系中右边的梯形面积就是预热的时间,即:
        // warmupPeriodMicros = 0.5 * (stableInterval + coldInterval) * (maxPermits - thresholdPermits)
        // 因此可以推导求出maxPermits:
        maxPermits = thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros);

        // 咳咳,这里其实仔细算算,一顿操作猛如虎,因为coldFactor默认为3,所以其实:
        // maxPermits = warmupPeriodMicros / stableIntervalMicros
        // thresholdPermits = maxPermits / 2

        // 斜率 = (冷却期产生令牌时间间隔 - 稳定期令牌生成间隔时间) / (最大令牌数 - 临界值)
        // 看图,是小学数学题的求斜率哦
        slope = (coldIntervalMicros - stableIntervalMicros) / (maxPermits - thresholdPermits);

        // 这部分和稳定限流器的逻辑不一样,初始化时默认storedPermits就为maxPermits
        if (oldMaxPermits == Double.POSITIVE_INFINITY) {
            // if we don't special-case this, we would get storedPermits == NaN, below
            storedPermits = 0.0;
        } else {
            storedPermits = (oldMaxPermits == 0.0) ? maxPermits : storedPermits * maxPermits / oldMaxPermits;
        }
    }

    /**
     * 计算令牌桶的等待时间
     * 怎么计算时间呢?我们用给的这个坐标系来计算。
     * 其中纵坐标是生成令牌的间隔时间,横坐标是桶中的令牌数。
     * 我们要计算消耗掉permitsToTake个令牌数需要的时间,实际上就是求坐标系中的面积
     * 所以storedPermitsToWaitTime方法实际上就是求三种情况下的面积
     *
     * @param storedPermits 令牌桶中存在的令牌数
     * @param permitsToTake 本次消耗掉的令牌数
     * @return
     */
    @Override
    long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
        // 当前令牌桶内的令牌数和临界值的差值
        double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
        long micros = 0;
        // measuring the integral on the right part of the function (the climbing line)
        // 高于临界值,则需要根据预热算法计算时间
        if (availablePermitsAboveThreshold > 0.0) {
            double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
            // TODO(cpovirk): Figure out a good name for this variable.
            // 吐槽:你看,大佬写代码时也不会起变量名
            // 而且这个todo好几年了也没有do,所以不要写todo,这和"下次一定"没啥差别
            double length = permitsToTime(availablePermitsAboveThreshold) + permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake);
            micros = (long) (permitsAboveThresholdToTake * length / 2.0);
            permitsToTake -= permitsAboveThresholdToTake;
        }
        // measuring the integral on the left part of the function (the horizontal line)
        // 如果不走上面的分支,则说明当前限流器不处于预热阶段,那么,等待时间 = 消耗掉的令牌数 * 稳定令牌生成间隔时间

        micros += (long) (stableIntervalMicros * permitsToTake);
        return micros;
    }

    private double permitsToTime(double permits) {
        return stableIntervalMicros + permits * slope;
    }

    @Override
    double coolDownIntervalMicros() {
        return warmupPeriodMicros / maxPermits;
    }
}

对于注释中所说的简化计算maxPermits = warmupPeriodMicros / stableIntervalMicros的过程,推导如下:

image

2.获取令牌

限流器创建成功后,就可以开始使用了。无论是稳定限流器,还是预热限流器,获取令牌的方法都是一致的。

其中包括acquire方法和tryAcquire方法。

acquire

acquire方法有两个:

  • acquire(int permits):获取一定数量的令牌,等待并返回等待时间
  • acquire():等同于acquire(1)

通过源码来看看获取令牌数时都做了什么。

public double acquire(int permits) {
    // 获取本次消耗令牌桶需要等待的时间
    long microsToWait = reserve(permits);
    // 等待
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    // 返回等待时间
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
}

/**
 * 预消费令牌,并返回要等待的时间
 */
final long reserve(int permits) {
    checkPermits(permits);
    synchronized (mutex()) {
        return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
}

具体reserveAndGetWaitLength做了啥,我们暂且不表。等看完tryAcquire方法后,我们一起聊聊。

tryAcquire

tryAcquire方法一共有四个,但是实际上最终调用的都是tryAcquire(int permits, long timeout, TimeUnit unit)方法。

/**
 * 如果不能在超时时间内获取到令牌,则直接返回false
 * 如果可以,则在超时时间内等待并获取令牌,返回true
 */
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    checkPermits(permits);
    long microsToWait;
    synchronized (mutex()) {
        long nowMicros = stopwatch.readMicros();
        // 判断在超时时间内是否可以获取到令牌
        if (!canAcquire(nowMicros, timeoutMicros)) {
            return false;
        } else {
            // 获取等待时间
            microsToWait = reserveAndGetWaitLength(permits, nowMicros);
        }
    }
    // 等待
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return true;
}

简单总结一下,acquire是直接获取令牌,如果还没有到达接受下一个请求的时间点,就继续等待。而tryAcquire则如其名,我先试试,如果在我允许的时间范围内获取不到就不等了,如果能获取到那我就等着。

我们可以发现,acquire和tryAcquire方法都是通过reserveAndGetWaitLength方法获取等待时间。而reserveAndGetWaitLength方法也是根据reserveEarliestAvailable这个核心方法来计算等待时间的。所以我们就来扒一扒reserveEarliestAvailable是怎么实现的吧。

reserveEarliestAvailable

该方法主要做了以下事情:

  1. 根据当前时间,计算出桶内已有的令牌数。
  2. 根据本次请求需要的令牌数和桶内的令牌数,计算出可以直接获取的令牌数和还需要预获取的令牌数。
  3. 计算直接获取令牌数需要消耗的时间和预获取令牌数消耗的时间,计算出下一次可以获取令牌数的时间点,并返回本次请求可以获取令牌的时间点。
/**
 * Reserves next ticket and returns the wait time that the caller must wait for.
 * 预定令牌并获取等待的时间长度,acquire和tryAcquire最后调用的都是它
 *
 * @return the required wait time, never negative
 */
final long reserveAndGetWaitLength(int permits, long nowMicros) {
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);
}

/**
 * 该方法返回最早可以获取令牌数的时间点,并重新计算nextFreeTicketMicros和storedPermits
 *
 * @param requiredPermits
 * @param nowMicros
 * @return
 */
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    // 根据当前时间判断是否产生了新的令牌,如果是则更新当前时间点的桶内令牌数和下一个可获取令牌的时间点
    resync(nowMicros);
    // 本次请求可以获取令牌的时间点,那自然是nextFreeTicketMicros啦
    long returnValue = nextFreeTicketMicros;
    // 本次实际获取的令牌数量。比较一下需要的数量和当前桶中的数量,只能两者取其小了。
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    // 领取后还需要预获取的令牌数
    double freshPermits = requiredPermits - storedPermitsToSpend;

    // 分别计算storedPermitsToSpend和freshPermits的等待时间
    // 预获取的令牌还需要占用的时间 = 预获取的令牌数 * 令牌生成间隔
    // 通过storedPermitsToWaitTime方法计算本次获取令牌消耗的时间
    // 在使用默认限流器时,直接返回0。如果是预热限流器,那就需要根据限流器当前的状态计算额外需要等待的时间
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros);
    // 重置nextFreeTicketMicros,即当前的nextFreeTicketMicros+新增的等待时间
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    // 重置storedPermits,减去本次消耗的令牌数,就是当前桶中剩余的令牌数
    this.storedPermits -= storedPermitsToSpend;

    return returnValue;
}

void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    // 如果nextFreeTicket时间点已经过期了,那就更新成当前时间
    if (nowMicros > nextFreeTicketMicros) {
        // 从nextFreeTicket时间点到当前时间差,除以令牌生成时间间隔,就是这段时间产生的令牌数
        double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
        // nextFreeTicket时间点桶内存在的数据 + 这段时间新生成的令牌数,就是当前时间点桶内的令牌数。
        // 但是桶有限制,所以我们只能和桶的最大容量比较一下,取二者中较小的值,置为新的桶内存在的令牌数
        storedPermits = min(maxPermits, storedPermits + newPermits);
        // nextFreeTicketMicros置为当前时间点
        nextFreeTicketMicros = nowMicros;
    }
}

获取令牌的逻辑就是以上这些啦。

现在我们回到刚才创建限流器那节说的,限流器的storedPermitsToWaitTime方法是做什么用的。

3.计算获取令牌时间

我们回到刚才reserveEarliestAvailable方法中的这一行:

// 分别计算storedPermitsToSpend和freshPermits的等待时间
// 预获取的令牌还需要占用的时间 = 预获取的令牌数 * 令牌生成间隔
// 通过storedPermitsToWaitTime方法计算本次获取令牌消耗的时间
// 在使用默认限流器时,直接返回0。如果是预热限流器,那就需要根据限流器当前的状态计算额外需要等待的时间
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros);

这里计算的等待时间包括两部分,一部分是获取已存在令牌时需要消耗的时间,另一部分是生成预获取令牌数需要消耗的时间。

无论是稳定限流器还是预热限流器,生成预获取令牌数需要消耗的时间计算方法都是一致的,用令牌数×稳定的令牌生成间隔时间。因为都要预获取了,说明令牌桶里的令牌是不够用的,预热限流器已经进入了稳定生成令牌的阶段,所以两种限流器的算法是一致的。

而二者的差别在于如何计算已获取令牌需要消耗的时间。

对于稳定限流器来说,它是可以应对突增请求的,因此在获取已存在令牌时是不应该消耗时间的,因此SmoothBursty类中的storedPermitsToWaitTime方法直接return了0。

对于预热限流器来说,它有一个预热的阶段。如果像稳定限流器一样,获取已存在的令牌不需要消耗时间,那么他就没有了预热效果。因此它在获取桶中已存在的令牌时,是需要计算消耗时间的。这样既保证下一次请求需要等待一段时间再通过,也可以在这段时间里再生成一些令牌,进而保证他的预热效果。

我们再回过头看看预热限流器的storedPermitsToWaitTime方法是怎么实现的。仔细看代码中的注释哈。

/**
 * 计算获取令牌桶中已有令牌的等待时间
 * 怎么计算时间呢?我们用源代码中给的这个坐标系来计算。
 *          ^ throttling
 *          |
 *    cold  +                  /
 * interval |                 /.
 *          |                / .
 *          |               /  .   ← "warmup period" is the area of the trapezoid between
 *          |              /   .     thresholdPermits and maxPermits
 *          |             /    .
 *          |            /     .
 *          |           /      .
 *   stable +----------/  WARM .
 * interval |          .   UP  .
 *          |          . PERIOD.
 *          |          .       .
 *        0 +----------+-------+--------------→ storedPermits
 *          0 thresholdPermits maxPermits
 * 其中纵坐标是生成令牌的间隔时间,横坐标是桶中的令牌数。
 * 我们要计算消耗掉permitsToTake个令牌数需要的时间,实际上就是求坐标系中x轴permitsToTake这部分和图像围成的面积
 * 所以storedPermitsToWaitTime方法实际上就是求三种情况下的面积
 *
 * @param storedPermits 令牌桶中存在的令牌数
 * @param permitsToTake 本次消耗掉的令牌数
 * @return
 */
@Override
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
    // 当前令牌桶内的令牌数和临界值的差值
    double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
    long micros = 0;
    // measuring the integral on the right part of the function (the climbing line)
    // 高于临界值,则需要根据预热算法计算时间
    if (availablePermitsAboveThreshold > 0.0) {
        double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
        // TODO(cpovirk): Figure out a good name for this variable.
        // 吐槽:你看,大佬写代码时也不会起变量名
        // 而且这个todo好几年了也没有do,所以不要写todo,这和"下次一定"没啥差别
        double length = permitsToTime(availablePermitsAboveThreshold) + permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake);
        micros = (long) (permitsAboveThresholdToTake * length / 2.0);
        permitsToTake -= permitsAboveThresholdToTake;
    }
    // measuring the integral on the left part of the function (the horizontal line)
    // 如果不走上面的分支,则说明当前限流器不处于预热阶段,那么,等待时间 = 消耗掉的令牌数 * 稳定令牌生成间隔时间
    micros += (long) (stableIntervalMicros * permitsToTake);
    return micros;
}

注释中说的三种情况,就是在以下三种情况下,求函数图像和x轴之间围成的面积,如下:
第一种,限流器在预热期,获取permitsToTake个令牌后,桶中的令牌数多于thresholdPermits,如图:

image

第二种,限流器在预热期,获取permitsToTake个令牌后,桶中的令牌数少于thresholdPermits,如图:

image

第三种,限流器处于稳定期,如图:

image

这样就容易理解一些了。

三.总结

好啦,本文主要介绍了一下三种常见的限流算法:

  • 计数器法:通过限制一个时间窗口内的请求数来进行限流,请求数用计数器计算。
  • 漏桶算法:流入桶内请求的速率是动态的,但是漏出桶的速率是稳定的,通过处理漏出的请求达到限流的效果。
  • 令牌桶算法:生成令牌的速率是稳定的,只要桶内有令牌即可通过请求,通过是否可以获取令牌达到限流的效果

之后详细介绍了Guava的RateLimiter对令牌桶算法的实现

  • 预获取令牌
  • 通过计算下一次允许通过请求的时间点来限制流量
  • 有两种实现方式:平滑突发限流器和平滑预热限流器。前者匀速生成令牌,后者有预热阶段,随着请求的增多提高生成令牌的速率,直到匀速生成。
  • RateLimiter的使用还是非常广泛的,比如在美团内部使用的服务保护平台Rhino,其中限流逻辑就是基于RateLimiter实现的。

我喜欢将具体详细逻辑都用注释的方式和源码写在一起,这样讲解可以紧密一些,所以在看实现逻辑时要仔细看源码中的注释哈。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小白码上飞

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值