GUAVA RateLimiter限流器

4,531 阅读3分钟

1.什么是限流器

限流器是在高并发场景下限制流量的工具。

常见的限流器的实现方式有以下几种:

  1. 计数器算法
  2. 漏桶算法
  3. 令牌桶算法

关于三种算法的优缺点可以参考这篇博客逐行拆解Guava限流器RateLimiter

由于令牌桶算法相较于计算器算法和漏桶算法,没有明显的缺点,不会出现异常流量峰值也可以处理突发流量的情况,所以限流器实现上一般会使用令牌桶算法。

2.基于令牌桶算法实现一个限流器

下面我们基于令牌桶算法实现一个简单的限流器,限流器接口定义如下:

public interface IRateLimiter {

    /**
     * 返回true,表示拿到令牌,请求可以通过,
     * 返回falase,表示没有拿到令牌,请求不能通过
     * @return
     */
    boolean tryAcquire();

}

下面我们自定义的限流器就实现这个接口, MyRateLimiter:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class MyRateLimiter implements IRateLimiter {

    private final AtomicInteger tokenBucket;

    private final int qps;

    // 生成一个令牌需要的微秒数
    private final long mircroSecondsPerToken;

    private final ScheduledExecutorService scheduledExecutorService =
            Executors.newScheduledThreadPool(1);

    /**
     * 构造方法
     * @param qps 每秒钟请求数量
     */
    public MyRateLimiter(int qps) {
        this.qps =  qps;
        this.tokenBucket = new AtomicInteger(qps);
        // 生成一个令牌需要的微妙数
        this.mircroSecondsPerToken = 1 * 1000 * 1000 / qps;
        scheduledExecutorService.schedule(() -> {
            /**
             * 创建一个定时任务,每隔 ${mircroSecondsPerToken} 生成一个令牌
             * 虽然判断”令牌桶是否满了“和“给令牌桶中添加一枚令牌”这两个操作不是原子的,
             * 但是因为这两个操作是单线程的,且添加令牌只在这个线程中进行,所以这两步操作没有必要加锁来保证原子性
             */
            if (this.tokenBucket.get() < qps) {
                this.tokenBucket.incrementAndGet();
            }
        }, mircroSecondsPerToken, TimeUnit.MICROSECONDS);
    }

    @Override
    public boolean tryAcquire() {

        if (this.tokenBucket.get() < 1) {
            return false;
        } else {
            synchronized (this) {
                if (this.tokenBucket.get() < 1) {
                    return false;
                } else {
                    this.tokenBucket.decrementAndGet();
                }
            }
            return true;
        }
    }
}

上述实现的这个限流器其实是有缺陷的,mircroSecondsPerToken的类型是long类型,所以输入的qps必须是10^n,0<=n<=6。这里将mircroSecondsPerToken改成double类型的化,精度会更高一些,但是因为我们这里是使用ScheduledExecutorService来创建定时任务作为生产者来生产令牌,而ScheduledExecutorService的schedule方法中,时间大小只能为long,并没有提供时间单位为double的方法。

除此之外,还存在其他缺陷:我们使用一个定时线程去作为provider,来按照设置的频率往tokenBucket放令牌,当令牌桶是满且没有消费者来消费令牌,这个时候我们的provider线程还是会继续运行,尝试往tokenBucket中放入令牌,这就相当于是做无用功了,有点浪费资源。如果根据tokenBucket的状态来自动启停provider线程,又会极大的增加并发复杂度。

下面我们来看看GUAVA的RateLimiter是如何实现的,看看能否按照GUAVA RateLimiter的实现方式来优化上述我们自己的限流器。

3.GUAVA的RateLimiter

3.1.介绍

Guava RateLimiter是一个谷歌提供的限流工具,RateLimiter基于令牌桶算法,可以有效限定单个JVM实例上某个接口的流量。

3.2.关键代码分析

创建一个Guava RateLimiter,qps为2,注意这里qps的类型为double:

  // 最终rateLimiter是一个SmoothBursty实例,SmoothBursty继承自SmoothRateLimiter,记住SmoothRateLimiter,后面还会提到它
  RateLimiter rateLimiter = RateLimiter.create(2D);

  public static RateLimiter create(double permitsPerSecond) {
    return create(permitsPerSecond, SleepingStopwatch.createFromSystemTimer());
  }

  static RateLimiter create(double permitsPerSecond, SleepingStopwatch stopwatch) {
    RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
    rateLimiter.setRate(permitsPerSecond);
    return rateLimiter;
  }

获取一个令牌:

    rateLimiter.tryAcquire();

我们来看RateLimiter.tryAcquire()方法内部是如何实现:

public boolean tryAcquire() {
	// 默认获取一个令牌, 超时时间设置为0
    return tryAcquire(1, 0, MICROSECONDS);
}

public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
    // 因为是从tryAcquire()方法进来,所以这里timeoutMicros=0
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    // 检查获要获取的令牌个数,permits>0才合法
    checkPermits(permits);
    // 
    long microsToWait;
    // RateLimiter实例中的所有操作都使用同一个对象来加锁
    synchronized (mutex()) {
      // 获取当前微秒时间戳
      long nowMicros = stopwatch.readMicros();
      // canAcquire方法见下面
      if (!canAcquire(nowMicros, timeoutMicros)) {
        return false;
      } else {
        // permits在这里为1,nowMicros是当前微秒时间戳,
        // reserveAndGetWaitLength方法计算出从现在开始获取1一个令牌需要等待的时间
        microsToWait = reserveAndGetWaitLength(permits, nowMicros);
      }
    }
    // 使用SleepingStopwatch去等待指定实现,当时间到了,就表示获取到锁了,返回true
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return true;
}

// 判断当前时间(微秒时间戳),在等待指定时间后是否晚于“能获取令牌的时间”,如果晚于,返回true,表示能够获取到令牌。
// “能获取令牌的时间”保存在SmoothRateLimiter的nextFreeTicketMicros上,如果时间在nextFreeTicketMicros之前,不能获取令牌,反之,则可以。
private boolean canAcquire(long nowMicros, long timeoutMicros) {
    return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}

// 
 final long reserveAndGetWaitLength(int permits, long nowMicros) {
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);
 }

  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    // resync这个方法很关键,是guava的SmoothRateLimiter的精髓, 采用“惰性”方式创建令牌
    resync(nowMicros);
    // 返回上一个“能够开始创建令牌的时间”
    long returnValue = nextFreeTicketMicros;
    // storedPermitsToSpend = 请求令牌数和令牌桶中令牌数较小的那一个
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    // 当令牌桶中的令牌足够的话,freshPermits = 0,当令牌桶中令牌不够的话,freshPermits = 还缺少的令令牌数
    double freshPermits = requiredPermits - storedPermitsToSpend;
    // 生产出不够部分的令牌所需要的时间
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
    
    // 关键部分,限流器并没有让当前线程继续阻塞等待在这里${waitMicros}微秒,而是将${waitMicros}的等待时间加到了nextFreeTicketMicros,这样子,“下一个开始生产令牌的时间”就往后推迟了,相当于是当前请求超前消费了,然后这笔费用由之后的请求去埋单。
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    // 从令牌桶中减去实际消费掉的令牌数
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }
  
  void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      // coolDownIntervalMicros()返回的是SmoothRateLimiter中的stableIntervalMicros属性
      // stableIntervalMicros 属性表示的是当前的限流器,每隔多长创建一个令牌需要的微秒数
      // 所以计算出来的newPermits,表示从下一个开始能够开始创建令牌的时间到当前时间,一共创建出来的令牌数。
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      // 将令牌数加到SmoothRateLimiter#storedPermits上
      storedPermits = min(maxPermits, storedPermits + newPermits);
      // 将下一个能够开始创建令牌的时间更新为当前时间
      nextFreeTicketMicros = nowMicros;
    }
  }

3.3.惰性加载的思想

经过3.2章节对GUAVA RateLimiter的tryAcquire源码的分析,我们可以很明显的发现,GUAVA RateLimiter虽然也采用令牌桶的方式,但是它没有单独采用一个线程来作为provider,定时去生成令牌。而是采用了一种“惰性加载”的方式,只有当请求令牌的时候,才会去根据当前的时间戳,和令牌开始生产的时间戳,来计算当前令牌桶中应该有多少个令牌:

创建令牌的时间间隔 = 1秒 / qps    // qps即每秒钟允许被消费的令牌个数 
令牌桶中的令牌数量 = Math.min(令牌桶的容量, ( 当前时时间戳 - 令牌开始生产的时间戳 ) / 创建令牌的时间间隔)

计算出当前令牌桶中的令牌数量之后,再让consumer来消费,假如令牌数不够,就让consumer提前消费,再计算出生产这些不够的令牌数,需要的时间:

生产不够的令牌数所需要的时间 = 创建令牌的时间间隔 * 不够的令牌数

然后更新令牌开始生产的时间戳,即在令牌开始生产的时间戳之前,令牌桶中是没有令牌的,只有到了{令牌开始生产的时间戳}之前,令牌桶中是没有令牌的,只有到了{令牌开始生产的时间戳},才会生产出第一个令牌:

令牌开始生产的时间戳 = 当前的时间戳 + 生产不够的令牌数所需要的时间

之后如果要再从这个RateLimiter中请求令牌数,就会判断当前时间是否在${令牌开始生产的时间戳}之前,如果是,就请求不到令牌。

上述,GUAVA RateLimiter这种方式就是“惰性加载”思想的一种体现,当需要请求令牌的时候,再去根据时间戳来计算出当前令牌桶中应该要有的令牌数量。避免了在RateLimiter不工作的时候,系统还要分配资源给RateLimiter去进行运算的情况。

4.借鉴惰性加载的思想修改我们的限流器代码

借鉴惰性加载的思想修改我们在上文中的限流器代码

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class MyRateLimiter implements IRateLimiter {

    private double tokenBucket;

    private final double qps;

    // 生成一个令牌需要的纳秒数
    private final double nanoSecondsPerToken;

    // 开始创建令牌的纳秒时间戳
    private long timeBeginToCreateToken;

    /**
     * 构造方法
     * @param qps 每秒钟请求数量
     */
    public MyRateLimiter(double qps) {
        this.qps =  qps;
        this.tokenBucket = qps;
        // 生成一个令牌需要的微妙数
        this.nanoSecondsPerToken = 1 * 1000 * 1000 * 1000 / qps;
        this.timeBeginToCreateToken = System.nanoTime();
    }

    @Override
    public boolean tryAcquire() {
        synchronized (this) {
            long now = System.nanoTime();
            // 创建令牌
            double newToken = (now - timeBeginToCreateToken) / nanoSecondsPerToken;
            // 更新令牌桶中的令牌数
            tokenBucket = Math.min(qps, newToken + tokenBucket);
            // 更新开始创建令牌的时间
            timeBeginToCreateToken = now;

            if (tokenBucket > 0) {
                System.out.println("tokenBucket = " + tokenBucket);
                tokenBucket = tokenBucket - 1;
                return true;
            } else {
                return false;
            }
        }
    }

5.总结

GUAVA RateLimiter的实现方式中的“惰性加载”的思想很值得我们学习,在日常编码中有很多地方使用可以使用这种思想来编码,比如当一个资源不难获取,但是获取之后很占用空间或者获取之后还会继续消耗系统资源,这种情况下我们就可以使用“惰性加载”。